@lewishowles/cli-style 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,223 @@
1
+ import Foundation
2
+ #if canImport(Darwin)
3
+ import Darwin
4
+ #elseif canImport(Glibc)
5
+ import Glibc
6
+ #endif
7
+
8
+ /**
9
+ * Errors raised while the cli-style adapter resolves or runs the renderer.
10
+ */
11
+ public enum CliStyleError: Error {
12
+ case notFound(String)
13
+ case renderFailed(String)
14
+ case invalidInput(String)
15
+ }
16
+
17
+ /**
18
+ * Options for controlling cli-style render behaviour.
19
+ */
20
+ public struct CliStyleOptions {
21
+ // Binary path or command name.
22
+ public var binary: String = "cli-style"
23
+
24
+ // Profile name such as human, agent, or json.
25
+ public var profile: String? = nil
26
+
27
+ // Terminal width in characters.
28
+ public var width: Int? = nil
29
+
30
+ // Force plain text output.
31
+ public var isPlain: Bool = false
32
+
33
+ // Disable colour output.
34
+ public var isNoColour: Bool = false
35
+
36
+ // Disable Unicode symbols.
37
+ public var isNoUnicode: Bool = false
38
+
39
+ // Additional arguments passed through to the binary.
40
+ public var extraArgs: [String] = []
41
+
42
+ // Throw when the cli-style binary cannot be found, instead of returning a plain-text rendering of the data.
43
+ public var throwsWhenMissing: Bool = false
44
+
45
+ /**
46
+ * Create render options. Every argument has a default, so callers only pass what they change.
47
+ */
48
+ public init(
49
+ binary: String = "cli-style",
50
+ profile: String? = nil,
51
+ width: Int? = nil,
52
+ isPlain: Bool = false,
53
+ isNoColour: Bool = false,
54
+ isNoUnicode: Bool = false,
55
+ extraArgs: [String] = [],
56
+ throwsWhenMissing: Bool = false
57
+ ) {
58
+ self.binary = binary
59
+ self.profile = profile
60
+ self.width = width
61
+ self.isPlain = isPlain
62
+ self.isNoColour = isNoColour
63
+ self.isNoUnicode = isNoUnicode
64
+ self.extraArgs = extraArgs
65
+ self.throwsWhenMissing = throwsWhenMissing
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Render caller data through the shared cli-style binary.
71
+ */
72
+ public enum CliStyle {
73
+
74
+ /**
75
+ Render a dictionary through `cli-style render`.
76
+
77
+ When the binary cannot be found, return a plain-text rendering of the data instead, unless `options.throwsWhenMissing` is set.
78
+
79
+ - parameter renderer: Stable renderer name accepted by `cli-style render`.
80
+ - parameter data: JSON-serialisable renderer input.
81
+ - parameter options: Render options including binary path and output flags.
82
+ - returns: Rendered string output from cli-style.
83
+ - throws: `CliStyleError` if the binary is missing and `options.throwsWhenMissing` is set, input is invalid, or rendering fails.
84
+ */
85
+ public static func render(
86
+ _ renderer: String,
87
+ data: [String: Any],
88
+ options: CliStyleOptions = .init()
89
+ ) throws -> String {
90
+ if renderer.isEmpty {
91
+ throw CliStyleError.invalidInput("renderer must be a non-empty string")
92
+ }
93
+
94
+ let resolvedBinary: String
95
+
96
+ do {
97
+ resolvedBinary = try resolveBinary(options.binary)
98
+ } catch CliStyleError.notFound(let message) {
99
+ if options.throwsWhenMissing {
100
+ throw CliStyleError.notFound(message)
101
+ }
102
+
103
+ // Swift dictionaries have no order, so sort the keys to keep the output the same between runs.
104
+ return data
105
+ .sorted { $0.key < $1.key }
106
+ .map { "\($0.key): \(String(describing: $0.value))" }
107
+ .joined(separator: "\n")
108
+ }
109
+
110
+ let jsonData: Data
111
+
112
+ do {
113
+ jsonData = try JSONSerialization.data(withJSONObject: data)
114
+ } catch {
115
+ throw CliStyleError.invalidInput("data is not JSON-serialisable")
116
+ }
117
+
118
+ var arguments = ["render", renderer]
119
+
120
+ if let profile = options.profile {
121
+ arguments += ["--profile", profile]
122
+ }
123
+
124
+ if let width = options.width {
125
+ arguments += ["--width", String(width)]
126
+ }
127
+
128
+ if options.isPlain {
129
+ arguments.append("--plain")
130
+ }
131
+
132
+ if options.isNoColour {
133
+ arguments.append("--no-colour")
134
+ }
135
+
136
+ if options.isNoUnicode {
137
+ arguments.append("--no-unicode")
138
+ }
139
+
140
+ arguments += options.extraArgs
141
+
142
+ let process = Process()
143
+ process.executableURL = URL(fileURLWithPath: resolvedBinary)
144
+ process.arguments = arguments
145
+ process.environment = resolveProcessEnvironment()
146
+
147
+ let stdinPipe = Pipe()
148
+ let stdoutPipe = Pipe()
149
+ let stderrPipe = Pipe()
150
+ process.standardInput = stdinPipe
151
+ process.standardOutput = stdoutPipe
152
+ process.standardError = stderrPipe
153
+
154
+ do {
155
+ try process.run()
156
+ } catch {
157
+ throw CliStyleError.renderFailed("failed to launch cli-style: \(error.localizedDescription)")
158
+ }
159
+
160
+ stdinPipe.fileHandleForWriting.write(jsonData)
161
+ stdinPipe.fileHandleForWriting.closeFile()
162
+
163
+ process.waitUntilExit()
164
+
165
+ let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
166
+ let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
167
+
168
+ if process.terminationStatus != 0 {
169
+ let stderrText = String(data: stderrData, encoding: .utf8)?
170
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
171
+ let message = stderrText.isEmpty
172
+ ? "cli-style render failed with exit code \(process.terminationStatus)"
173
+ : stderrText
174
+
175
+ throw CliStyleError.renderFailed(message)
176
+ }
177
+
178
+ let stdoutText = String(data: stdoutData, encoding: .utf8) ?? ""
179
+ return stdoutText.trimmingCharacters(in: .newlines)
180
+ }
181
+
182
+ /**
183
+ * Resolve the child environment for a captured render.
184
+ */
185
+ private static func resolveProcessEnvironment() -> [String: String] {
186
+ var environment = ProcessInfo.processInfo.environment
187
+
188
+ if isatty(STDOUT_FILENO) == 1 &&
189
+ environment["FORCE_COLOR"] == nil &&
190
+ environment["NO_COLOR"] == nil {
191
+ environment["FORCE_COLOR"] = "1"
192
+ }
193
+
194
+ return environment
195
+ }
196
+
197
+ /**
198
+ Resolve a command name or executable path for subprocess use.
199
+
200
+ - parameter binary: Binary path or command name.
201
+ - returns: Resolved executable path.
202
+ - throws: `CliStyleError.notFound` if the binary cannot be found.
203
+ */
204
+ public static func resolveBinary(_ binary: String) throws -> String {
205
+ if binary.contains("/") {
206
+ if FileManager.default.isExecutableFile(atPath: binary) {
207
+ return binary
208
+ }
209
+ throw CliStyleError.notFound("cli-style binary not found: \(binary)")
210
+ }
211
+
212
+ let path = ProcessInfo.processInfo.environment["PATH"] ?? ""
213
+
214
+ for directory in path.split(separator: ":") {
215
+ let candidate = "\(directory)/\(binary)"
216
+ if FileManager.default.isExecutableFile(atPath: candidate) {
217
+ return candidate
218
+ }
219
+ }
220
+
221
+ throw CliStyleError.notFound("cli-style binary not found: \(binary)")
222
+ }
223
+ }