Skip to content

Commit d8739e3

Browse files
macmadeclaude
andcommitted
feat: Add a command-line mode to format files without the GUI
Run the bundle binary directly from a shell to format files or list configurations, instead of launching the menu-bar app. Because direct execution does not go through LaunchServices, this works even while the GUI app is already running — the two are independent processes sharing only the app-group defaults and cache. - ApplicationDelegate: applicationWillFinishLaunching branches into the CLI before any AppKit setup. Launches via launchd (Finder, open, login item) are reparented to launchd, so getppid() == 1 keeps running the GUI; a shell parent runs the CLI, even with no arguments. - XcodeFormatCLI: a swift-argument-parser command exposing --list and --config <name> <files…>. Uses the stored configurations, falling back to the built-in defaults in memory when none are stored, so it works before the app has ever run. - FileFormatter: file-based formatting and up-front validation, reusing ProcessTask, UncrustifyLanguage, and FormatterOutcome. Validates every file first so an unsupported input formats nothing; rewrites a file only when the formatter changed it. - CLIError: user-facing messages for unknown configurations and unavailable configuration files. - Configuration: downloadSynchronouslyIfNeeded() fetches missing files on the calling thread for the CLI, leaving cached files untouched. - project.pbxproj: add the swift-argument-parser package to the app target only, and a Copy Executables phase placing swiftformat and uncrustify next to the app binary (they previously shipped only in the extension), so ProcessTask resolves them in the CLI process. - SharedTests: cover FileFormatter type resolution and validation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d51d8ba commit d8739e3

8 files changed

Lines changed: 723 additions & 0 deletions

File tree

Shared/Configuration.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,44 @@ public class Configuration: NSObject, Codable
154154
}
155155
}
156156

157+
/// Synchronously downloads any of this configuration's files that are not
158+
/// already cached, blocking the calling thread until each finishes.
159+
///
160+
/// Unlike ``download()``, this performs no dispatching: it fetches the
161+
/// missing files on the caller's thread. Intended for command-line use,
162+
/// where there is no run loop and the work must complete before formatting.
163+
/// Already-cached files are left untouched, so repeat invocations avoid
164+
/// needless network traffic.
165+
public func downloadSynchronouslyIfNeeded()
166+
{
167+
if let url = self.swiftFormat, self.isCached( url: url ) == false
168+
{
169+
self.download( url: url )
170+
}
171+
172+
if let url = self.uncrustify, self.isCached( url: url ) == false
173+
{
174+
self.download( url: url )
175+
}
176+
}
177+
178+
/// Reports whether a cached file already exists for a configuration URL.
179+
///
180+
/// - Parameter url: The original configuration URL whose cache is checked.
181+
/// - Returns: `true` when a file named by the hash of `url` exists in the
182+
/// shared cache.
183+
private func isCached( url: URL ) -> Bool
184+
{
185+
guard let sha256 = url.sha256,
186+
let container = FileManager.sharedContainerURL?.appendingPathComponent( "Configurations" )
187+
else
188+
{
189+
return false
190+
}
191+
192+
return FileManager.default.fileExists( atPath: container.appendingPathComponent( sha256 ).path )
193+
}
194+
157195
/// Downloads a single configuration file and writes it to the shared cache.
158196
///
159197
/// Rejects non-HTTPS URLs, names the cached file by the hash of its URL,

