A lightweight SPM plugin that lints and formats Swift source files. Its only dependency is the Swift toolchain's built-in swift-format binary.
| Plugin | Type | What it does |
|---|---|---|
| Persnoop | Build Tool | Runs swift-format lint automatically on every build as a pre-build step. Violations appear as build warnings. |
| Persnipe | Command | Runs swift-format format --in-place on demand to reformat source files. |
Both plugins work with Swift Package Manager. On macOS, Xcode project integration is also supported.
- Swift 6.0+ toolchain that includes
swift-format - macOS: Xcode 16+ (the plugin invokes
swift-formatviaxcrun) - Linux: The plugin auto-discovers
swift-formatfrom the active Swift toolchain.
On either platform, set $SWIFT_FORMAT to an absolute path to a swift-format binary to override the default — the escape hatch for custom toolchains or non-standard layouts.
The plugin can lint and format targets for any Apple platform (iOS, tvOS, watchOS, visionOS) — it runs on the host machine during the build.
Add the package to your Package.swift dependencies:
dependencies: [
.package(url: "https://github.com/HeirloomLogic/Persnicket", from: "2.0.0"),
]Or in Xcode: File → Add Package Dependencies and enter the repository URL.
Apply the plugin to any target you want linted on every build:
.target(
name: "MyTarget",
plugins: [
.plugin(name: "Persnoop", package: "Persnicket"),
]
)Lint violations are reported as build warnings — they show up inline in Xcode and in swift build output, but they do not fail the build. (swift-format lint only exits non-zero in --strict mode, and a failing pre-build step would block compilation entirely.) If you want violations to fail a build, either run swift-format lint --strict directly in CI (see CI below) or opt into the plugin's strict mode:
To make Persnoop fail the build on a violation, opt in with either:
- the
PERSNICKET_STRICTenvironment variable set to1,true, oryes— convenient for CI andswift build; or - a
.persnicket-strictfile in your project root (touch .persnicket-strict). Unlike the environment variable, this is visible to Xcode GUI builds, which don't inherit your shell environment.
When either is present, Persnoop passes --strict to swift-format lint, so violations exit non-zero. Because this is a pre-build step, a violation halts the build before compilation — so it's best kept CI-only, or committed as .persnicket-strict only when your whole team wants hard local enforcement. Strict mode is off by default.
Strict mode also hardens the failure path: when swift-format can't parse the configuration or is missing from the toolchain, Persnoop normally warns and skips linting (see Toolchain Compatibility) — but in strict mode it fails the build instead, so a broken setup can't silently disable the gate you opted into.
A few sharp edges:
- Precedence: the
.persnicket-strictsentinel wins — when it's present,PERSNICKET_STRICT=0cannot turn strict mode off. UnrecognizedPERSNICKET_STRICTvalues are treated as off, with a warning. - Toggling: takes effect on the next
swift build. Xcode caches plugin build planning, so a toggle may not register until the next re-plan — if a rebuild doesn't pick it up, clean the build folder (Product → Clean Build Folder) and build again.
If your package is itself consumed as a dependency, applying Persnoop pulls Persnicket into your consumers' dependency graph too. See DEV-TOOLING.md to gate it out so only your own builds run the linter.
The command plugin registers the SwiftPM built-in format-source-code verb. Run it from the command line:
swift package plugin --allow-writing-to-package-directory format-source-codeThe plugin runs silently on success — use git diff to see what changed.
To format only specific targets, pass --target (repeatable):
swift package plugin --allow-writing-to-package-directory format-source-code --target MyTargetIn Xcode: right-click your project or package → Persnipe.
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 default configuration. This config is fairly strict, and includes, among other things:
- 4-space indentation, 120-character line length
- Ordered imports and trailing commas
NeverForceUnwrap,NeverUseForceTry, andNeverUseImplicitlyUnwrappedOptionalsAllPublicDeclarationsHaveDocumentationFileScopedDeclarationPrivacyset toprivate
The plugin always passes the resolved configuration to swift-format explicitly, which disables swift-format's own per-directory config discovery: only the project-root .swift-format applies. A nested .swift-format in a subdirectory (which bare swift-format would pick up per-file) is ignored.
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:
# macOS
xcrun swift-format dump-configuration > .swift-format
# Linux
swift-format dump-configuration > .swift-formatFor CI lint workflows we recommend invoking swift-format lint --strict directly and registering a GitHub problem matcher so violations appear as inline annotations on the pull request diff. The matcher is a small regex file that tells the runner to parse swift-format's output (path:line:col: severity: message) into native annotations — no third-party action, no extra permissions, and the linter's exit code drives job pass/fail directly.
A ready-to-use matcher ships in this repo at .github/swift-format-matcher.json, along with a bin/ci-lint-setup script that wires everything up in a single step. The script:
- copies the default
.swift-formatinto your project root if you don't already have one, - installs the problem matcher at
.github/swift-format-matcher.json, and - emits the
::add-matcher::workflow command so violations appear as inline PR annotations.
It's idempotent and never overwrites an existing project .swift-format. Sample workflow (macOS):
name: Lint
on:
pull_request:
push:
branches: [main]
jobs:
swift-format-lint:
runs-on: macos-26
steps:
- uses: actions/checkout@v4
- name: Setup swift-format lint
run: |
swift package resolve
.build/checkouts/Persnicket/bin/ci-lint-setup
- name: Lint (strict)
run: xcrun swift-format lint --strict --parallel --recursive --configuration .swift-format Sources TestsOn Linux, install Swift with swift-actions/setup-swift@v2 before the setup step, and drop the xcrun prefix from the lint command. The setup script itself is portable sh and runs unchanged.
Caveats:
- Inline annotations on the PR "Files changed" tab only show for lines that are part of the diff. Violations on unchanged lines still appear in the workflow run summary.
- GitHub caps workflow-command annotations at 10 errors and 10 warnings shown inline per run; the remainder are listed in the run summary. For typical PRs this is fine — for a first-time lint sweep across a large codebase, run
swift-format lintlocally for the full list. ci-lint-setuprefreshes.github/swift-format-matcher.jsonfrom this package on every run. If you've customized that file, your changes will be overwritten — rename your copy and register it with your own::add-matcher::command instead.- Run
ci-lint-setupfrom the workspace root: the runner resolves the::add-matcher::path relative toGITHUB_WORKSPACE, not the step'sworking-directory. - The matcher stays registered for the rest of the job, and its pattern also matches
swiftccompiler diagnostics from later build steps. If that produces duplicate or mis-attributed annotations, emitecho "::remove-matcher owner=swift-format::"after the lint step. - The
swift package resolveapproach above executes a script out of a floating dependency checkout. Commit yourPackage.resolvedso the checkout is pinned to a version you've reviewed.
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 previously shipped 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 could see lint failures that can't be reproduced locally.
When the plugin detects that the active toolchain's swift-format cannot parse the configuration (or that the binary is missing entirely), it emits a warning and skips linting rather than failing the build. Keep an eye out for the linting skipped warning — a passing build does not guarantee the linter actually ran. Two exceptions:
- In strict mode, these failures fail the build instead of skipping — a hard gate shouldn't silently disarm itself.
- A
.swift-formatthat isn't valid JSON at all is always a hard error: that's a problem with your config file, not a toolchain mismatch, and it fails loudly so you can fix it.
When using swift-actions/setup-swift@v2 on Linux, the action may install an older default Swift if swift-version is omitted. This can produce a swift-format cannot parse the configuration — linting skipped warning, although the build succeeds. Pin the version to match your project:
- uses: swift-actions/setup-swift@v2
with:
swift-version: "6.2"On every platform, an absolute, executable $SWIFT_FORMAT is honored first — if it points at a runnable binary it wins; if it's set but relative or not executable, the plugin warns and falls back to the platform default.
Otherwise, on macOS, the plugins invoke swift-format via /usr/bin/xcrun, which resolves to the binary in your active Xcode toolchain.
Otherwise, on Linux, the plugins auto-discover swift-format from the active Swift toolchain. Search order:
$SWIFT_FORMATenvironment variable, if set to an absolute path (relative values are ignored with a warning).- Sibling of
swifton$PATH— Swift toolchains shipswift-formatin the samebindirectory asswift. This is the canonical location and matches whatdirname $(which swift)/swift-formatwould produce. Ifswiftis a symlink (e.g.update-alternatives), the plugin also checks beside its resolved target. /usr/local/bin/swift-formatand/usr/bin/swift-format.swift-formatdirectly on$PATH.
This means consumers don't need to symlink the binary into /usr/local/bin from CI — runners using the official Swift toolchain (e.g. swift-actions/setup-swift, the swift:* Docker images) work out of the box. If discovery fails, the plugin emits a clear error listing every path it checked instead of failing with a cryptic env: 'swift-format': No such file or directory.
Before linting, Persnoop runs a one-off preflight probe — it lints a trivial throwaway file to confirm the active toolchain can parse your config, so a config/toolchain mismatch surfaces as a skipped-lint warning (or, in strict mode, a build failure) rather than a cryptic prebuild error. The probe verdict is cached per config and toolchain in the plugin work directory, so unchanged incremental builds don't re-run it — only a .ok verdict is cached, so a broken config keeps re-probing until it's fixed.
The approach buys a few properties:
- Zero compile-time cost: no
swift-syntaxdependency tree to build. - Always in sync with your toolchain's Swift version.
- No binary artifacts to download or manage.
A few other tools cover the same ground. The right pick depends on how much control you want versus how much weight you're willing to carry.
What this plugin uses. Ships with the Swift toolchain, no extra dependencies, always version-matched to your compiler, opinionated defaults out of the box.
Tradeoffs: less configurable than SwiftLint, smaller rule set, and the configuration format can drift between Swift minor versions (see Toolchain Compatibility).
The most powerful option — ~200+ rules, custom rules, mature autocorrect, large community. Best integrated via SwiftLintPlugins, which ships SwiftLint as a .binaryTarget (prebuilt SwiftLintBinary.artifactbundle) so consumers don't pay the SourceKitten/SwiftSyntax compile cost.
Tradeoffs: the prebuilt binary must be kept version-aligned with your Swift toolchain, and .swiftlint.yml exposes hundreds of knobs to tune. Worth the cost when you need that level of control.
SwiftFormat (Nick Lockwood)
A dedicated formatter — fast, mature, focused purely on formatting rather than lint. Extensive rule set, distributable as a binary.
Tradeoffs: a separate tool to install and version-align, and the name collision with Apple's swift-format is a frequent source of confusion.
Lightweight integration is the deciding factor. SwiftLint offers much more control, but swift-format's opinionated defaults get ~99% of the way to a sensible style for most projects. Reaching for the heavier tool only pays off when the remaining 1% actually matters.
This repo ships shell scripts under bin/ for working on the plugin itself:
| Script | Purpose |
|---|---|
bin/regenerate-embedded-fallback |
Rewrites the embedded fallbackConfigJSON literal in all plugin source files from the canonical .swift-format at the repo root. |
bin/check-shared-plugin-code |
Verifies that the shared plugin infrastructure section is byte-identical across both plugin targets. |
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.
Formatting the plugin's own source. The Persnipe command plugin only formats its host package's source modules, so it's a no-op when invoked on Persnicket itself (this package contains only plugin targets). To reformat the plugin sources after editing them or changing .swift-format, invoke swift-format directly:
xcrun swift-format format --in-place --parallel --recursive --configuration .swift-format Plugins/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 plugin source files 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), verifies the shared plugin infrastructure is byte-identical across both targets, then runs swift-format lint in strict mode on the plugin's own source.
- Persnicket repository
- Keeping dev-only plugins out of consumers' dependency graphs
swift-formatrepositoryswift-formatrules reference
This project is available under the MIT License. See LICENSE for details.
