diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..2f558ad --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,27 @@ +name: Lint + +on: + pull_request: + push: + branches: [main] + +jobs: + swift-format-lint: + name: swift-format (strict) + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Log toolchain versions + run: | + xcode-select -p + xcrun swift --version + xcrun swift-format --version || true + + - name: Verify embedded fallback is in sync with .swift-format + run: | + ./bin/regenerate-embedded-fallback + git diff --exit-code -- Plugins/SwiftFormatBuildToolPlugin/plugin.swift Plugins/SwiftFormatCommandPlugin/plugin.swift + + - name: Lint (strict) + run: ./bin/lint diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..267c15a --- /dev/null +++ b/.swift-format @@ -0,0 +1,77 @@ +{ + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" + }, + "indentConditionalCompilationBlocks": false, + "indentSwitchCaseLabels": false, + "indentation": { + "spaces": 4 + }, + "lineBreakAroundMultilineExpressionChainComponents": false, + "lineBreakBeforeControlFlowKeywords": false, + "lineBreakBeforeEachArgument": true, + "lineBreakBeforeEachGenericRequirement": false, + "lineBreakBetweenDeclarationAttributes": false, + "lineLength": 120, + "maximumBlankLines": 1, + "multiElementCollectionTrailingCommas": true, + "noAssignmentInExpressions": { + "allowedFunctions": [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether": true, + "reflowMultilineStringLiterals": { + "never": { } + }, + "respectsExistingLineBreaks": true, + "rules": { + "AllPublicDeclarationsHaveDocumentation": true, + "AlwaysUseLiteralForEmptyCollectionInit": false, + "AlwaysUseLowerCamelCase": true, + "AmbiguousTrailingClosureOverload": true, + "AvoidRetroactiveConformances": true, + "BeginDocumentationCommentWithOneLineSummary": true, + "DoNotUseSemicolons": true, + "DontRepeatTypeInStaticProperties": true, + "FileScopedDeclarationPrivacy": true, + "FullyIndirectEnum": true, + "GroupNumericLiterals": true, + "IdentifiersMustBeASCII": true, + "NeverForceUnwrap": true, + "NeverUseForceTry": true, + "NeverUseImplicitlyUnwrappedOptionals": true, + "NoAccessLevelOnExtensionDeclaration": true, + "NoAssignmentInExpressions": true, + "NoBlockComments": true, + "NoCasesWithOnlyFallthrough": true, + "NoEmptyLinesOpeningClosingBraces": true, + "NoEmptyTrailingClosureParentheses": true, + "NoLabelsInCasePatterns": true, + "NoLeadingUnderscores": true, + "NoParensAroundConditions": true, + "NoPlaygroundLiterals": true, + "NoVoidReturnOnFunctionSignature": true, + "OmitExplicitReturns": true, + "OneCasePerLine": true, + "OneVariableDeclarationPerLine": true, + "OnlyOneTrailingClosureArgument": true, + "OrderedImports": true, + "ReplaceForEachWithForLoop": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "TypeNamesShouldBeCapitalized": true, + "UseEarlyExits": false, + "UseExplicitNilCheckInConditions": true, + "UseLetInEveryBoundCaseVariable": true, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": true, + "UseSynthesizedInitializer": true, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": true, + "ValidateDocumentationComments": true + }, + "spacesAroundRangeFormationOperators": false, + "spacesBeforeEndOfLineComments": 2, + "tabWidth": 4, + "version": 1 +} diff --git a/Plugins/SwiftFormatBuildToolPlugin/XcodePlugin.swift b/Plugins/SwiftFormatBuildToolPlugin/XcodePlugin.swift index 2ab68b0..ee25e30 100644 --- a/Plugins/SwiftFormatBuildToolPlugin/XcodePlugin.swift +++ b/Plugins/SwiftFormatBuildToolPlugin/XcodePlugin.swift @@ -19,6 +19,7 @@ extension SwiftFormatBuildToolPlugin: XcodeBuildToolPlugin { executable: URL(fileURLWithPath: "/usr/bin/xcrun"), arguments: [ "swift-format", "lint", + "--parallel", "--configuration", configPath, "--recursive", context.xcodeProject.directoryURL.path, diff --git a/Plugins/SwiftFormatBuildToolPlugin/plugin.swift b/Plugins/SwiftFormatBuildToolPlugin/plugin.swift index 3f2f16f..8bd8973 100644 --- a/Plugins/SwiftFormatBuildToolPlugin/plugin.swift +++ b/Plugins/SwiftFormatBuildToolPlugin/plugin.swift @@ -23,6 +23,7 @@ struct SwiftFormatBuildToolPlugin: BuildToolPlugin { var arguments: [String] = [ "swift-format", "lint", + "--parallel", "--configuration", configPath, ] for file in sourceFiles { @@ -40,52 +41,124 @@ struct SwiftFormatBuildToolPlugin: BuildToolPlugin { } /// Returns the executable URL used to invoke `swift-format`. + /// /// On macOS this is `xcrun` (resolves from the active Xcode toolchain). /// On Linux / Windows the binary is expected on `$PATH`. private func swiftFormatExecutable() -> URL { #if os(macOS) - URL(fileURLWithPath: "/usr/bin/xcrun") + URL(fileURLWithPath: "/usr/bin/xcrun") #else - URL(fileURLWithPath: "/usr/bin/env") + URL(fileURLWithPath: "/usr/bin/env") #endif } // MARK: - Configuration Resolution /// Looks for `.swift-format` in the downstream project root. + /// /// Falls back to an embedded default written to the plugin work directory. func resolveConfiguration( projectRoot: URL, pluginWorkDirectory: URL ) throws -> String { + let resolvedPath: String let projectConfig = projectRoot.appendingPathComponent(".swift-format") if FileManager.default.fileExists(atPath: projectConfig.path) { Diagnostics.remark( "Using project configuration at \(projectConfig.path)." ) - return projectConfig.path + resolvedPath = projectConfig.path + } else { + let fallbackURL = pluginWorkDirectory.appendingPathComponent("swift-format-fallback.json") + try fallbackConfigJSON.write(to: fallbackURL, atomically: true, encoding: .utf8) + 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 + • Rules reference: https://github.com/swiftlang/swift-format/blob/main/Documentation/RuleDocumentation.md + """ + ) + resolvedPath = fallbackURL.path } - let fallbackURL = pluginWorkDirectory.appendingPathComponent("swift-format-fallback.json") - try fallbackConfigJSON.write(to: fallbackURL, atomically: true, encoding: .utf8) - Diagnostics.remark( - "No .swift-format found in project root; using bundled fallback configuration." - ) - return fallbackURL.path + emitPreflightDiagnostics(configPath: resolvedPath) + return resolvedPath + } + + /// Emits an up-front summary of the config/toolchain so that when swift-format + /// fails downstream with its cryptic `: error: Unable to read configuration`, + /// the context needed to diagnose the failure is already in the log above it. + private func emitPreflightDiagnostics(configPath: String) { + switch validateConfig(at: configPath) { + case .ok(let version): + let versionString = version.map(String.init) ?? "unknown" + Diagnostics.remark( + """ + swift-format plugin preflight: + • config: \(configPath) + • version: \(versionString) + • executable: \(swiftFormatExecutable().path) swift-format + + • If swift-format reports "Unable to read configuration", the most likely cause \ + is a mismatch between the active toolchain's bundled swift-format and the schema \ + used by this config. The config "version" field does not always change between \ + incompatible schemas, so breaks can be silent. + """ + ) + case .invalid(let reason): + Diagnostics.error( + """ + The swift-format configuration at \(configPath) failed to parse as JSON: \(reason) + • swift-format reports ": error: Unable to read configuration" without \ + naming the file. Please fix the JSON above before rerunning. + """ + ) + } + } +} + +enum ConfigValidation { + case ok(version: Int?) + case invalid(reason: String) +} + +/// Parses the swift-format config at `path` and returns its `version` field if present. +func validateConfig(at path: String) -> ConfigValidation { + let url = URL(fileURLWithPath: path) + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + return .invalid(reason: "could not read file: \(error.localizedDescription)") + } + do { + let object = try JSONSerialization.jsonObject(with: data) + guard let dict = object as? [String: Any] else { + return .invalid(reason: "top-level JSON value is not an object") + } + return .ok(version: dict["version"] as? Int) + } catch { + return .invalid(reason: error.localizedDescription) } } // MARK: - Embedded Fallback Configuration /// The default `.swift-format` configuration shipped with this plugin. +/// /// Downstream projects can override this by placing their own `.swift-format` /// in the project root. +/// +/// GENERATED: this literal is rewritten by `bin/regenerate-embedded-fallback` +/// from the canonical `.swift-format` at the repo root. Do not edit by hand — +/// edit `.swift-format` and run the regenerator. SwiftPM plugin targets cannot +/// share Swift source or carry resources, so both plugins embed a copy. private let fallbackConfigJSON = """ { "fileScopedDeclarationPrivacy": { "accessLevel": "private" }, - "indentConditionalCompilationBlocks": true, + "indentConditionalCompilationBlocks": false, "indentSwitchCaseLabels": false, "indentation": { "spaces": 4 @@ -104,7 +177,9 @@ private let fallbackConfigJSON = """ ] }, "prioritizeKeepingFunctionOutputTogether": true, - "reflowMultilineStringLiterals": "never", + "reflowMultilineStringLiterals": { + "never": { } + }, "respectsExistingLineBreaks": true, "rules": { "AllPublicDeclarationsHaveDocumentation": true, @@ -112,7 +187,7 @@ private let fallbackConfigJSON = """ "AlwaysUseLowerCamelCase": true, "AmbiguousTrailingClosureOverload": true, "AvoidRetroactiveConformances": true, - "BeginDocumentationCommentWithOneLineSummary": false, + "BeginDocumentationCommentWithOneLineSummary": true, "DoNotUseSemicolons": true, "DontRepeatTypeInStaticProperties": true, "FileScopedDeclarationPrivacy": true, @@ -149,7 +224,7 @@ private let fallbackConfigJSON = """ "UseSynthesizedInitializer": true, "UseTripleSlashForDocumentationComments": true, "UseWhereClausesInForLoops": true, - "ValidateDocumentationComments": false + "ValidateDocumentationComments": true }, "spacesAroundRangeFormationOperators": false, "spacesBeforeEndOfLineComments": 2, diff --git a/Plugins/SwiftFormatCommandPlugin/XcodePlugin.swift b/Plugins/SwiftFormatCommandPlugin/XcodePlugin.swift index 8aa9b9e..711f56d 100644 --- a/Plugins/SwiftFormatCommandPlugin/XcodePlugin.swift +++ b/Plugins/SwiftFormatCommandPlugin/XcodePlugin.swift @@ -1,47 +1,47 @@ #if canImport(XcodeProjectPlugin) - import Foundation - import PackagePlugin - import XcodeProjectPlugin +import Foundation +import PackagePlugin +import XcodeProjectPlugin - extension SwiftFormatCommandPlugin: XcodeCommandPlugin { - func performCommand( - context: XcodePluginContext, - arguments: [String] - ) throws { - let configPath = try resolveConfiguration( - projectRoot: context.xcodeProject.directoryURL, - pluginWorkDirectory: context.pluginWorkDirectoryURL - ) - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") - process.arguments = [ - "swift-format", "format", - "--in-place", - "--parallel", - "--configuration", configPath, - "--recursive", - context.xcodeProject.directoryURL.path, - ] +extension SwiftFormatCommandPlugin: XcodeCommandPlugin { + func performCommand( + context: XcodePluginContext, + arguments: [String] + ) throws { + let configPath = try resolveConfiguration( + projectRoot: context.xcodeProject.directoryURL, + pluginWorkDirectory: context.pluginWorkDirectoryURL + ) - try process.run() - process.waitUntilExit() + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = [ + "swift-format", "format", + "--in-place", + "--parallel", + "--configuration", configPath, + "--recursive", + context.xcodeProject.directoryURL.path, + ] - guard - process.terminationReason == .exit, - process.terminationStatus == EXIT_SUCCESS - else { - Diagnostics.error( - "swift-format format failed for project " - + "\"\(context.xcodeProject.displayName)\" " - + "(status \(process.terminationStatus))." - ) - return - } + try process.run() + process.waitUntilExit() - Diagnostics.remark( - "Formatted Swift source files in project \"\(context.xcodeProject.displayName)\"." + guard + process.terminationReason == .exit, + process.terminationStatus == EXIT_SUCCESS + else { + Diagnostics.error( + "swift-format format failed for project " + + "\"\(context.xcodeProject.displayName)\" " + + "(status \(process.terminationStatus))." ) + return } + + Diagnostics.remark( + "Formatted Swift source files in project \"\(context.xcodeProject.displayName)\"." + ) } +} #endif diff --git a/Plugins/SwiftFormatCommandPlugin/plugin.swift b/Plugins/SwiftFormatCommandPlugin/plugin.swift index 0a63987..a2be9a8 100644 --- a/Plugins/SwiftFormatCommandPlugin/plugin.swift +++ b/Plugins/SwiftFormatCommandPlugin/plugin.swift @@ -12,6 +12,8 @@ struct SwiftFormatCommandPlugin: CommandPlugin { pluginWorkDirectory: context.pluginWorkDirectoryURL ) + logSwiftFormatVersion() + for target in context.package.targets { guard let sourceModule = target as? SourceModuleTarget else { Diagnostics.remark( @@ -28,7 +30,11 @@ struct SwiftFormatCommandPlugin: CommandPlugin { continue } - try format(sourceFiles: sourceFiles, targetName: target.name, configPath: configPath) + try format( + sourceFiles: sourceFiles, + targetName: target.name, + configPath: configPath + ) } } @@ -49,67 +55,188 @@ struct SwiftFormatCommandPlugin: CommandPlugin { process.executableURL = swiftFormatExecutable() process.arguments = arguments + 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 { - Diagnostics.error( - "swift-format format failed for target \"\(targetName)\" " - + "(status \(process.terminationStatus))." - ) + 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") { + var version: Int? + if case .ok(let v) = validateConfig(at: configPath) { version = v } + let versionString = version.map(String.init) ?? "unknown" + message += """ + + + 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. + """ + } + Diagnostics.error(message) return } Diagnostics.remark("Formatted Swift source files in target \"\(targetName)\".") } + /// Best-effort probe of the active swift-format's `--version` output. + /// + /// Surfaces the toolchain version that would otherwise be invisible in logs. + private func logSwiftFormatVersion() { + let process = Process() + process.executableURL = swiftFormatExecutable() + process.arguments = ["swift-format", "--version"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let output = + String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + Diagnostics.remark( + "swift-format --version: \(output.isEmpty ? "(no output)" : output)" + ) + } catch { + Diagnostics.remark( + "swift-format --version probe failed: \(error.localizedDescription)" + ) + } + } + /// Returns the executable URL used to invoke `swift-format`. + /// /// On macOS this is `xcrun` (resolves from the active Xcode toolchain). /// On Linux / Windows the binary is expected on `$PATH`. private func swiftFormatExecutable() -> URL { #if os(macOS) - URL(fileURLWithPath: "/usr/bin/xcrun") + URL(fileURLWithPath: "/usr/bin/xcrun") #else - URL(fileURLWithPath: "/usr/bin/env") + URL(fileURLWithPath: "/usr/bin/env") #endif } // MARK: - Configuration Resolution /// Looks for `.swift-format` in the downstream project root. + /// /// Falls back to an embedded default written to the plugin work directory. func resolveConfiguration( projectRoot: URL, pluginWorkDirectory: URL ) throws -> String { + let resolvedPath: String let projectConfig = projectRoot.appendingPathComponent(".swift-format") if FileManager.default.fileExists(atPath: projectConfig.path) { Diagnostics.remark( "Using project configuration at \(projectConfig.path)." ) - return projectConfig.path + resolvedPath = projectConfig.path + } else { + let fallbackURL = pluginWorkDirectory.appendingPathComponent("swift-format-fallback.json") + try fallbackConfigJSON.write(to: fallbackURL, atomically: true, encoding: .utf8) + 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 + • Rules reference: https://github.com/swiftlang/swift-format/blob/main/Documentation/RuleDocumentation.md + """ + ) + resolvedPath = fallbackURL.path } - let fallbackURL = pluginWorkDirectory.appendingPathComponent("swift-format-fallback.json") - try fallbackConfigJSON.write(to: fallbackURL, atomically: true, encoding: .utf8) - Diagnostics.remark( - "No .swift-format found in project root; using bundled fallback configuration." - ) - return fallbackURL.path + emitPreflightDiagnostics(configPath: resolvedPath) + return resolvedPath + } + + private func emitPreflightDiagnostics(configPath: String) { + switch validateConfig(at: configPath) { + case .ok(let version): + let versionString = version.map(String.init) ?? "unknown" + Diagnostics.remark( + """ + swift-format plugin preflight: + • config: \(configPath) + • version: \(versionString) + • executable: \(swiftFormatExecutable().path) swift-format + + • If swift-format reports "Unable to read configuration", the most likely cause \ + is a mismatch between the active toolchain's bundled swift-format and the schema \ + used by this config. The config "version" field does not always change between \ + incompatible schemas, so breaks can be silent. + """ + ) + case .invalid(let reason): + Diagnostics.error( + """ + The swift-format configuration at \(configPath) failed to parse as JSON: \(reason) + • swift-format reports ": error: Unable to read configuration" without \ + naming the file. Please fix the JSON above before rerunning. + """ + ) + } + } +} + +enum ConfigValidation { + case ok(version: Int?) + case invalid(reason: String) +} + +/// Parses the swift-format config at `path` and returns its `version` field if present. +func validateConfig(at path: String) -> ConfigValidation { + let url = URL(fileURLWithPath: path) + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + return .invalid(reason: "could not read file: \(error.localizedDescription)") + } + do { + let object = try JSONSerialization.jsonObject(with: data) + guard let dict = object as? [String: Any] else { + return .invalid(reason: "top-level JSON value is not an object") + } + return .ok(version: dict["version"] as? Int) + } catch { + return .invalid(reason: error.localizedDescription) } } // MARK: - Embedded Fallback Configuration /// The default `.swift-format` configuration shipped with this plugin. +/// /// Downstream projects can override this by placing their own `.swift-format` /// in the project root. +/// +/// GENERATED: this literal is rewritten by `bin/regenerate-embedded-fallback` +/// from the canonical `.swift-format` at the repo root. Do not edit by hand — +/// edit `.swift-format` and run the regenerator. SwiftPM plugin targets cannot +/// share Swift source or carry resources, so both plugins embed a copy. private let fallbackConfigJSON = """ { "fileScopedDeclarationPrivacy": { "accessLevel": "private" }, - "indentConditionalCompilationBlocks": true, + "indentConditionalCompilationBlocks": false, "indentSwitchCaseLabels": false, "indentation": { "spaces": 4 @@ -128,7 +255,9 @@ private let fallbackConfigJSON = """ ] }, "prioritizeKeepingFunctionOutputTogether": true, - "reflowMultilineStringLiterals": "never", + "reflowMultilineStringLiterals": { + "never": { } + }, "respectsExistingLineBreaks": true, "rules": { "AllPublicDeclarationsHaveDocumentation": true, @@ -136,7 +265,7 @@ private let fallbackConfigJSON = """ "AlwaysUseLowerCamelCase": true, "AmbiguousTrailingClosureOverload": true, "AvoidRetroactiveConformances": true, - "BeginDocumentationCommentWithOneLineSummary": false, + "BeginDocumentationCommentWithOneLineSummary": true, "DoNotUseSemicolons": true, "DontRepeatTypeInStaticProperties": true, "FileScopedDeclarationPrivacy": true, @@ -173,7 +302,7 @@ private let fallbackConfigJSON = """ "UseSynthesizedInitializer": true, "UseTripleSlashForDocumentationComments": true, "UseWhereClausesInForLoops": true, - "ValidateDocumentationComments": false + "ValidateDocumentationComments": true }, "spacesAroundRangeFormationOperators": false, "spacesBeforeEndOfLineComments": 2, diff --git a/README.md b/README.md index 11595c9..52db4d2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SwiftFormatPlugin -A lightweight SPM plugin that lints and formats Swift source files using `swift-format` from the Swift 6 toolchain. +A lightweight SPM plugin that lints and formats Swift source files using the Swift 6 toolchain's `swift-format` command. Works on **macOS**, **Linux**, and **Windows**. @@ -58,7 +58,7 @@ In Xcode: **right-click your project or package → SwiftFormatCommandPlugin**. The plugin looks for a `.swift-format` configuration file in your **project root**. If one is found, it will be used for both linting and formatting. -If no `.swift-format` file is present, the plugin falls back to a sensible built-in default configuration that includes, among other things: +If no `.swift-format` file is present, the plugin falls back to a default configuration. This config is fairly strict, and includes, among other things: - 4-space indentation, 120-character line length - Ordered imports and trailing commas @@ -66,7 +66,7 @@ If no `.swift-format` file is present, the plugin falls back to a sensible built - `AllPublicDeclarationsHaveDocumentation` - `FileScopedDeclarationPrivacy` set to `private` -To customize, define your own `.swift-format` file in the root of your project. You can generate a starter configuration with: +To use your own configuration, create a `.swift-format` file in the root of your project. You can generate a starter configuration with the following: ```bash # macOS @@ -76,6 +76,12 @@ xcrun swift-format dump-configuration > .swift-format swift-format dump-configuration > .swift-format ``` +## Toolchain Compatibility + +Match the Swift toolchain on your CI runner to the one on your development machine. Major.minor must align; patch should not matter. + +The `swift-format` configuration format has been observed to ship breaking changes without a version bump. A `.swift-format` file that parses cleanly under one Swift minor version may fail under another. If local dev and CI drift, you'll see lint failures that can't be reproduced locally. + ## How It Works On **macOS**, the plugins invoke `swift-format` via `/usr/bin/xcrun`, which resolves to the binary in your active Xcode toolchain. On **Linux** and **Windows**, the plugins invoke `swift-format` directly from your `$PATH`. This means: @@ -84,6 +90,22 @@ On **macOS**, the plugins invoke `swift-format` via `/usr/bin/xcrun`, which reso - **Always in sync** with your toolchain's Swift version. - **No binary artifacts** to download or manage. +## Development + +This repo ships a few shell wrappers under `bin/` for working on the plugin itself: + +| Script | Purpose | +|---|---| +| `bin/format` | Runs `SwiftFormatCommandPlugin` on this package to format its own sources. | +| `bin/lint` | Fast-path lint via `swift-format` directly (skips SwiftPM). Runs in `--strict` mode. | +| `bin/regenerate-embedded-fallback` | Rewrites the embedded `fallbackConfigJSON` literal in both plugin source files from the canonical `.swift-format` at the repo root. | + +**Editing the default config.** The `.swift-format` file at the repo root is the single source of truth for this plugin's default configuration. If you change it, run `bin/regenerate-embedded-fallback` before committing — the script rewrites the `private let fallbackConfigJSON = """..."""` block in both plugin source files to match. + +**Why the duplication exists.** SwiftPM plugin targets cannot share Swift source across targets and cannot carry resources (no `resources:` parameter on `.plugin(...)`, no `PluginContext` API to locate the plugin's own on-disk files), so both `SwiftFormatBuildToolPlugin/plugin.swift` and `SwiftFormatCommandPlugin/plugin.swift` must embed the fallback as a literal. The generator + CI drift check turns this structural duplication into a managed one: you only ever edit `.swift-format`, and CI fails if the embedded literals are out of sync. + +**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. + ## License This project is available under the MIT License. See [LICENSE](LICENSE) for details. diff --git a/bin/format b/bin/format new file mode 100755 index 0000000..ee5b878 --- /dev/null +++ b/bin/format @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +# Reformat all Swift sources in this package in place via SwiftFormatCommandPlugin. +# Forwards extra args to `swift package plugin` (e.g. --verbose). +set -eu +cd "$(dirname "$0")/.." +exec swift package plugin --allow-writing-to-package-directory format-source-code "$@" diff --git a/bin/lint b/bin/lint new file mode 100755 index 0000000..faf2628 --- /dev/null +++ b/bin/lint @@ -0,0 +1,25 @@ +#!/usr/bin/env sh +# Lint Swift sources without going through SwiftPM (fast path for CI / pre-commit). +# 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. +set -eu +cd "$(dirname "$0")/.." + +if [ ! -f .swift-format ]; then + echo "error: .swift-format not found at repo root" >&2 + exit 1 +fi + +if [ "$(uname)" = "Darwin" ]; then + SF="xcrun swift-format" +else + SF="swift-format" +fi + +if [ "$#" -eq 0 ]; then + set -- Plugins +fi + +# shellcheck disable=SC2086 +exec $SF lint --strict --recursive --parallel --configuration .swift-format "$@" diff --git a/bin/regenerate-embedded-fallback b/bin/regenerate-embedded-fallback new file mode 100755 index 0000000..d26f9da --- /dev/null +++ b/bin/regenerate-embedded-fallback @@ -0,0 +1,52 @@ +#!/usr/bin/env sh +# Regenerates the `fallbackConfigJSON` Swift string literal in both plugin +# source files from the canonical `.swift-format` at the repo root. +# +# SwiftPM plugin targets cannot share Swift source across targets and cannot +# carry resources, so each plugin must embed the fallback config as a literal. +# This script makes `.swift-format` the single source of truth and rewrites +# the literals idempotently. CI enforces drift via `git diff --exit-code` +# after a regen. +set -eu +cd "$(dirname "$0")/.." + +CONFIG=".swift-format" +FILE_A="Plugins/SwiftFormatBuildToolPlugin/plugin.swift" +FILE_B="Plugins/SwiftFormatCommandPlugin/plugin.swift" + +[ -f "$CONFIG" ] || { echo "error: $CONFIG not found" >&2; exit 1; } +[ -f "$FILE_A" ] || { echo "error: $FILE_A not found" >&2; exit 1; } +[ -f "$FILE_B" ] || { echo "error: $FILE_B not found" >&2; exit 1; } + +# Build the indented payload: Swift `"""` multi-line literals strip common +# leading whitespace based on the closing delimiter's column, so every line +# in the payload needs to be indented at least 4 spaces to match ` """`. +payload_file=$(mktemp) +trap 'rm -f "$payload_file"' EXIT +awk '{ if ($0 == "") print ""; else print " " $0 }' "$CONFIG" > "$payload_file" + +rewrite() { + file=$1 + tmp=$(mktemp) + awk -v payload_file="$payload_file" ' + /^private let fallbackConfigJSON = """$/ { + print + while ((getline line < payload_file) > 0) print line + close(payload_file) + skip = 1 + next + } + skip && /^ """$/ { + skip = 0 + print + next + } + !skip { print } + ' "$file" > "$tmp" + mv "$tmp" "$file" +} + +rewrite "$FILE_A" +rewrite "$FILE_B" + +echo "ok: regenerated fallbackConfigJSON literal in both plugin files from $CONFIG"