Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Plugins/SwiftFormatBuildToolPlugin/XcodePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ extension SwiftFormatBuildToolPlugin: XcodeBuildToolPlugin {
pluginWorkDirectory: context.pluginWorkDirectoryURL
)

if case .configError(let stderr) = probeSwiftFormat(
configPath: configPath,
pluginWorkDirectory: context.pluginWorkDirectoryURL
) {
emitConfigWarning(configPath: configPath, stderr: stderr)
return []
}

return [
.prebuildCommand(
displayName: "swift-format lint (\(target.displayName))",
Expand Down
86 changes: 85 additions & 1 deletion Plugins/SwiftFormatBuildToolPlugin/plugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ struct SwiftFormatBuildToolPlugin: BuildToolPlugin {
pluginWorkDirectory: context.pluginWorkDirectoryURL
)

if case .configError(let stderr) = probeSwiftFormat(
configPath: configPath,
pluginWorkDirectory: context.pluginWorkDirectoryURL
) {
emitConfigWarning(configPath: configPath, stderr: stderr)
return []
}

var arguments: [String] = [
"swift-format", "lint",
"--parallel",
Expand Down Expand Up @@ -74,7 +82,8 @@ struct SwiftFormatBuildToolPlugin: BuildToolPlugin {
Diagnostics.remark(
"""
No .swift-format found in project root, using the bundled fallback configuration.
• To learn about swift-format, go to https://github.com/swiftlang/swift-format
• Heirloom Logic SwiftFormatPlugin repository: https://github.com/heirloomlogic/SwiftFormatPlugin
• Swift Programming Language `swift-format` repository: https://github.com/swiftlang/swift-format
• Rules reference: https://github.com/swiftlang/swift-format/blob/main/Documentation/RuleDocumentation.md
"""
)
Expand Down Expand Up @@ -115,6 +124,81 @@ struct SwiftFormatBuildToolPlugin: BuildToolPlugin {
)
}
}

// MARK: - Preflight Probe

/// Runs swift-format against a trivial file to verify the config is parseable.
///
/// This catches config/toolchain mismatches before SPM's prebuild command
/// runs — where a non-zero exit would fail the build.
func probeSwiftFormat(configPath: String, pluginWorkDirectory: URL) -> ProbeResult {
let probeFile = pluginWorkDirectory.appendingPathComponent("_swift_format_probe.swift")
do {
try "// probe\n".write(to: probeFile, atomically: true, encoding: .utf8)
} catch {
return .ok
}

let process = Process()
process.executableURL = swiftFormatExecutable()
process.arguments = ["swift-format", "lint", "--configuration", configPath, probeFile.path]

let stderrPipe = Pipe()
process.standardOutput = FileHandle.nullDevice
process.standardError = stderrPipe

do {
try process.run()
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()

guard process.terminationStatus != EXIT_SUCCESS else {
return .ok
}

let stderr = String(data: stderrData, encoding: .utf8) ?? ""
let lower = stderr.lowercased()
if lower.contains("unable to read configuration")
|| lower.contains("invalid configuration")
|| lower.contains("unknown argument")
{
return .configError(stderr: stderr)
}
return .ok
} catch {
return .ok
}
}

/// Emits a detailed warning when the preflight probe detects a config/toolchain
/// mismatch, and explains that linting has been skipped.
func emitConfigWarning(configPath: String, stderr: String) {
var version: Int?
if case .ok(let v) = validateConfig(at: configPath) { version = v }
let versionString = version.map(String.init) ?? "unknown"
Diagnostics.warning(
"""
swift-format cannot parse the configuration — linting skipped.

The active toolchain's swift-format is incompatible with the config schema. \
This is a CI/toolchain setup issue, not a source code problem.

--- swift-format stderr ---
\(stderr.trimmingCharacters(in: .whitespacesAndNewlines))
---------------------------

• config: \(configPath) (version: \(versionString))
• executable: \(swiftFormatExecutable().path) swift-format
• Fix: upgrade the toolchain to match the config schema, or pin \
the config to an older schema compatible with the active toolchain.
"""
)
}
}

enum ProbeResult {
case ok
case configError(stderr: String)
}

enum ConfigValidation {
Expand Down
32 changes: 32 additions & 0 deletions Plugins/SwiftFormatCommandPlugin/XcodePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,45 @@ extension SwiftFormatCommandPlugin: XcodeCommandPlugin {
context.xcodeProject.directoryURL.path,
]

let stderrPipe = Pipe()
process.standardError = stderrPipe

try process.run()
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()

guard
process.terminationReason == .exit,
process.terminationStatus == EXIT_SUCCESS
else {
let stderr = String(data: stderrData, encoding: .utf8) ?? ""
let lower = stderr.lowercased()
let isConfigError =
lower.contains("unable to read configuration")
|| lower.contains("invalid configuration")
|| lower.contains("unknown argument")

if isConfigError {
Diagnostics.warning(
"""
swift-format cannot parse the configuration — formatting skipped for \
project "\(context.xcodeProject.displayName)".

The active toolchain's swift-format is incompatible with the config schema. \
This is a CI/toolchain setup issue, not a source code problem.

--- swift-format stderr ---
\(stderr.trimmingCharacters(in: .whitespacesAndNewlines))
---------------------------

• config: \(configPath)
• Fix: upgrade the toolchain to match the config schema, or pin \
the config to an older schema compatible with the active toolchain.
"""
)
return
}

Diagnostics.error(
"swift-format format failed for project "
+ "\"\(context.xcodeProject.displayName)\" "
Expand Down
53 changes: 35 additions & 18 deletions Plugins/SwiftFormatCommandPlugin/plugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,30 +64,46 @@ struct SwiftFormatCommandPlugin: CommandPlugin {

guard process.terminationReason == .exit, process.terminationStatus == EXIT_SUCCESS else {
let stderr = String(data: stderrData, encoding: .utf8) ?? ""
var message = """
swift-format format failed for target "\(targetName)" \
(status \(process.terminationStatus)).
--- swift-format stderr ---
\(stderr.isEmpty ? "(empty)" : stderr.trimmingCharacters(in: .whitespacesAndNewlines))
---------------------------
"""
if stderr.contains("Unable to read configuration") {
let lower = stderr.lowercased()
let isConfigError =
lower.contains("unable to read configuration")
|| lower.contains("invalid configuration")
|| lower.contains("unknown argument")

if isConfigError {
var version: Int?
if case .ok(let v) = validateConfig(at: configPath) { version = v }
let versionString = version.map(String.init) ?? "unknown"
message += """
Diagnostics.warning(
"""
swift-format cannot parse the configuration — formatting skipped for \
target "\(targetName)".

The active toolchain's swift-format is incompatible with the config schema. \
This is a CI/toolchain setup issue, not a source code problem.

This error almost always means the active toolchain's bundled swift-format \
is incompatible with the schema of:
\(configPath) (version: \(versionString))
The schema "version" field does not always change between incompatible \
releases, so breaks can be silent. Check `xcrun swift-format --version` \
against the Swift release that produced this config, then upgrade the \
toolchain or pin the config to an older schema.
--- swift-format stderr ---
\(stderr.trimmingCharacters(in: .whitespacesAndNewlines))
---------------------------

• config: \(configPath) (version: \(versionString))
• executable: \(swiftFormatExecutable().path) swift-format
• Fix: upgrade the toolchain to match the config schema, or pin \
the config to an older schema compatible with the active toolchain.
"""
)
return
}
Diagnostics.error(message)

Diagnostics.error(
"""
swift-format format failed for target "\(targetName)" \
(status \(process.terminationStatus)).
--- swift-format stderr ---
\(stderr.isEmpty ? "(empty)" : stderr.trimmingCharacters(in: .whitespacesAndNewlines))
---------------------------
"""
)
return
}

Expand Down Expand Up @@ -155,7 +171,8 @@ struct SwiftFormatCommandPlugin: CommandPlugin {
Diagnostics.remark(
"""
No .swift-format found in project root, using the bundled fallback configuration.
• To learn about swift-format, go to https://github.com/swiftlang/swift-format
• Heirloom Logic SwiftFormatPlugin repository: https://github.com/heirloomlogic/SwiftFormatPlugin
• Swift Programming Language `swift-format` repository: https://github.com/swiftlang/swift-format
• Rules reference: https://github.com/swiftlang/swift-format/blob/main/Documentation/RuleDocumentation.md
"""
)
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ This repo ships a few shell wrappers under `bin/` for working on the plugin itse

**CI.** `.github/workflows/lint.yml` runs on every pull request and push to `main`. It regenerates the embedded literals and verifies there's no diff (drift check), then runs `bin/lint` in strict mode.

## Links

- [SwiftFormatPlugin repository](https://github.com/heirloomlogic/SwiftFormatPlugin)
- [`swift-format` repository](https://github.com/swiftlang/swift-format)
- [`swift-format` rules reference](https://github.com/swiftlang/swift-format/blob/main/Documentation/RuleDocumentation.md)

## License

This project is available under the MIT License. See [LICENSE](LICENSE) for details.
36 changes: 35 additions & 1 deletion bin/lint
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
# Uses the canonical `.swift-format` at the repo root. Runs in --strict mode:
# any finding is a non-zero exit. Defaults to linting ./Plugins; pass paths
# as args to override.
#
# If swift-format cannot parse the configuration (toolchain/schema mismatch),
# this script warns and exits 0 — the build should not fail because the CI
# runner's toolchain is too old for the config.
set -eu
cd "$(dirname "$0")/.."

Expand All @@ -21,5 +25,35 @@ if [ "$#" -eq 0 ]; then
set -- Plugins
fi

stderr_file=$(mktemp)
trap 'rm -f "$stderr_file"' EXIT

# shellcheck disable=SC2086
exec $SF lint --strict --recursive --parallel --configuration .swift-format "$@"
if $SF lint --strict --recursive --parallel --configuration .swift-format "$@" 2>"$stderr_file"; then
exit 0
fi

exit_code=$?
stderr_content=$(cat "$stderr_file")

# Check if the failure is a config/toolchain mismatch rather than a real lint finding.
case "$stderr_content" in
*"Unable to read configuration"*|*"unable to read configuration"*|*"Invalid configuration"*|*"invalid configuration"*)
echo "warning: swift-format cannot parse the configuration — linting skipped." >&2
echo "" >&2
echo "The active toolchain's swift-format is incompatible with the config schema." >&2
echo "This is a CI/toolchain setup issue, not a source code problem." >&2
echo "" >&2
echo "--- swift-format stderr ---" >&2
echo "$stderr_content" >&2
echo "---------------------------" >&2
echo "" >&2
echo "Fix: upgrade the toolchain to match the config schema, or pin" >&2
echo "the config to an older schema compatible with the active toolchain." >&2
exit 0
;;
esac

# Real lint failure — replay stderr and exit with the original code.
echo "$stderr_content" >&2
exit "$exit_code"
Loading