Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Sources/imsg/CommandRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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
}
Expand All @@ -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
}
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/imsg/Commands/ChatsCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
}
}
8 changes: 4 additions & 4 deletions Sources/imsg/Commands/HistoryCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,26 +57,26 @@ enum HistoryCommand {
attachments: attachments,
reactions: reactions
)
try JSONLines.print(payload)
try StdoutWriter.writeJSONLine(payload)
}
return
}

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)))"
)
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/imsg/Commands/SendCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
11 changes: 4 additions & 7 deletions Sources/imsg/Commands/WatchCommand.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Commander
import Darwin
import Foundation
import IMsgCore

Expand Down Expand Up @@ -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)
}
}
}
4 changes: 2 additions & 2 deletions Sources/imsg/HelpPrinter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/imsg/JSONLines.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ enum JSONLines {
static func print<T: Encodable>(_ value: T) throws {
let line = try encode(value)
if !line.isEmpty {
Swift.print(line)
StdoutWriter.writeLine(line)
}
}
}
24 changes: 8 additions & 16 deletions Sources/imsg/RPCServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
Expand All @@ -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\"}}"
)
}
}
}
Expand Down
24 changes: 24 additions & 0 deletions Sources/imsg/StdoutWriter.swift
Original file line number Diff line number Diff line change
@@ -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<T: Encodable>(_ value: T) throws {
let data = try jsonEncoder.encode(value)
guard let line = String(data: data, encoding: .utf8), !line.isEmpty else { return }
writeLine(line)
}
}
14 changes: 10 additions & 4 deletions Tests/imsgTests/CommandRouterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
48 changes: 31 additions & 17 deletions Tests/imsgTests/CommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down