Skip to content

Commit fb74b77

Browse files
authored
Add JSON output for use command (#380)
Add JSON output to use command
1 parent ab6757f commit fb74b77

File tree

10 files changed

+325
-58
lines changed

10 files changed

+325
-58
lines changed

Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ Note that listing available snapshots before the latest release (major and minor
152152
Set the in-use or default toolchain. If no toolchain is provided, print the currently in-use toolchain, if any.
153153

154154
```
155-
swiftly use [--print-location] [--global-default] [--assume-yes] [--verbose] [<toolchain>] [--version] [--help]
155+
swiftly use [--print-location] [--global-default] [--format=<format>] [--assume-yes] [--verbose] [<toolchain>] [--version] [--help]
156156
```
157157

158158
**--print-location:**
@@ -165,6 +165,11 @@ swiftly use [--print-location] [--global-default] [--assume-yes] [--verbose] [<t
165165
*Set the global default toolchain that is used when there are no .swift-version files.*
166166

167167

168+
**--format=\<format\>:**
169+
170+
*Output format (text, json)*
171+
172+
168173
**--assume-yes:**
169174

170175
*Disable confirmation prompts by assuming 'yes'*

Sources/Swiftly/OutputSchema.swift

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import Foundation
2+
import SwiftlyCore
3+
4+
struct LocationInfo: OutputData {
5+
let path: String
6+
7+
init(path: String) {
8+
self.path = path
9+
}
10+
11+
var description: String {
12+
self.path
13+
}
14+
}
15+
16+
struct ToolchainInfo: OutputData {
17+
let version: ToolchainVersion
18+
let source: ToolchainSource?
19+
20+
var description: String {
21+
var message = String(describing: self.version)
22+
if let source = source {
23+
message += " (\(source.description))"
24+
}
25+
return message
26+
}
27+
}
28+
29+
struct ToolchainSetInfo: OutputData {
30+
let version: ToolchainVersion
31+
let previousVersion: ToolchainVersion?
32+
let isGlobal: Bool
33+
let versionFile: String?
34+
35+
var description: String {
36+
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)`"
37+
if let previousVersion = previousVersion {
38+
message += " (was \(previousVersion.name))"
39+
}
40+
41+
return message
42+
}
43+
}
44+
45+
enum ToolchainSource: Codable, CustomStringConvertible {
46+
case swiftVersionFile(String)
47+
case globalDefault
48+
49+
var description: String {
50+
switch self {
51+
case let .swiftVersionFile(path):
52+
return path
53+
case .globalDefault:
54+
return "default"
55+
}
56+
}
57+
}

Sources/Swiftly/Swiftly.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ public struct Swiftly: SwiftlyCommand {
5151
]
5252
)
5353

54-
public static func createDefaultContext() -> SwiftlyCoreContext {
55-
SwiftlyCoreContext()
54+
public static func createDefaultContext(format: SwiftlyCore.OutputFormat = .text) -> SwiftlyCoreContext {
55+
SwiftlyCoreContext(format: format)
5656
}
5757

5858
/// The list of directories that swiftly needs to exist in order to execute.

Sources/Swiftly/Use.swift

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ struct Use: SwiftlyCommand {
1414
@Flag(name: .shortAndLong, help: "Set the global default toolchain that is used when there are no .swift-version files.")
1515
var globalDefault: Bool = false
1616

17+
@Option(name: .long, help: "Output format (text, json)")
18+
var format: SwiftlyCore.OutputFormat = .text
19+
1720
@OptionGroup var root: GlobalOptions
1821

1922
@Argument(help: ArgumentHelp(
@@ -56,7 +59,7 @@ struct Use: SwiftlyCommand {
5659
var toolchain: String?
5760

5861
mutating func run() async throws {
59-
try await self.run(Swiftly.createDefaultContext())
62+
try await self.run(Swiftly.createDefaultContext(format: self.format))
6063
}
6164

6265
mutating func run(_ ctx: SwiftlyCoreContext) async throws {
@@ -84,21 +87,20 @@ struct Use: SwiftlyCommand {
8487
}
8588

8689
if self.printLocation {
87-
// Print the toolchain location and exit
88-
await ctx.message("\(Swiftly.currentPlatform.findToolchainLocation(ctx, selectedVersion))")
90+
let location = LocationInfo(path: "\(Swiftly.currentPlatform.findToolchainLocation(ctx, selectedVersion))")
91+
await ctx.output(location)
8992
return
9093
}
9194

92-
var message = "\(selectedVersion)"
93-
94-
switch result {
95+
let source: ToolchainSource? = switch result {
9596
case let .swiftVersionFile(versionFile, _, _):
96-
message += " (\(versionFile))"
97+
.swiftVersionFile("\(versionFile)")
9798
case .globalDefault:
98-
message += " (default)"
99+
.globalDefault
99100
}
100101

101-
await ctx.message(message)
102+
let toolchainInfo = ToolchainInfo(version: selectedVersion, source: source)
103+
await ctx.output(toolchainInfo)
102104

103105
return
104106
}
@@ -110,8 +112,7 @@ struct Use: SwiftlyCommand {
110112
let selector = try ToolchainSelector(parsing: toolchain)
111113

112114
guard let toolchain = config.listInstalledToolchains(selector: selector).max() else {
113-
await ctx.message("No installed toolchains match \"\(toolchain)\"")
114-
return
115+
throw SwiftlyError(message: "No installed toolchains match \"\(toolchain)\"")
115116
}
116117

117118
try await Self.execute(ctx, toolchain, globalDefault: self.globalDefault, assumeYes: self.root.assumeYes, &config)
@@ -121,13 +122,14 @@ struct Use: SwiftlyCommand {
121122
static func execute(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, globalDefault: Bool, assumeYes: Bool = true, _ config: inout Config) async throws {
122123
let (selectedVersion, result) = try await selectToolchain(ctx, config: &config, globalDefault: globalDefault)
123124

124-
var message: String
125+
let isGlobal: Bool
126+
let configFile: String?
125127

126128
if case let .swiftVersionFile(versionFile, _, _) = result {
127129
// We don't care in this case if there were any problems with the swift version files, just overwrite it with the new value
128130
try toolchain.name.write(to: versionFile, atomically: true)
129-
130-
message = "The file `\(versionFile)` has been set to `\(toolchain)`"
131+
isGlobal = false
132+
configFile = "\(versionFile)"
131133
} else if let newVersionFile = try await findNewVersionFile(ctx), !globalDefault {
132134
if !assumeYes {
133135
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 {
139141
}
140142

141143
try toolchain.name.write(to: newVersionFile, atomically: true)
142-
143-
message = "The file `\(newVersionFile)` has been set to `\(toolchain)`"
144+
isGlobal = false
145+
configFile = "\(newVersionFile)"
144146
} else {
145147
config.inUse = toolchain
146148
try config.save(ctx)
147-
message = "The global default toolchain has been set to `\(toolchain)`"
148-
}
149-
150-
if let selectedVersion {
151-
message += " (was \(selectedVersion.name))"
149+
isGlobal = true
150+
configFile = nil
152151
}
153152

154-
await ctx.message(message)
153+
await ctx.output(ToolchainSetInfo(
154+
version: toolchain,
155+
previousVersion: selectedVersion,
156+
isGlobal: isGlobal,
157+
versionFile: configFile
158+
))
155159
}
156160

157161
static func findNewVersionFile(_ ctx: SwiftlyCoreContext) async throws -> FilePath? {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import ArgumentParser
2+
import Foundation
3+
4+
public enum OutputFormat: String, Sendable, CaseIterable, ExpressibleByArgument {
5+
case text
6+
case json
7+
8+
public var description: String {
9+
self.rawValue
10+
}
11+
}
12+
13+
public protocol OutputFormatter {
14+
func format(_ data: OutputData) -> String
15+
}
16+
17+
public protocol OutputData: Codable, CustomStringConvertible {
18+
var description: String { get }
19+
}
20+
21+
public struct TextOutputFormatter: OutputFormatter {
22+
public init() {}
23+
24+
public func format(_ data: OutputData) -> String {
25+
data.description
26+
}
27+
}
28+
29+
public struct JSONOutputFormatter: OutputFormatter {
30+
public init() {}
31+
32+
public func format(_ data: OutputData) -> String {
33+
let encoder = JSONEncoder()
34+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
35+
36+
let jsonData = try? encoder.encode(data)
37+
38+
guard let jsonData = jsonData, let result = String(data: jsonData, encoding: .utf8) else {
39+
return "{}"
40+
}
41+
42+
return result
43+
}
44+
}

Sources/SwiftlyCore/SwiftlyCore.swift

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -37,68 +37,91 @@ public struct SwiftlyCoreContext: Sendable {
3737
/// The output handler to use, if any.
3838
public var outputHandler: (any OutputHandler)?
3939

40-
/// The input probider to use, if any
40+
/// The output handler for error streams
41+
public var errorOutputHandler: (any OutputHandler)?
42+
43+
/// The input provider to use, if any
4144
public var inputProvider: (any InputProvider)?
4245

43-
public init() {
46+
/// The terminal info provider
47+
public var terminal: any Terminal
48+
49+
/// The format
50+
public var format: OutputFormat = .text
51+
52+
public init(format: SwiftlyCore.OutputFormat = .text) {
4453
self.httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl())
4554
self.currentDirectory = fs.cwd
55+
self.format = format
56+
self.terminal = SystemTerminal()
4657
}
4758

4859
public init(httpClient: SwiftlyHTTPClient) {
4960
self.httpClient = httpClient
5061
self.currentDirectory = fs.cwd
62+
self.terminal = SystemTerminal()
5163
}
5264

5365
/// Pass the provided string to the set output handler if any.
5466
/// If no output handler has been set, just print to stdout.
55-
public func print(_ string: String = "", terminator: String? = nil) async {
67+
public func print(_ string: String = "") async {
5668
guard let handler = self.outputHandler else {
57-
if let terminator {
58-
Swift.print(string, terminator: terminator)
59-
} else {
60-
Swift.print(string)
61-
}
69+
Swift.print(string)
6270
return
6371
}
64-
await handler.handleOutputLine(string + (terminator ?? ""))
72+
await handler.handleOutputLine(string)
6573
}
6674

6775
public func message(_ string: String = "", terminator: String? = nil) async {
68-
// Get terminal size or use default width
69-
let terminalWidth = self.getTerminalWidth()
70-
let wrappedString = string.isEmpty ? string : string.wrapText(to: terminalWidth)
71-
await self.print(wrappedString, terminator: terminator)
76+
let wrappedString = self.wrappedMessage(string) + (terminator ?? "")
77+
78+
if self.format == .json {
79+
await self.printError(wrappedString)
80+
return
81+
} else {
82+
await self.print(wrappedString)
83+
}
7284
}
7385

74-
/// Detects the terminal width in columns
75-
private func getTerminalWidth() -> Int {
76-
#if os(macOS) || os(Linux)
77-
var size = winsize()
78-
#if os(OpenBSD)
79-
// TIOCGWINSZ is a complex macro, so we need the flattened value.
80-
let tiocgwinsz = UInt(0x4008_7468)
81-
let result = ioctl(STDOUT_FILENO, tiocgwinsz, &size)
82-
#else
83-
let result = ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &size)
84-
#endif
86+
private func wrappedMessage(_ string: String) -> String {
87+
let terminalWidth = self.terminal.width()
88+
return string.isEmpty ? string : string.wrapText(to: terminalWidth)
89+
}
8590

86-
if result == 0 && Int(size.ws_col) > 0 {
87-
return Int(size.ws_col)
91+
public func printError(_ string: String = "") async {
92+
if let handler = self.errorOutputHandler {
93+
await handler.handleOutputLine(string)
94+
} else {
95+
if let data = (string + "\n").data(using: .utf8) {
96+
try? FileHandle.standardError.write(contentsOf: data)
97+
}
8898
}
89-
#endif
90-
return 80 // Default width if terminal size detection fails
99+
}
100+
101+
public func output(_ data: OutputData) async {
102+
let formattedOutput: String
103+
switch self.format {
104+
case .text:
105+
formattedOutput = TextOutputFormatter().format(data)
106+
case .json:
107+
formattedOutput = JSONOutputFormatter().format(data)
108+
}
109+
await self.print(formattedOutput)
91110
}
92111

93112
public func readLine(prompt: String) async -> String? {
94-
await self.print(prompt, terminator: ": \n")
113+
await self.message(prompt, terminator: ": \n")
95114
guard let provider = self.inputProvider else {
96115
return Swift.readLine(strippingNewline: true)
97116
}
98117
return await provider.readLine()
99118
}
100119

101120
public func promptForConfirmation(defaultBehavior: Bool) async -> Bool {
121+
if self.format == .json {
122+
await self.message("Assuming \(defaultBehavior ? "yes" : "no") due to JSON format")
123+
return defaultBehavior
124+
}
102125
let options: String
103126
if defaultBehavior {
104127
options = "(Y/n)"
@@ -112,7 +135,7 @@ public struct SwiftlyCoreContext: Sendable {
112135
?? (defaultBehavior ? "y" : "n")).lowercased()
113136

114137
guard ["y", "n", ""].contains(answer) else {
115-
await self.print(
138+
await self.message(
116139
"Please input either \"y\" or \"n\", or press ENTER to use the default.")
117140
continue
118141
}

Sources/SwiftlyCore/Terminal.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import Foundation
2+
3+
/// Protocol retrieving terminal properties
4+
public protocol Terminal: Sendable {
5+
/// Detects the terminal width in columns
6+
func width() -> Int
7+
}
8+
9+
public struct SystemTerminal: Terminal {
10+
/// Detects the terminal width in columns
11+
public func width() -> Int {
12+
#if os(macOS) || os(Linux)
13+
var size = winsize()
14+
#if os(OpenBSD)
15+
// TIOCGWINSZ is a complex macro, so we need the flattened value.
16+
let tiocgwinsz = UInt(0x4008_7468)
17+
let result = ioctl(STDOUT_FILENO, tiocgwinsz, &size)
18+
#else
19+
let result = ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &size)
20+
#endif
21+
22+
if result == 0 && Int(size.ws_col) > 0 {
23+
return Int(size.ws_col)
24+
}
25+
#endif
26+
return 80 // Default width if terminal size detection fails
27+
}
28+
}

0 commit comments

Comments
 (0)