From f46531b65c4e5e3a24e5bb8a1e2bb115d9a72132 Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Thu, 12 Jun 2025 21:35:49 +0530 Subject: [PATCH] Add JSON output to use command --- .../SwiftlyDocs.docc/swiftly-cli-reference.md | 7 +- Sources/Swiftly/OutputSchema.swift | 57 +++++++++++++ Sources/Swiftly/Swiftly.swift | 4 +- Sources/Swiftly/Use.swift | 48 ++++++----- Sources/SwiftlyCore/OutputFormatter.swift | 44 ++++++++++ Sources/SwiftlyCore/SwiftlyCore.swift | 83 ++++++++++++------- Sources/SwiftlyCore/Terminal.swift | 28 +++++++ Tests/SwiftlyTests/SwiftlyCoreTests.swift | 82 ++++++++++++++++++ Tests/SwiftlyTests/SwiftlyTests.swift | 9 +- Tests/SwiftlyTests/UseTests.swift | 21 +++++ 10 files changed, 325 insertions(+), 58 deletions(-) create mode 100644 Sources/Swiftly/OutputSchema.swift create mode 100644 Sources/SwiftlyCore/OutputFormatter.swift create mode 100644 Sources/SwiftlyCore/Terminal.swift create mode 100644 Tests/SwiftlyTests/SwiftlyCoreTests.swift diff --git a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md index 31e2fd1e..f8c0b1f6 100644 --- a/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md +++ b/Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md @@ -152,7 +152,7 @@ Note that listing available snapshots before the latest release (major and minor Set the in-use or default toolchain. If no toolchain is provided, print the currently in-use toolchain, if any. ``` -swiftly use [--print-location] [--global-default] [--assume-yes] [--verbose] [] [--version] [--help] +swiftly use [--print-location] [--global-default] [--format=] [--assume-yes] [--verbose] [] [--version] [--help] ``` **--print-location:** @@ -165,6 +165,11 @@ swiftly use [--print-location] [--global-default] [--assume-yes] [--verbose] [:** + +*Output format (text, json)* + + **--assume-yes:** *Disable confirmation prompts by assuming 'yes'* diff --git a/Sources/Swiftly/OutputSchema.swift b/Sources/Swiftly/OutputSchema.swift new file mode 100644 index 00000000..2c1959e6 --- /dev/null +++ b/Sources/Swiftly/OutputSchema.swift @@ -0,0 +1,57 @@ +import Foundation +import SwiftlyCore + +struct LocationInfo: OutputData { + let path: String + + init(path: String) { + self.path = path + } + + var description: String { + self.path + } +} + +struct ToolchainInfo: OutputData { + let version: ToolchainVersion + let source: ToolchainSource? + + var description: String { + var message = String(describing: self.version) + if let source = source { + message += " (\(source.description))" + } + return message + } +} + +struct ToolchainSetInfo: OutputData { + let version: ToolchainVersion + let previousVersion: ToolchainVersion? + let isGlobal: Bool + let versionFile: String? + + var description: String { + var message = self.isGlobal ? "The global default toolchain has been set to `\(self.version)`" : "The file `\(self.versionFile ?? ".swift-version")` has been set to `\(self.version)`" + if let previousVersion = previousVersion { + message += " (was \(previousVersion.name))" + } + + return message + } +} + +enum ToolchainSource: Codable, CustomStringConvertible { + case swiftVersionFile(String) + case globalDefault + + var description: String { + switch self { + case let .swiftVersionFile(path): + return path + case .globalDefault: + return "default" + } + } +} diff --git a/Sources/Swiftly/Swiftly.swift b/Sources/Swiftly/Swiftly.swift index 4ef95b7a..6660b9ab 100644 --- a/Sources/Swiftly/Swiftly.swift +++ b/Sources/Swiftly/Swiftly.swift @@ -51,8 +51,8 @@ public struct Swiftly: SwiftlyCommand { ] ) - public static func createDefaultContext() -> SwiftlyCoreContext { - SwiftlyCoreContext() + public static func createDefaultContext(format: SwiftlyCore.OutputFormat = .text) -> SwiftlyCoreContext { + SwiftlyCoreContext(format: format) } /// The list of directories that swiftly needs to exist in order to execute. diff --git a/Sources/Swiftly/Use.swift b/Sources/Swiftly/Use.swift index da597ec2..6759b535 100644 --- a/Sources/Swiftly/Use.swift +++ b/Sources/Swiftly/Use.swift @@ -14,6 +14,9 @@ struct Use: SwiftlyCommand { @Flag(name: .shortAndLong, help: "Set the global default toolchain that is used when there are no .swift-version files.") var globalDefault: Bool = false + @Option(name: .long, help: "Output format (text, json)") + var format: SwiftlyCore.OutputFormat = .text + @OptionGroup var root: GlobalOptions @Argument(help: ArgumentHelp( @@ -56,7 +59,7 @@ struct Use: SwiftlyCommand { var toolchain: String? mutating func run() async throws { - try await self.run(Swiftly.createDefaultContext()) + try await self.run(Swiftly.createDefaultContext(format: self.format)) } mutating func run(_ ctx: SwiftlyCoreContext) async throws { @@ -84,21 +87,20 @@ struct Use: SwiftlyCommand { } if self.printLocation { - // Print the toolchain location and exit - await ctx.message("\(Swiftly.currentPlatform.findToolchainLocation(ctx, selectedVersion))") + let location = LocationInfo(path: "\(Swiftly.currentPlatform.findToolchainLocation(ctx, selectedVersion))") + await ctx.output(location) return } - var message = "\(selectedVersion)" - - switch result { + let source: ToolchainSource? = switch result { case let .swiftVersionFile(versionFile, _, _): - message += " (\(versionFile))" + .swiftVersionFile("\(versionFile)") case .globalDefault: - message += " (default)" + .globalDefault } - await ctx.message(message) + let toolchainInfo = ToolchainInfo(version: selectedVersion, source: source) + await ctx.output(toolchainInfo) return } @@ -110,8 +112,7 @@ struct Use: SwiftlyCommand { let selector = try ToolchainSelector(parsing: toolchain) guard let toolchain = config.listInstalledToolchains(selector: selector).max() else { - await ctx.message("No installed toolchains match \"\(toolchain)\"") - return + throw SwiftlyError(message: "No installed toolchains match \"\(toolchain)\"") } try await Self.execute(ctx, toolchain, globalDefault: self.globalDefault, assumeYes: self.root.assumeYes, &config) @@ -121,13 +122,14 @@ struct Use: SwiftlyCommand { static func execute(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, globalDefault: Bool, assumeYes: Bool = true, _ config: inout Config) async throws { let (selectedVersion, result) = try await selectToolchain(ctx, config: &config, globalDefault: globalDefault) - var message: String + let isGlobal: Bool + let configFile: String? if case let .swiftVersionFile(versionFile, _, _) = result { // We don't care in this case if there were any problems with the swift version files, just overwrite it with the new value try toolchain.name.write(to: versionFile, atomically: true) - - message = "The file `\(versionFile)` has been set to `\(toolchain)`" + isGlobal = false + configFile = "\(versionFile)" } else if let newVersionFile = try await findNewVersionFile(ctx), !globalDefault { if !assumeYes { await ctx.message("A new file `\(newVersionFile)` will be created to set the new in-use toolchain for this project. Alternatively, you can set your default globally with the `--global-default` flag. Proceed with creating this file?") @@ -139,19 +141,21 @@ struct Use: SwiftlyCommand { } try toolchain.name.write(to: newVersionFile, atomically: true) - - message = "The file `\(newVersionFile)` has been set to `\(toolchain)`" + isGlobal = false + configFile = "\(newVersionFile)" } else { config.inUse = toolchain try config.save(ctx) - message = "The global default toolchain has been set to `\(toolchain)`" - } - - if let selectedVersion { - message += " (was \(selectedVersion.name))" + isGlobal = true + configFile = nil } - await ctx.message(message) + await ctx.output(ToolchainSetInfo( + version: toolchain, + previousVersion: selectedVersion, + isGlobal: isGlobal, + versionFile: configFile + )) } static func findNewVersionFile(_ ctx: SwiftlyCoreContext) async throws -> FilePath? { diff --git a/Sources/SwiftlyCore/OutputFormatter.swift b/Sources/SwiftlyCore/OutputFormatter.swift new file mode 100644 index 00000000..dca0677c --- /dev/null +++ b/Sources/SwiftlyCore/OutputFormatter.swift @@ -0,0 +1,44 @@ +import ArgumentParser +import Foundation + +public enum OutputFormat: String, Sendable, CaseIterable, ExpressibleByArgument { + case text + case json + + public var description: String { + self.rawValue + } +} + +public protocol OutputFormatter { + func format(_ data: OutputData) -> String +} + +public protocol OutputData: Codable, CustomStringConvertible { + var description: String { get } +} + +public struct TextOutputFormatter: OutputFormatter { + public init() {} + + public func format(_ data: OutputData) -> String { + data.description + } +} + +public struct JSONOutputFormatter: OutputFormatter { + public init() {} + + public func format(_ data: OutputData) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + let jsonData = try? encoder.encode(data) + + guard let jsonData = jsonData, let result = String(data: jsonData, encoding: .utf8) else { + return "{}" + } + + return result + } +} diff --git a/Sources/SwiftlyCore/SwiftlyCore.swift b/Sources/SwiftlyCore/SwiftlyCore.swift index 3268abf3..3606a692 100644 --- a/Sources/SwiftlyCore/SwiftlyCore.swift +++ b/Sources/SwiftlyCore/SwiftlyCore.swift @@ -37,61 +37,80 @@ public struct SwiftlyCoreContext: Sendable { /// The output handler to use, if any. public var outputHandler: (any OutputHandler)? - /// The input probider to use, if any + /// The output handler for error streams + public var errorOutputHandler: (any OutputHandler)? + + /// The input provider to use, if any public var inputProvider: (any InputProvider)? - public init() { + /// The terminal info provider + public var terminal: any Terminal + + /// The format + public var format: OutputFormat = .text + + public init(format: SwiftlyCore.OutputFormat = .text) { self.httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) self.currentDirectory = fs.cwd + self.format = format + self.terminal = SystemTerminal() } public init(httpClient: SwiftlyHTTPClient) { self.httpClient = httpClient self.currentDirectory = fs.cwd + self.terminal = SystemTerminal() } /// Pass the provided string to the set output handler if any. /// If no output handler has been set, just print to stdout. - public func print(_ string: String = "", terminator: String? = nil) async { + public func print(_ string: String = "") async { guard let handler = self.outputHandler else { - if let terminator { - Swift.print(string, terminator: terminator) - } else { - Swift.print(string) - } + Swift.print(string) return } - await handler.handleOutputLine(string + (terminator ?? "")) + await handler.handleOutputLine(string) } public func message(_ string: String = "", terminator: String? = nil) async { - // Get terminal size or use default width - let terminalWidth = self.getTerminalWidth() - let wrappedString = string.isEmpty ? string : string.wrapText(to: terminalWidth) - await self.print(wrappedString, terminator: terminator) + let wrappedString = self.wrappedMessage(string) + (terminator ?? "") + + if self.format == .json { + await self.printError(wrappedString) + return + } else { + await self.print(wrappedString) + } } - /// Detects the terminal width in columns - private func getTerminalWidth() -> Int { -#if os(macOS) || os(Linux) - var size = winsize() -#if os(OpenBSD) - // TIOCGWINSZ is a complex macro, so we need the flattened value. - let tiocgwinsz = UInt(0x4008_7468) - let result = ioctl(STDOUT_FILENO, tiocgwinsz, &size) -#else - let result = ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &size) -#endif + private func wrappedMessage(_ string: String) -> String { + let terminalWidth = self.terminal.width() + return string.isEmpty ? string : string.wrapText(to: terminalWidth) + } - if result == 0 && Int(size.ws_col) > 0 { - return Int(size.ws_col) + public func printError(_ string: String = "") async { + if let handler = self.errorOutputHandler { + await handler.handleOutputLine(string) + } else { + if let data = (string + "\n").data(using: .utf8) { + try? FileHandle.standardError.write(contentsOf: data) + } } -#endif - return 80 // Default width if terminal size detection fails + } + + public func output(_ data: OutputData) async { + let formattedOutput: String + switch self.format { + case .text: + formattedOutput = TextOutputFormatter().format(data) + case .json: + formattedOutput = JSONOutputFormatter().format(data) + } + await self.print(formattedOutput) } public func readLine(prompt: String) async -> String? { - await self.print(prompt, terminator: ": \n") + await self.message(prompt, terminator: ": \n") guard let provider = self.inputProvider else { return Swift.readLine(strippingNewline: true) } @@ -99,6 +118,10 @@ public struct SwiftlyCoreContext: Sendable { } public func promptForConfirmation(defaultBehavior: Bool) async -> Bool { + if self.format == .json { + await self.message("Assuming \(defaultBehavior ? "yes" : "no") due to JSON format") + return defaultBehavior + } let options: String if defaultBehavior { options = "(Y/n)" @@ -112,7 +135,7 @@ public struct SwiftlyCoreContext: Sendable { ?? (defaultBehavior ? "y" : "n")).lowercased() guard ["y", "n", ""].contains(answer) else { - await self.print( + await self.message( "Please input either \"y\" or \"n\", or press ENTER to use the default.") continue } diff --git a/Sources/SwiftlyCore/Terminal.swift b/Sources/SwiftlyCore/Terminal.swift new file mode 100644 index 00000000..4268f19e --- /dev/null +++ b/Sources/SwiftlyCore/Terminal.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Protocol retrieving terminal properties +public protocol Terminal: Sendable { + /// Detects the terminal width in columns + func width() -> Int +} + +public struct SystemTerminal: Terminal { + /// Detects the terminal width in columns + public func width() -> Int { +#if os(macOS) || os(Linux) + var size = winsize() +#if os(OpenBSD) + // TIOCGWINSZ is a complex macro, so we need the flattened value. + let tiocgwinsz = UInt(0x4008_7468) + let result = ioctl(STDOUT_FILENO, tiocgwinsz, &size) +#else + let result = ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &size) +#endif + + if result == 0 && Int(size.ws_col) > 0 { + return Int(size.ws_col) + } +#endif + return 80 // Default width if terminal size detection fails + } +} diff --git a/Tests/SwiftlyTests/SwiftlyCoreTests.swift b/Tests/SwiftlyTests/SwiftlyCoreTests.swift new file mode 100644 index 00000000..789d72e0 --- /dev/null +++ b/Tests/SwiftlyTests/SwiftlyCoreTests.swift @@ -0,0 +1,82 @@ +import ArgumentParser +import Foundation +@testable import SwiftlyCore +import Testing + +/// Test actor for capturing output from SwiftlyCoreContext functions +actor TestOutputCapture: OutputHandler { + private(set) var outputLines: [String] = [] + + func handleOutputLine(_ string: String) { + self.outputLines.append(string) + } + + func getOutput() -> [String] { + self.outputLines + } + + func clearOutput() { + self.outputLines.removeAll() + } +} + +/// Mock Terminal for testing +struct MockTerminal: Terminal { + func width() -> Int { + 80 // Default terminal width for testing + } +} + +@Suite struct SwiftlyCoreContextTests { + @Test func testMessageText() async throws { + let handler = TestOutputCapture() + var context = SwiftlyCoreContext(format: .text) + context.outputHandler = handler + + await context.message("test message") + + let output = await handler.getOutput() + #expect(output.count == 1) + #expect(output[0].contains("test message")) + } + + @Test func testMessageJSON() async throws { + let errorHandler = TestOutputCapture() + var context = SwiftlyCoreContext(format: .json) + context.errorOutputHandler = errorHandler + + await context.message("test message") + + let output = await errorHandler.getOutput() + #expect(output.count == 1) + #expect(output[0].contains("test message")) + } + + @Test func testMessageCustomTerminator() async throws { + let handler = TestOutputCapture() + var context = SwiftlyCoreContext(format: .text) + context.outputHandler = handler + + await context.message("test message", terminator: "!") + + let output = await handler.getOutput() + #expect(output.count == 1) + #expect(output[0].contains("test message")) + #expect(output[0].hasSuffix("!")) + } + + @Test func testMessageTextWrapping() async throws { + let handler = TestOutputCapture() + var context = SwiftlyCoreContext(format: .text) + context.outputHandler = handler + context.terminal = MockTerminal() + + // Create a very long message that should be wrapped + let longMessage = String(repeating: "a ", count: 50) + await context.message(longMessage) + + let output = await handler.getOutput() + #expect(output.count == 1) + #expect(output[0] == String(repeating: "a ", count: 39) + "a\n" + String(repeating: "a ", count: 10)) + } +} diff --git a/Tests/SwiftlyTests/SwiftlyTests.swift b/Tests/SwiftlyTests/SwiftlyTests.swift index 60f78838..e0de4a37 100644 --- a/Tests/SwiftlyTests/SwiftlyTests.swift +++ b/Tests/SwiftlyTests/SwiftlyTests.swift @@ -89,7 +89,8 @@ extension SwiftlyCoreContext { mockedHomeDir: FilePath?, httpRequestExecutor: HTTPRequestExecutor, outputHandler: (any OutputHandler)?, - inputProvider: (any InputProvider)? + inputProvider: (any InputProvider)?, + format: SwiftlyCore.OutputFormat = .text ) { self.init(httpClient: SwiftlyHTTPClient(httpRequestExecutor: httpRequestExecutor)) @@ -98,6 +99,7 @@ extension SwiftlyCoreContext { self.httpClient = SwiftlyHTTPClient(httpRequestExecutor: httpRequestExecutor) self.outputHandler = outputHandler self.inputProvider = inputProvider + self.format = format } } @@ -236,7 +238,7 @@ public enum SwiftlyTests { /// Run this command, using the provided input as the stdin (in lines). Returns an array of captured /// output lines. - static func runWithMockedIO(_ commandType: T.Type, _ arguments: [String], quiet: Bool = false, input: [String]? = nil) async throws -> [String] { + static func runWithMockedIO(_ commandType: T.Type, _ arguments: [String], quiet: Bool = false, input: [String]? = nil, format: SwiftlyCore.OutputFormat = .text) async throws -> [String] { let handler = TestOutputHandler(quiet: quiet) let provider: (any InputProvider)? = if let input { TestInputProvider(lines: input) @@ -248,7 +250,8 @@ public enum SwiftlyTests { mockedHomeDir: SwiftlyTests.ctx.mockedHomeDir, httpRequestExecutor: SwiftlyTests.ctx.httpClient.httpRequestExecutor, outputHandler: handler, - inputProvider: provider + inputProvider: provider, + format: format ) let rawCmd = try Swiftly.parseAsRoot(arguments) diff --git a/Tests/SwiftlyTests/UseTests.swift b/Tests/SwiftlyTests/UseTests.swift index 9297f074..e2b0f23e 100644 --- a/Tests/SwiftlyTests/UseTests.swift +++ b/Tests/SwiftlyTests/UseTests.swift @@ -256,4 +256,25 @@ import Testing #expect(ToolchainVersion.newStable.name == versionFileContents) } } + + /// Tests that running a use command without an argument prints the currently in-use toolchain. + @Test(.mockedSwiftlyVersion()) func printInUseJsonFormat() async throws { + let toolchains = [ + ToolchainVersion.newStable, + .newMainSnapshot, + .newReleaseSnapshot, + ] + try await SwiftlyTests.withMockedHome(homeName: Self.homeName, toolchains: Set(toolchains)) { + let decoder = JSONDecoder() + for toolchain in toolchains { + var output = try await SwiftlyTests.runWithMockedIO(Use.self, ["use", "-g", "--format", "json", toolchain.name], format: .json) + let result = try decoder.decode(ToolchainSetInfo.self, from: output[0].data(using: .utf8)!) + #expect(result.version == toolchain) + + output = try await SwiftlyTests.runWithMockedIO(Use.self, ["use", "-g", "--print-location", "--format", "json"], format: .json) + let result2 = try decoder.decode(LocationInfo.self, from: output[0].data(using: .utf8)!) + #expect(result2.path == Swiftly.currentPlatform.findToolchainLocation(SwiftlyTests.ctx, toolchain).string) + } + } + } }