Skip to content

Commit 8afc99b

Browse files
committed
feat: Add parallel directory processing for faster formatting
Process multiple directories concurrently using Swift's TaskGroup, where each directory runs its full formatting pipeline independently: SwiftFormat → SwiftLint autocorrect → SwiftLint lint. ## Changes - Convert AirbnbSwiftFormatTool to AsyncParsableCommand - Add Command.runAsync() using Task.detached for true parallelism - Add DirectoryResult struct for per-directory exit code aggregation - Use withTaskGroup (not withThrowingTaskGroup) to process ALL directories even when some fail, giving users complete feedback - Add directory context to error messages (e.g., "Lint failures in: DirA, DirC") - Update tests to support async execution - Add tests for mixed success/failure scenarios across directories - Update ArgumentParser to 1.7.0 for AsyncParsableCommand support - Update minimum macOS to 10.15 for Swift Concurrency ## Benchmark Results (IronUI - 141 files, 3 directories) | Version | Wall-clock Time | Speedup | |------------|-----------------|--------------| | Sequential | 1.494s | baseline | | Parallel | 0.686s | 2.18x faster |
1 parent e43b6b4 commit 8afc99b

File tree

5 files changed

+312
-76
lines changed

5 files changed

+312
-76
lines changed

Package.resolved

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import PackageDescription
33

