diff --git a/Sources/imsg/CommandRouter.swift b/Sources/imsg/CommandRouter.swift index 7d819f9..d198e6f 100644 --- a/Sources/imsg/CommandRouter.swift +++ b/Sources/imsg/CommandRouter.swift @@ -33,7 +33,7 @@ struct CommandRouter { func run(argv: [String]) async -> Int32 { let argv = normalizeArguments(argv) if argv.contains("--version") || argv.contains("-V") { - Swift.print(version) + StdoutWriter.writeLine(version) return 0 } if argv.count <= 1 || argv.contains("--help") || argv.contains("-h") { @@ -46,7 +46,7 @@ struct CommandRouter { guard let commandName = invocation.path.last, let spec = specs.first(where: { $0.name == commandName }) else { - Swift.print("Unknown command") + StdoutWriter.writeLine("Unknown command") HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs) return 1 } @@ -55,17 +55,17 @@ struct CommandRouter { try await spec.run(invocation.parsedValues, runtime) return 0 } catch { - Swift.print(error) + StdoutWriter.writeLine(String(describing: error)) return 1 } } catch let error as CommanderProgramError { - Swift.print(error.description) + StdoutWriter.writeLine(error.description) if case .missingSubcommand = error { HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs) } return 1 } catch { - Swift.print(error) + StdoutWriter.writeLine(String(describing: error)) return 1 } } diff --git a/Sources/imsg/Commands/ChatsCommand.swift b/Sources/imsg/Commands/ChatsCommand.swift index dae76dc..baa447b 100644 --- a/Sources/imsg/Commands/ChatsCommand.swift +++ b/Sources/imsg/Commands/ChatsCommand.swift @@ -26,14 +26,14 @@ enum ChatsCommand { if runtime.jsonOutput { for chat in chats { - try JSONLines.print(ChatPayload(chat: chat)) + try StdoutWriter.writeJSONLine(ChatPayload(chat: chat)) } return } for chat in chats { let last = CLIISO8601.format(chat.lastMessageAt) - Swift.print("[\(chat.id)] \(chat.name) (\(chat.identifier)) last=\(last)") + StdoutWriter.writeLine("[\(chat.id)] \(chat.name) (\(chat.identifier)) last=\(last)") } } } diff --git a/Sources/imsg/Commands/HistoryCommand.swift b/Sources/imsg/Commands/HistoryCommand.swift index 2d6c057..407eb79 100644 --- a/Sources/imsg/Commands/HistoryCommand.swift +++ b/Sources/imsg/Commands/HistoryCommand.swift @@ -57,7 +57,7 @@ enum HistoryCommand { attachments: attachments, reactions: reactions ) - try JSONLines.print(payload) + try StdoutWriter.writeJSONLine(payload) } return } @@ -65,18 +65,18 @@ enum HistoryCommand { for message in filtered { let direction = message.isFromMe ? "sent" : "recv" let timestamp = CLIISO8601.format(message.date) - Swift.print("\(timestamp) [\(direction)] \(message.sender): \(message.text)") + StdoutWriter.writeLine("\(timestamp) [\(direction)] \(message.sender): \(message.text)") if message.attachmentsCount > 0 { if showAttachments { let metas = try store.attachments(for: message.rowID) for meta in metas { let name = displayName(for: meta) - Swift.print( + StdoutWriter.writeLine( " attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)" ) } } else { - Swift.print( + StdoutWriter.writeLine( " (\(message.attachmentsCount) attachment\(pluralSuffix(for: message.attachmentsCount)))" ) } diff --git a/Sources/imsg/Commands/SendCommand.swift b/Sources/imsg/Commands/SendCommand.swift index 3c9a707..3fdad4f 100644 --- a/Sources/imsg/Commands/SendCommand.swift +++ b/Sources/imsg/Commands/SendCommand.swift @@ -91,9 +91,9 @@ enum SendCommand { )) if runtime.jsonOutput { - try JSONLines.print(["status": "sent"]) + try StdoutWriter.writeJSONLine(["status": "sent"]) } else { - Swift.print("sent") + StdoutWriter.writeLine("sent") } } } diff --git a/Sources/imsg/Commands/WatchCommand.swift b/Sources/imsg/Commands/WatchCommand.swift index abc86a7..36b2797 100644 --- a/Sources/imsg/Commands/WatchCommand.swift +++ b/Sources/imsg/Commands/WatchCommand.swift @@ -1,5 +1,4 @@ import Commander -import Darwin import Foundation import IMsgCore @@ -90,29 +89,27 @@ enum WatchCommand { attachments: attachments, reactions: reactions ) - try JSONLines.print(payload) - fflush(stdout) + try StdoutWriter.writeJSONLine(payload) continue } let direction = message.isFromMe ? "sent" : "recv" let timestamp = CLIISO8601.format(message.date) - Swift.print("\(timestamp) [\(direction)] \(message.sender): \(message.text)") + StdoutWriter.writeLine("\(timestamp) [\(direction)] \(message.sender): \(message.text)") if message.attachmentsCount > 0 { if showAttachments { let metas = try store.attachments(for: message.rowID) for meta in metas { let name = displayName(for: meta) - Swift.print( + StdoutWriter.writeLine( " attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)" ) } } else { - Swift.print( + StdoutWriter.writeLine( " (\(message.attachmentsCount) attachment\(pluralSuffix(for: message.attachmentsCount)))" ) } } - fflush(stdout) } } } diff --git a/Sources/imsg/HelpPrinter.swift b/Sources/imsg/HelpPrinter.swift index 14a9209..a0bba67 100644 --- a/Sources/imsg/HelpPrinter.swift +++ b/Sources/imsg/HelpPrinter.swift @@ -4,13 +4,13 @@ import Foundation struct HelpPrinter { static func printRoot(version: String, rootName: String, commands: [CommandSpec]) { for line in renderRoot(version: version, rootName: rootName, commands: commands) { - Swift.print(line) + StdoutWriter.writeLine(line) } } static func printCommand(rootName: String, spec: CommandSpec) { for line in renderCommand(rootName: rootName, spec: spec) { - Swift.print(line) + StdoutWriter.writeLine(line) } } diff --git a/Sources/imsg/JSONLines.swift b/Sources/imsg/JSONLines.swift index a34e46f..893a4be 100644 --- a/Sources/imsg/JSONLines.swift +++ b/Sources/imsg/JSONLines.swift @@ -15,7 +15,7 @@ enum JSONLines { static func print(_ value: T) throws { let line = try encode(value) if !line.isEmpty { - Swift.print(line) + StdoutWriter.writeLine(line) } } } diff --git a/Sources/imsg/RPCServer.swift b/Sources/imsg/RPCServer.swift index 6109005..4f0a470 100644 --- a/Sources/imsg/RPCServer.swift +++ b/Sources/imsg/RPCServer.swift @@ -286,8 +286,6 @@ private func buildMessagePayload( } private final class RPCWriter: RPCOutput, @unchecked Sendable { - private let queue = DispatchQueue(label: "imsg.rpc.writer") - func sendResponse(id: Any, result: Any) { send(["jsonrpc": "2.0", "id": id, "result": result]) } @@ -306,21 +304,15 @@ private final class RPCWriter: RPCOutput, @unchecked Sendable { } private func send(_ object: Any) { - queue.sync { - do { - let data = try JSONSerialization.data(withJSONObject: object, options: []) - if let output = String(data: data, encoding: .utf8) { - FileHandle.standardOutput.write(Data(output.utf8)) - FileHandle.standardOutput.write(Data("\n".utf8)) - } - } catch { - if let fallback = - "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"write failed\"}}\n" - .data(using: .utf8) - { - FileHandle.standardOutput.write(fallback) - } + do { + let data = try JSONSerialization.data(withJSONObject: object, options: []) + if let output = String(data: data, encoding: .utf8) { + StdoutWriter.writeLine(output) } + } catch { + StdoutWriter.writeLine( + "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"write failed\"}}" + ) } } } diff --git a/Sources/imsg/StdoutWriter.swift b/Sources/imsg/StdoutWriter.swift new file mode 100644 index 0000000..acdebef --- /dev/null +++ b/Sources/imsg/StdoutWriter.swift @@ -0,0 +1,24 @@ +import Dispatch +import Foundation + +enum StdoutWriter { + private static let queue = DispatchQueue(label: "imsg.stdout.writer") + + private static let jsonEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + return encoder + }() + + static func writeLine(_ line: String) { + queue.sync { + FileHandle.standardOutput.write(Data((line + "\n").utf8)) + } + } + + static func writeJSONLine(_ value: T) throws { + let data = try jsonEncoder.encode(value) + guard let line = String(data: data, encoding: .utf8), !line.isEmpty else { return } + writeLine(line) + } +} diff --git a/Tests/imsgTests/CommandRouterTests.swift b/Tests/imsgTests/CommandRouterTests.swift index f55f77b..82fefa0 100644 --- a/Tests/imsgTests/CommandRouterTests.swift +++ b/Tests/imsgTests/CommandRouterTests.swift @@ -4,25 +4,31 @@ import Testing @testable import imsg @Test -func commandRouterPrintsVersionFromEnv() async throws { +func commandRouterPrintsVersionFromEnv() async { setenv("IMSG_VERSION", "9.9.9-test", 1) defer { unsetenv("IMSG_VERSION") } let router = CommandRouter() #expect(router.version == "9.9.9-test") - let status = await router.run(argv: ["imsg", "--version"]) + let (_, status) = await StdoutCapture.capture { + await router.run(argv: ["imsg", "--version"]) + } #expect(status == 0) } @Test func commandRouterPrintsHelp() async { let router = CommandRouter() - let status = await router.run(argv: ["imsg", "--help"]) + let (_, status) = await StdoutCapture.capture { + await router.run(argv: ["imsg", "--help"]) + } #expect(status == 0) } @Test func commandRouterUnknownCommand() async { let router = CommandRouter() - let status = await router.run(argv: ["imsg", "nope"]) + let (_, status) = await StdoutCapture.capture { + await router.run(argv: ["imsg", "nope"]) + } #expect(status == 1) } diff --git a/Tests/imsgTests/CommandTests.swift b/Tests/imsgTests/CommandTests.swift index c389875..4e32761 100644 --- a/Tests/imsgTests/CommandTests.swift +++ b/Tests/imsgTests/CommandTests.swift @@ -100,7 +100,9 @@ func chatsCommandRunsWithJsonOutput() async throws { flags: ["jsonOutput"] ) let runtime = RuntimeOptions(parsedValues: values) - try await ChatsCommand.spec.run(values, runtime) + _ = try await StdoutCapture.capture { + try await ChatsCommand.spec.run(values, runtime) + } } @Test @@ -112,7 +114,9 @@ func historyCommandRunsWithChatID() async throws { flags: ["jsonOutput"] ) let runtime = RuntimeOptions(parsedValues: values) - try await HistoryCommand.spec.run(values, runtime) + _ = try await StdoutCapture.capture { + try await HistoryCommand.spec.run(values, runtime) + } } @Test @@ -124,7 +128,9 @@ func historyCommandRunsWithAttachmentsNonJson() async throws { flags: ["attachments"] ) let runtime = RuntimeOptions(parsedValues: values) - try await HistoryCommand.spec.run(values, runtime) + _ = try await StdoutCapture.capture { + try await HistoryCommand.spec.run(values, runtime) + } } @Test @@ -136,7 +142,9 @@ func chatsCommandRunsWithPlainOutput() async throws { flags: [] ) let runtime = RuntimeOptions(parsedValues: values) - try await ChatsCommand.spec.run(values, runtime) + _ = try await StdoutCapture.capture { + try await ChatsCommand.spec.run(values, runtime) + } } @Test @@ -204,7 +212,9 @@ func watchCommandRejectsInvalidDebounce() async { ) let runtime = RuntimeOptions(parsedValues: values) do { - try await WatchCommand.spec.run(values, runtime) + _ = try await StdoutCapture.capture { + try await WatchCommand.spec.run(values, runtime) + } #expect(Bool(false)) } catch let error as ParsedValuesError { #expect(error.description.contains("Invalid value")) @@ -251,12 +261,14 @@ func watchCommandRunsWithStubStream() async throws { continuation.finish() } } - try await WatchCommand.run( - values: values, - runtime: runtime, - storeFactory: { _ in store }, - streamProvider: streamProvider - ) + _ = try await StdoutCapture.capture { + try await WatchCommand.run( + values: values, + runtime: runtime, + storeFactory: { _ in store }, + streamProvider: streamProvider + ) + } } @Test @@ -320,12 +332,14 @@ func watchCommandRunsWithJsonOutput() async throws { continuation.finish() } } - try await WatchCommand.run( - values: values, - runtime: runtime, - storeFactory: { _ in store }, - streamProvider: streamProvider - ) + _ = try await StdoutCapture.capture { + try await WatchCommand.run( + values: values, + runtime: runtime, + storeFactory: { _ in store }, + streamProvider: streamProvider + ) + } } @Test