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
27 changes: 27 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -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
77 changes: 77 additions & 0 deletions .swift-format
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions Plugins/SwiftFormatBuildToolPlugin/XcodePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
101 changes: 88 additions & 13 deletions Plugins/SwiftFormatBuildToolPlugin/plugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ struct SwiftFormatBuildToolPlugin: BuildToolPlugin {

var arguments: [String] = [
"swift-format", "lint",
"--parallel",
"--configuration", configPath,
]
for file in sourceFiles {
Expand All @@ -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 `<unknown>: 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 "<unknown>: 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
Expand All @@ -104,15 +177,17 @@ private let fallbackConfigJSON = """
]
},
"prioritizeKeepingFunctionOutputTogether": true,
"reflowMultilineStringLiterals": "never",
"reflowMultilineStringLiterals": {
"never": { }
},
"respectsExistingLineBreaks": true,
"rules": {
"AllPublicDeclarationsHaveDocumentation": true,
"AlwaysUseLiteralForEmptyCollectionInit": false,
"AlwaysUseLowerCamelCase": true,
"AmbiguousTrailingClosureOverload": true,
"AvoidRetroactiveConformances": true,
"BeginDocumentationCommentWithOneLineSummary": false,
"BeginDocumentationCommentWithOneLineSummary": true,
"DoNotUseSemicolons": true,
"DontRepeatTypeInStaticProperties": true,
"FileScopedDeclarationPrivacy": true,
Expand Down Expand Up @@ -149,7 +224,7 @@ private let fallbackConfigJSON = """
"UseSynthesizedInitializer": true,
"UseTripleSlashForDocumentationComments": true,
"UseWhereClausesInForLoops": true,
"ValidateDocumentationComments": false
"ValidateDocumentationComments": true
},
"spacesAroundRangeFormationOperators": false,
"spacesBeforeEndOfLineComments": 2,
Expand Down
76 changes: 38 additions & 38 deletions Plugins/SwiftFormatCommandPlugin/XcodePlugin.swift
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading