diff --git a/Plugins/SwiftFormatBuildToolPlugin/XcodePlugin.swift b/Plugins/SwiftFormatBuildToolPlugin/XcodePlugin.swift index ee25e30..8ae84db 100644 --- a/Plugins/SwiftFormatBuildToolPlugin/XcodePlugin.swift +++ b/Plugins/SwiftFormatBuildToolPlugin/XcodePlugin.swift @@ -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))", diff --git a/Plugins/SwiftFormatBuildToolPlugin/plugin.swift b/Plugins/SwiftFormatBuildToolPlugin/plugin.swift index 8bd8973..41cf247 100644 --- a/Plugins/SwiftFormatBuildToolPlugin/plugin.swift +++ b/Plugins/SwiftFormatBuildToolPlugin/plugin.swift @@ -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", @@ -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 """ ) @@ -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 { diff --git a/Plugins/SwiftFormatCommandPlugin/XcodePlugin.swift b/Plugins/SwiftFormatCommandPlugin/XcodePlugin.swift index 711f56d..516aebf 100644 --- a/Plugins/SwiftFormatCommandPlugin/XcodePlugin.swift +++ b/Plugins/SwiftFormatCommandPlugin/XcodePlugin.swift @@ -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)\" " diff --git a/Plugins/SwiftFormatCommandPlugin/plugin.swift b/Plugins/SwiftFormatCommandPlugin/plugin.swift index a2be9a8..d6b0548 100644 --- a/Plugins/SwiftFormatCommandPlugin/plugin.swift +++ b/Plugins/SwiftFormatCommandPlugin/plugin.swift @@ -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 } @@ -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 """ ) diff --git a/README.md b/README.md index 52db4d2..fd7acda 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bin/lint b/bin/lint index faf2628..dacd362 100755 --- a/bin/lint +++ b/bin/lint @@ -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")/.." @@ -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"