Skip to content

Commit 4a65097

Browse files
committed
Refactor output formatting and context handling with improved JSON support
1 parent e079937 commit 4a65097

File tree

9 files changed

+216
-87
lines changed

9 files changed

+216
-87
lines changed

Sources/Swiftly/OutputSchema.swift

Lines changed: 10 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,16 @@ struct LocationInfo: OutputData {
99
}
1010

1111
var description: String {
12-
return self.path
12+
self.path
1313
}
1414
}
1515

1616
struct ToolchainInfo: OutputData {
17-
let version: String
17+
let version: ToolchainVersion
1818
let source: ToolchainSource?
1919

20-
init(version: String, source: ToolchainSource? = nil) {
21-
self.version = version
22-
self.source = source
23-
}
24-
2520
var description: String {
26-
var message = self.version
21+
var message = String(describing: self.version)
2722
if let source = source {
2823
message += " (\(source.description))"
2924
}
@@ -32,36 +27,22 @@ struct ToolchainInfo: OutputData {
3227
}
3328

3429
struct ToolchainSetInfo: OutputData {
35-
let version: String
36-
let previousVersion: String?
30+
let version: ToolchainVersion
31+
let previousVersion: ToolchainVersion?
3732
let isGlobal: Bool
38-
let configFile: String?
39-
40-
init(
41-
version: String, previousVersion: String? = nil, isGlobal: Bool, configFile: String? = nil
42-
) {
43-
self.version = version
44-
self.previousVersion = previousVersion
45-
self.isGlobal = isGlobal
46-
self.configFile = configFile
47-
}
33+
let versionFile: String?
4834

4935
var description: String {
50-
var message = self.version
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)`"
5137
if let previousVersion = previousVersion {
52-
message += " (previous: \(previousVersion))"
53-
}
54-
if isGlobal {
55-
message += " (global)"
56-
}
57-
if let configFile = configFile {
58-
message += " (config: \(configFile))"
38+
message += " (was \(previousVersion.name))"
5939
}
40+
6041
return message
6142
}
6243
}
6344

64-
enum ToolchainSource: Encodable, CustomStringConvertible {
45+
enum ToolchainSource: Codable, CustomStringConvertible {
6546
case swiftVersionFile(String)
6647
case globalDefault
6748

Sources/Swiftly/Swiftly.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ public struct Swiftly: SwiftlyCommand {
5454
]
5555
)
5656

57-
public static func createDefaultContext() -> SwiftlyCoreContext {
58-
SwiftlyCoreContext()
57+
public static func createDefaultContext(format: SwiftlyCore.OutputFormat = .text) -> SwiftlyCoreContext {
58+
SwiftlyCoreContext(format: format)
5959
}
6060

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

Sources/Swiftly/Use.swift

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ struct Use: SwiftlyCommand {
5656
var toolchain: String?
5757

5858
mutating func run() async throws {
59-
try await self.run(Swiftly.createDefaultContext())
59+
try await self.run(Swiftly.createDefaultContext(format: self.root.format))
6060
}
6161

6262
mutating func run(_ ctx: SwiftlyCoreContext) async throws {
@@ -69,8 +69,6 @@ struct Use: SwiftlyCommand {
6969

7070
try await validateLinked(ctx)
7171

72-
let formatter: OutputFormatter = self.root.format == .json ? JSONOutputFormatter() : TextOutputFormatter()
73-
7472
// This is the bare use command where we print the selected toolchain version (or the path to it)
7573
guard let toolchain = self.toolchain else {
7674
let (selectedVersion, result) = try await selectToolchain(ctx, config: &config, globalDefault: self.globalDefault)
@@ -86,9 +84,8 @@ struct Use: SwiftlyCommand {
8684
}
8785

8886
if self.printLocation {
89-
let location = "\(Swiftly.currentPlatform.findToolchainLocation(ctx, selectedVersion))"
90-
let output = formatter.format(LocationInfo(path: location))
91-
await ctx.print(output)
87+
let location = LocationInfo(path: "\(Swiftly.currentPlatform.findToolchainLocation(ctx, selectedVersion))")
88+
await ctx.output(location)
9289
return
9390
}
9491

@@ -99,9 +96,8 @@ struct Use: SwiftlyCommand {
9996
.globalDefault
10097
}
10198

102-
let toolchainInfo = ToolchainInfo(version: "\(selectedVersion)", source: source)
103-
let output = formatter.format(toolchainInfo)
104-
await ctx.print(output)
99+
let toolchainInfo = ToolchainInfo(version: selectedVersion, source: source)
100+
await ctx.output(toolchainInfo)
105101

106102
return
107103
}
@@ -116,14 +112,13 @@ struct Use: SwiftlyCommand {
116112
throw SwiftlyError(message: "No installed toolchains match \"\(toolchain)\"")
117113
}
118114

119-
try await Self.execute(ctx, toolchain, globalDefault: self.globalDefault, assumeYes: self.root.assumeYes, formatter: formatter, &config)
115+
try await Self.execute(ctx, toolchain, globalDefault: self.globalDefault, assumeYes: self.root.assumeYes, &config)
120116
}
121117

122118
/// Use a toolchain. This method can modify and save the input config and also create/modify a `.swift-version` file.
123-
static func execute(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, globalDefault: Bool, assumeYes: Bool = true, formatter: OutputFormatter? = nil, _ config: inout Config) async throws {
119+
static func execute(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, globalDefault: Bool, assumeYes: Bool = true, _ config: inout Config) async throws {
124120
let (selectedVersion, result) = try await selectToolchain(ctx, config: &config, globalDefault: globalDefault)
125121

126-
let outputFormatter = formatter ?? TextOutputFormatter()
127122
let isGlobal: Bool
128123
let configFile: String?
129124

@@ -152,15 +147,12 @@ struct Use: SwiftlyCommand {
152147
configFile = nil
153148
}
154149

155-
let setInfo = ToolchainSetInfo(
156-
version: "\(toolchain)",
157-
previousVersion: selectedVersion?.name,
150+
await ctx.output(ToolchainSetInfo(
151+
version: toolchain,
152+
previousVersion: selectedVersion,
158153
isGlobal: isGlobal,
159-
configFile: configFile
160-
)
161-
162-
let output = outputFormatter.format(setInfo)
163-
await ctx.print(output)
154+
versionFile: configFile
155+
))
164156
}
165157

166158
static func findNewVersionFile(_ ctx: SwiftlyCoreContext) async throws -> FilePath? {

Sources/SwiftlyCore/OutputFormatter.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import ArgumentParser
22
import Foundation
33

4-
public enum OutputFormat: String, CaseIterable, ExpressibleByArgument {
4+
public enum OutputFormat: String, Sendable, CaseIterable, ExpressibleByArgument {
55
case text
66
case json
77

@@ -14,7 +14,7 @@ public protocol OutputFormatter {
1414
func format(_ data: OutputData) -> String
1515
}
1616

17-
public protocol OutputData: Encodable, CustomStringConvertible {
17+
public protocol OutputData: Codable, CustomStringConvertible {
1818
var description: String { get }
1919
}
2020

Sources/SwiftlyCore/SwiftlyCore.swift

Lines changed: 53 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -37,69 +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 {
56-
67+
public func print(_ string: String = "") async {
5768
guard let handler = self.outputHandler else {
58-
if let terminator {
59-
Swift.print(string, terminator: terminator)
60-
} else {
61-
Swift.print(string)
62-
}
69+
Swift.print(string)
6370
return
6471
}
65-
await handler.handleOutputLine(string + (terminator ?? ""))
72+
await handler.handleOutputLine(string)
6673
}
6774

6875
public func message(_ string: String = "", terminator: String? = nil) async {
69-
// Get terminal size or use default width
70-
let terminalWidth = self.getTerminalWidth()
71-
let wrappedString = string.isEmpty ? string : string.wrapText(to: terminalWidth)
72-
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+
}
7384
}
7485

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

87-
if result == 0 && Int(size.ws_col) > 0 {
88-
return Int(size.ws_col)
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)
89108
}
90-
#endif
91-
return 80 // Default width if terminal size detection fails
109+
await self.print(formattedOutput)
92110
}
93111

94112
public func readLine(prompt: String) async -> String? {
95-
await self.print(prompt, terminator: ": \n")
113+
await self.message(prompt, terminator: ": \n")
96114
guard let provider = self.inputProvider else {
97115
return Swift.readLine(strippingNewline: true)
98116
}
99117
return await provider.readLine()
100118
}
101119

102120
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+
}
103125
let options: String
104126
if defaultBehavior {
105127
options = "(Y/n)"
@@ -113,7 +135,7 @@ public struct SwiftlyCoreContext: Sendable {
113135
?? (defaultBehavior ? "y" : "n")).lowercased()
114136

115137
guard ["y", "n", ""].contains(answer) else {
116-
await self.print(
138+
await self.message(
117139
"Please input either \"y\" or \"n\", or press ENTER to use the default.")
118140
continue
119141
}

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)