Shared/FileFormatter.swift

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
/*******************************************************************************
2+
* The MIT License (MIT)
3+
*
4+
* Copyright (c) 2022, Jean-David Gadina - www.xs-labs.com
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the Software), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
******************************************************************************/
24+
25+
import Foundation
26+
import UniformTypeIdentifiers
27+
28+
/// Formats source files on disk with the bundled command-line formatters.
29+
///
30+
/// This is the file-based counterpart to the Xcode editor command: it picks the
31+
/// formatter matching a file's type — SwiftFormat for Swift, uncrustify for the
32+
/// C family — runs it over the file's contents, and rewrites the file in place
33+
/// when the output differs. It carries no editor/cursor logic and is used by the
34+
/// command-line mode.
35+
public enum FileFormatter
36+
{
37+
/// The formatter that applies to a given file, along with any language hint
38+
/// it needs.
39+
public enum Tool: Equatable
40+
{
41+
/// Format with SwiftFormat.
42+
case swiftFormat
43+
44+
/// Format with uncrustify, using the given `-l` language token.
45+
case uncrustify( language: String )
46+
}
47+
48+
/// A pre-flight failure that prevents formatting from starting.
49+
public enum ValidationError: LocalizedError, Equatable
50+
{
51+
/// No file exists at the given path.
52+
case fileNotFound( URL )
53+
54+
/// The file's type maps to no available formatter.
55+
case unsupportedType( URL )
56+
57+
/// The formatter for the file has no configuration file available.
58+
case missingConfiguration( URL )
59+
60+
/// A localized, user-facing description of the failure.
61+
public var errorDescription: String?
62+
{
63+
switch self
64+
{
65+
case .fileNotFound( let url ):
66+
67+
return "No such file: \( url.path )"
68+
69+
case .unsupportedType( let url ):
70+
71+
return "Unsupported file type: \( url.path )"
72+
73+
case .missingConfiguration( let url ):
74+
75+
return "No formatter configuration available for: \( url.path )"
76+
}
77+
}
78+
}
79+
80+
/// Resolves the formatter that applies to a file, based on its path
81+
/// extension.
82+
///
83+
/// Mirrors the editor command's type handling: Swift source uses
84+
/// SwiftFormat, while the C family is delegated to
85+
/// ``UncrustifyLanguage/argument(for:)``. Returns `nil` for types neither
86+
/// formatter handles.
87+
///
88+
/// - Parameter url: The file whose extension is examined.
89+
/// - Returns: The matching ``Tool``, or `nil` when the type is unsupported.
90+
public static func tool( for url: URL ) -> Tool?
91+
{
92+
guard let type = UTType( filenameExtension: url.pathExtension )
93+
else
94+
{
95+
return nil
96+
}
97+
98+
if type == .swiftSource
99+
{
100+
return .swiftFormat
101+
}
102+
103+
if let language = UncrustifyLanguage.argument( for: type )
104+
{
105+
return .uncrustify( language: language )
106+
}
107+
108+
return nil
109+
}
110+
111+
/// Validates every file before any formatting begins, so a bad input aborts
112+
/// the whole run without modifying anything.
113+
///
114+
/// Each file must exist, resolve to a supported ``Tool``, and have the
115+
/// configuration file its formatter requires.
116+
///
117+
/// - Parameters:
118+
/// - files: Files to be formatted.
119+
/// - swiftFormatConfig: The available SwiftFormat configuration, or `nil`.
120+
/// - uncrustifyConfig: The available uncrustify configuration, or `nil`.
121+
/// - Throws: A ``ValidationError`` for the first file that fails a check.
122+
public static func validate( files: [ URL ], swiftFormatConfig: URL?, uncrustifyConfig: URL? ) throws
123+
{
124+
try files.forEach
125+
{
126+
url in
127+
128+
guard FileManager.default.fileExists( atPath: url.path )
129+
else
130+
{
131+
throw ValidationError.fileNotFound( url )
132+
}
133+
134+
guard let tool = self.tool( for: url )
135+
else
136+
{
137+
throw ValidationError.unsupportedType( url )
138+
}
139+
140+
switch tool
141+
{
142+
case .swiftFormat:
143+
144+
if swiftFormatConfig == nil
145+
{
146+
throw ValidationError.missingConfiguration( url )
147+
}
148+
149+
case .uncrustify:
150+
151+
if uncrustifyConfig == nil
152+
{
153+
throw ValidationError.missingConfiguration( url )
154+
}
155+
}
156+
}
157+
}
158+
159+
/// Formats a single file in place, rewriting it only when the formatter
160+
/// produced changed output.
161+
///
162+
/// The file's bytes are piped to the matching formatter as standard input.
163+
/// The result is classified by ``FormatterOutcome``; only a `.formatted`
164+
/// outcome overwrites the file.
165+
///
166+
/// - Parameters:
167+
/// - url: The file to format in place.
168+
/// - swiftFormatConfig: SwiftFormat configuration file, or `nil`.
169+
/// - uncrustifyConfig: Uncrustify configuration file, or `nil`.
170+
/// - Returns: `true` if the file was rewritten, `false` if it was left
171+
/// unchanged.
172+
/// - Throws: ``ValidationError`` when the type or configuration is
173+
/// unusable, or ``FormatterError`` when the formatter cannot be
174+
/// launched or exits with a non-zero status.
175+
@discardableResult
176+
public static func format( file url: URL, swiftFormatConfig: URL?, uncrustifyConfig: URL? ) throws -> Bool
177+
{
178+
let executable: String
179+
let arguments: [ String ]
180+
181+
switch self.tool( for: url )
182+
{
183+
case .swiftFormat:
184+
185+
guard let config = swiftFormatConfig?.path
186+
else
187+
{
188+
throw ValidationError.missingConfiguration( url )
189+
}
190+
191+
executable = "swiftformat"
192+
arguments = [ "--config", config ]
193+
194+
case .uncrustify( let language ):
195+
196+
guard let config = uncrustifyConfig?.path
197+
else
198+
{
199+
throw ValidationError.missingConfiguration( url )
200+
}
201+
202+
executable = "uncrustify"
203+
arguments = [ "-c", config, "-l", language, "-q" ]
204+
205+
case nil:
206+
207+
throw ValidationError.unsupportedType( url )
208+
}
209+
210+
let input = try Data( contentsOf: url )
211+
212+
guard let task = ProcessTask.run( name: executable, arguments: arguments, input: input )
213+
else
214+
{
215+
throw FormatterError.executableNotFound( executable )
216+
}
217+
218+
guard let status = task.terminationStatus
219+
else
220+
{
221+
throw FormatterError.failed( executable: executable, status: -1, message: "" )
222+
}
223+
224+
let outcome = try FormatterOutcome.classify( executable: executable, input: String( data: input, encoding: .utf8 ) ?? "", status: status, standardOutput: task.standardOutput, standardError: task.standardError )
225+
226+
guard case .formatted( let output ) = outcome
227+
else
228+
{
229+
return false
230+
}
231+
232+
try Data( output.utf8 ).write( to: url )
233+
234+
return true
235+
}
236+
}

0 commit comments

Comments
 (0)