44
let package = Package(
55
name: "AirbnbSwift",
6-
platforms: [.macOS(.v10_13)],
6+
platforms: [.macOS(.v10_15)],
77
products: [
88
.plugin(name: "FormatSwift", targets: ["FormatSwift"])
99
],
1010
dependencies: [
11-
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.3")
11+
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.0")
1212
],
1313
targets: [
1414
.plugin(

Sources/AirbnbSwiftFormatTool/AirbnbSwiftFormatTool.swift

Lines changed: 128 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import Foundation
66
/// A command line tool that formats the given directories using SwiftFormat and SwiftLint,
77
/// based on the Airbnb Swift Style Guide
88
@main
9-
struct AirbnbSwiftFormatTool: ParsableCommand {
9+
struct AirbnbSwiftFormatTool: AsyncParsableCommand {
1010

1111
// MARK: Internal
1212

@@ -40,64 +40,134 @@ struct AirbnbSwiftFormatTool: ParsableCommand {
4040
@Option(help: "The project's minimum Swift version")
4141
var swiftVersion: String?
4242

43-
func run() throws {
44-
let swiftFormat = makeSwiftFormatCommand()
45-
let swiftLint = makeSwiftLintCommand(autocorrect: false)
46-
let swiftLintAutocorrect = makeSwiftLintCommand(autocorrect: true)
43+
func run() async throws {
44+
// Process all directories in parallel, each running the full pipeline independently.
45+
// We use withTaskGroup (not withThrowingTaskGroup) to ensure ALL directories are processed
46+
// even if some fail - this gives users complete feedback about all issues.
47+
let results = await withTaskGroup(of: Result<DirectoryResult, Error>.self) { group in
48+
for directory in directories {
49+
group.addTask {
50+
do {
51+
return .success(try await processDirectory(directory))
52+
} catch {
53+
return .failure(DirectoryError(directory: directory, underlyingError: error))
54+
}
55+
}
56+
}
57+
58+
var results = [Result<DirectoryResult, Error>]()
59+
for await result in group {
60+
results.append(result)
61+
}
62+
return results
63+
}
64+
65+
try aggregateResults(results)
66+
}
4767

48-
let swiftFormatExitCode = try swiftFormat.run()
68+
// MARK: Private
69+
70+
/// Whether the command should autocorrect invalid code, or only emit lint errors
71+
private var lintOnly: Bool {
72+
lint
73+
}
74+
75+
/// Processes a single directory through the full formatting pipeline
76+
/// - SwiftFormat -> SwiftLint autocorrect (if not lint-only) -> SwiftLint lint
77+
private func processDirectory(_ directory: String) async throws -> DirectoryResult {
78+
let swiftFormatExitCode = try await makeSwiftFormatCommand(for: directory).runAsync()
4979

5080
// Run SwiftLint in autocorrect mode first, so that if autocorrect fixes all of the SwiftLint violations
5181
// then the following lint-only invocation will not report any violations.
5282
let swiftLintAutocorrectExitCode: Int32?
53-
if
54-
// When only linting, we shouldn't run SwiftLint with autocorrect enabled
55-
!lintOnly
56-
{
57-
swiftLintAutocorrectExitCode = try swiftLintAutocorrect.run()
83+
if !lintOnly {
84+
swiftLintAutocorrectExitCode = try await makeSwiftLintCommand(for: directory, autocorrect: true).runAsync()
5885
} else {
5986
swiftLintAutocorrectExitCode = nil
6087
}
6188

6289
// We always have to run SwiftLint in lint-only mode at least once,
6390
// because when in autocorrect mode SwiftLint won't emit any lint warnings.
64-
let swiftLintExitCode = try swiftLint.run()
91+
let swiftLintExitCode = try await makeSwiftLintCommand(for: directory, autocorrect: false).runAsync()
6592

66-
if
67-
swiftFormatExitCode == SwiftFormatExitCode.lintFailure ||
68-
swiftLintExitCode == SwiftLintExitCode.lintFailure ||
69-
swiftLintAutocorrectExitCode == SwiftLintExitCode.lintFailure
70-
{
71-
throw ExitCode.failure
72-
}
93+
return DirectoryResult(
94+
directory: directory,
95+
swiftFormatExitCode: swiftFormatExitCode,
96+
swiftLintExitCode: swiftLintExitCode,
97+
swiftLintAutocorrectExitCode: swiftLintAutocorrectExitCode
98+
)
99+
}
73100

74-
// Any other non-success exit code is an unknown failure
75-
if swiftFormatExitCode != EXIT_SUCCESS {
76-
throw ExitCode(swiftFormatExitCode)
101+
/// Aggregates results from all directories and throws appropriate errors
102+
private func aggregateResults(_ results: [Result<DirectoryResult, Error>]) throws {
103+
var successResults = [DirectoryResult]()
104+
var executionErrors = [DirectoryError]()
105+
106+
// Separate successes from execution errors
107+
for result in results {
108+
switch result {
109+
case .success(let directoryResult):
110+
successResults.append(directoryResult)
111+
case .failure(let error):
112+
if let directoryError = error as? DirectoryError {
113+
executionErrors.append(directoryError)
114+
} else {
115+
// Defensive: wrap unexpected error types to avoid silent failures
116+
executionErrors.append(DirectoryError(directory: "unknown", underlyingError: error))
117+
}
118+
}
77119
}
78120

79-
if swiftLintExitCode != EXIT_SUCCESS {
80-
throw ExitCode(swiftLintExitCode)
121+
// Report any execution errors (e.g., binary not found)
122+
if let firstError = executionErrors.first {
123+
for error in executionErrors {
124+
log("Failed to process '\(error.directory)': \(error.underlyingError.localizedDescription)")
125+
}
126+
throw firstError.underlyingError
81127
}
82128

83-
if
84-
let swiftLintAutocorrectExitCode = swiftLintAutocorrectExitCode,
85-
swiftLintAutocorrectExitCode != EXIT_SUCCESS
86-
{
87-
throw ExitCode(swiftLintAutocorrectExitCode)
129+
// Check for lint failures and report which directories had issues
130+
var directoriesWithLintFailures = [String]()
131+
for result in successResults {
132+
if
133+
result.swiftFormatExitCode == SwiftFormatExitCode.lintFailure ||
134+
result.swiftLintExitCode == SwiftLintExitCode.lintFailure ||
135+
result.swiftLintAutocorrectExitCode == SwiftLintExitCode.lintFailure
136+
{
137+
directoriesWithLintFailures.append(result.directory)
138+
}
88139
}
89-
}
90140

91-
// MARK: Private
141+
if !directoriesWithLintFailures.isEmpty {
142+
log("Lint failures in: \(directoriesWithLintFailures.joined(separator: ", "))")
143+
throw ExitCode.failure
144+
}
92145

93-
/// Whether the command should autocorrect invalid code, or only emit lint errors
94-
private var lintOnly: Bool {
95-
lint
146+
// Any other non-success exit code is an unknown failure
147+
for result in successResults {
148+
if result.swiftFormatExitCode != EXIT_SUCCESS {
149+
log("SwiftFormat failed in '\(result.directory)' with exit code \(result.swiftFormatExitCode)")
150+
throw ExitCode(result.swiftFormatExitCode)
151+
}
152+
153+
if result.swiftLintExitCode != EXIT_SUCCESS {
154+
log("SwiftLint failed in '\(result.directory)' with exit code \(result.swiftLintExitCode)")
155+
throw ExitCode(result.swiftLintExitCode)
156+
}
157+
158+
if
159+
let swiftLintAutocorrectExitCode = result.swiftLintAutocorrectExitCode,
160+
swiftLintAutocorrectExitCode != EXIT_SUCCESS
161+
{
162+
log("SwiftLint autocorrect failed in '\(result.directory)' with exit code \(swiftLintAutocorrectExitCode)")
163+
throw ExitCode(swiftLintAutocorrectExitCode)
164+
}
165+
}
96166
}
97167

98-
/// Builds a command that runs the SwiftFormat tool
99-
private func makeSwiftFormatCommand() -> Command {
100-
var arguments = directories + [
168+
/// Builds a command that runs the SwiftFormat tool on a single directory
169+
private func makeSwiftFormatCommand(for directory: String) -> Command {
170+
var arguments = [directory] + [
101171
"--config",
102172
swiftFormatConfig,
103173
]
@@ -121,11 +191,11 @@ struct AirbnbSwiftFormatTool: ParsableCommand {
121191
)
122192
}
123193

124-
/// Builds a command that runs the SwiftLint tool
194+
/// Builds a command that runs the SwiftLint tool on a single directory
125195
/// - If `autocorrect` is true, passes the `--fix` flag to SwiftLint.
126196
/// When autocorrecting, SwiftLint doesn't emit any lint warnings.
127-
private func makeSwiftLintCommand(autocorrect: Bool) -> Command {
128-
var arguments = directories + [
197+
private func makeSwiftLintCommand(for directory: String, autocorrect: Bool) -> Command {
198+
var arguments = [directory] + [
129199
"--config",
130200
swiftLintConfig,
131201
// Required for SwiftLint to emit a non-zero exit code on lint failure
@@ -175,3 +245,21 @@ enum SwiftFormatExitCode {
175245
enum SwiftLintExitCode {
176246
static let lintFailure: Int32 = 2
177247
}
248+
249+
// MARK: - DirectoryResult
250+
251+
/// The result of running the formatting pipeline on a single directory
252+
struct DirectoryResult: Sendable {
253+
let directory: String
254+
let swiftFormatExitCode: Int32
255+
let swiftLintExitCode: Int32
256+
let swiftLintAutocorrectExitCode: Int32?
257+
}
258+
259+
// MARK: - DirectoryError
260+
261+
/// An error that occurred while processing a specific directory
262+
struct DirectoryError: Error {
263+
let directory: String
264+
let underlyingError: Error
265+
}

Sources/AirbnbSwiftFormatTool/Command.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ struct Command {
2121
try Command.runCommand(self)
2222
}
2323

24+
/// Async variant that runs the command in a detached background Task
25+
/// - Uses Task.detached to ensure true parallel execution on background threads
26+
/// - Enables concurrent processing of multiple commands without serialization
27+
func runAsync() async throws -> Int32 {
28+
try await Task.detached {
29+
try run()
30+
}.value
31+
}
32+
2433
// MARK: Private
2534

2635
/// Synchronously runs this command and returns its exit code

0 commit comments

Comments
 (0)