Skip to content

About

A lightweight SPM plugin that lints and formats Swift source files

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Persnicket

Persnicket

A lightweight SPM plugin that lints and formats Swift source files. Its only dependency is the Swift toolchain's built-in swift-format binary.

Swift 6.0+ Platform CI License

Plugins

Plugin Type What it does
Persnoop Build Tool Runs swift-format lint when a target's Swift sources or applicable configurations change. Violations appear as build warnings in Xcode and swift build, or fail the build in opt-in strict mode.
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.

Requirements

  • Swift 6.0+ toolchain that includes swift-format
  • macOS: Xcode 16+ (the plugin invokes swift-format via xcrun)
  • Linux: The plugin auto-discovers swift-format from 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.

Installation

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.

Build Tool Plugin (automatic linting)

Apply the plugin to any target you want linted when its Swift sources or applicable configurations change:

.target(
    name: "MyTarget",
    plugins: [
        .plugin(name: "Persnoop", package: "Persnicket"),
    ]
)

For an Xcode project, attach Persnoop to each target under Build Phases → Run Build Tool Plug-ins after adding the package dependency.

In Xcode and swift build, lint violations are reported as build warnings when lint runs, and they do not fail the build. Like incremental compiler warnings, they are not replayed on later no-op builds.

Persnoop declares the source files, applicable configurations, and applicable ignore files as command inputs and includes the preflight tool/config identity in each lint command. After each successful lint chunk, it writes a generated .swift file containing one comment; SwiftPM uses that stamp to skip unchanged lint work. These stamps add no declarations or executable code, but they are generated sources in the target's build graph. If the source set shrinks or lint is skipped, unused stamps remain until the build directory is cleaned so SwiftPM can keep its generated-source list valid.

A small planning command refreshes a marker on each build so native SwiftPM re-evaluates configuration removal, configuration addition, and formatter replacement. Its separate comment-only Swift stamp is written once. This adds planning work to unchanged builds; the cached preflight probe and unchanged lint chunks still skip execution.

If you want violations to fail a build, either run swift-format lint --strict directly in CI or opt into the plugin's strict mode:

Strict mode (opt-in)

To make Persnoop fail the build on a violation, opt in with either:

  • the PERSNICKET_STRICT environment variable set to 1, true, or yes — convenient for CI and swift build. It must be in the environment (PERSNICKET_STRICT=1 xcodebuild …); an xcodebuild build setting (xcodebuild … PERSNICKET_STRICT=1) doesn't reach the plugin; or
  • a .persnicket-strict file 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. A violation halts the build before compilation and writes no stamp, so unchanged strict builds rerun and fail until the violation is fixed. Strict mode is best kept CI-only, or committed as .persnicket-strict only when your whole team wants hard local enforcement. It 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-strict sentinel wins — when it's present, PERSNICKET_STRICT=0 cannot turn strict mode off. Unrecognized PERSNICKET_STRICT values are treated as off, with a warning.
  • Toggling: changes the lint command and takes effect on the next swift build, even when sources and configuration are unchanged. 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.

Command Plugin (on-demand formatting)

The command plugin registers the SwiftPM built-in format-source-code verb. Persnipe is available package-wide once the dependency is added; it needs no target attachment. Run it from the command line:

swift package plugin --allow-writing-to-package-directory format-source-code

The plugin runs silently on success — use git diff to see what changed. It exits non-zero if swift-format can't run, rejects a configuration, or fails to format a file. A rejected configuration stops further batches; files processed before the error may already have changed. Syntax errors do not prevent the remaining files from being formatted.

To format only specific targets, pass --target (repeatable; --target=MyTarget also works):

swift package plugin --allow-writing-to-package-directory format-source-code --target MyTarget

In Xcode: right-click your project or package → Persnipe.

Persnipe formats the Swift sources of your package's source-module targets, including test and executable targets and — from Swift 6.2, whose swift-format fixed the handling — source files that are symlinks. Earlier toolchains skip symlinked files, in linting too. Plugin targets are not visible to command plugins, so it can't format them — run swift-format on Plugins/ directly.

Skipping build-time lint

Set PERSNICKET_SKIP=1 in the build environment to skip Persnoop, including formatter discovery and configuration checks. Persnoop emits a warning naming the skipped target. This overrides strict mode, including the .persnicket-strict sentinel; any other value leaves lint enabled. Persnipe remains available for explicit formatting.

PERSNICKET_SKIP=1 swift build
PERSNICKET_SKIP=1 xcodebuild …

Remove the variable to resume lint. SwiftPM applies the change on the next build. Xcode caches plugin planning, so a toggle may require Product → Clean Build Folder and another build. Pass the variable in the environment; an xcodebuild build setting does not reach the plugin.

Configuration

The plugins look for a .swift-format configuration file in your project root: the directory containing Package.swift for a Swift package, or the directory containing the .xcodeproj for an Xcode project. When it exists, both plugins let swift-format discover the nearest ancestor .swift-format for each source file. Nested configurations replace the root configuration for their files; they do not merge with it. On formatters that support symlinked sources, discovery follows the link to its destination. If your .xcodeproj is not at your repository root (say, ios/MyApp.xcodeproj in a monorepo), put .swift-format next to the .xcodeproj to enable discovery.

The file may use JSON5 — comments, trailing commas — as swift-format itself accepts from Swift 6.2. An older toolchain's swift-format rejects that syntax, which the plugin reports as a config/toolchain mismatch (see Toolchain Compatibility).

If no project-root .swift-format file is present, the plugins pass their bundled fallback explicitly, so nested configurations are ignored. This config is fairly strict, and includes, among other things:

  • 4-space indentation, 120-character line length
  • Ordered imports and trailing commas
  • NeverForceUnwrap, NeverUseForceTry, and NeverUseImplicitlyUnwrappedOptionals
  • AllPublicDeclarationsHaveDocumentation
  • FileScopedDeclarationPrivacy set to private

Persnoop validates the root and selected nested configurations, checks them against the active formatter, and tracks them as lint inputs. Adding, editing, or removing a configuration reruns lint on the next SwiftPM build. Xcode may require a build re-plan to pick up configuration additions or removals.

Ignoring files

From Swift 6.4, swift-format skips any source file matched by a .swift-format-ignore file, even when the file is named explicitly, so both plugins inherit it: Persnoop doesn't lint a matched file and Persnipe doesn't format it. The file uses .gitignore pattern syntax, including ** and ! negation, with patterns relative to the directory that contains it. Earlier toolchains don't read these files, so a CI job on an older Swift still lints what a Swift 6.4 build skips. To exclude a single file on every toolchain, put // swift-format-ignore-file at its top instead.

For each source file, swift-format collects every .swift-format-ignore from the file's directory upward and stops at the first directory that contains a .swift-format, including that directory's ignore file. An ignore file above a nested .swift-format therefore never reaches the files that configuration governs; put the pattern beside or below the nested configuration. When the project has no .swift-format and the plugins use the bundled fallback, the search continues past the project root, so an ignore file in a parent directory can apply.

Persnoop mirrors that search and tracks the ignore files it finds as lint inputs, so adding, editing, or removing one reruns lint on the next SwiftPM build. As with configurations, Xcode may require a build re-plan to pick up additions or removals.

To use your own configuration, create a .swift-format file in the root of your project. Choose a starting point: generate the active formatter's vanilla defaults to select your own rules, or copy Persnicket's opinionated configuration to preserve the bundled defaults while making them editable.

Generate vanilla defaults:

# macOS
xcrun swift-format dump-configuration > .swift-format

# Linux
swift-format dump-configuration > .swift-format

Or, after resolving Persnicket, copy its configuration from the dependency checkout, using this command only when you have no existing project configuration to preserve:

cp .build/checkouts/Persnicket/.swift-format .swift-format

CI

For 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-format into 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:

permissions:
  contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  swift-format-lint:
    runs-on: macos-26
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          # `swift package resolve` below runs third-party manifest and plugin
          # code in this job. Nothing here pushes, so don't leave GITHUB_TOKEN in
          # .git/config where that code can read it.
          persist-credentials: false

      - name: Setup swift-format lint
        run: |
          swift package resolve --force-resolved-versions
          .build/checkouts/Persnicket/bin/ci-lint-setup

      - name: Lint (strict)
        run: xcrun swift-format lint --strict --parallel --recursive --configuration .swift-format Sources Tests

Three things in that skeleton are worth copying deliberately: the actions/checkout SHA pin with the version in a trailing comment (a floating @v4 gives you neither reproducibility nor a supported Node runtime), the timeout-minutes (GitHub's default job timeout is six hours, and a macOS minute bills at 10x on a private repo), and the single pull_request trigger (adding push: [main] runs the same jobs twice on every merged commit, for the same commit the pull request already tested).

On Linux, run the job in the official Swift image — runs-on: ubuntu-latest with container: swift:6.2 — and drop the xcrun prefix from the lint command. swift-format ships inside the toolchain, so the image needs no separate install step. Use the full image rather than a -slim variant, which omits the compiler. The setup script itself is portable sh and runs unchanged.

swift-actions/setup-swift also works and is what this repo used until 2026-08. Its latest stable release (v2.4.0) still declares using: node20, and Node 20 reached end of life on 2026-04-30; the only newer tag is a prerelease on the same runtime. The container avoids that, pins the compiler's major.minor version, and removes a third-party action from the job. It is not faster: pulling the image costs about what installing the toolchain did.

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 lint locally for the full list.
  • ci-lint-setup refreshes .github/swift-format-matcher.json from 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-setup from your project root: it writes .swift-format and .github/swift-format-matcher.json into the current directory. The matcher registers from any directory, including a monorepo subproject.
  • Configuration diagnostics, such as <unknown>: warning: Configuration contains an unrecognized rule, name no file, so they appear as annotations on the workflow run rather than inline.
  • The matchers stay registered for the rest of the job, and the file-and-line pattern also matches swiftc compiler diagnostics from later build steps. If that produces duplicate or mis-attributed annotations, emit echo "::remove-matcher owner=swift-format::" and echo "::remove-matcher owner=swift-format-config::" after the lint step.
  • The setup step executes a script out of a dependency checkout. Commit your Package.resolved so the checkout is pinned to a version you've reviewed: --force-resolved-versions then makes the step fail, rather than silently resolving the newest matching tag, if Package.resolved is missing or doesn't pin Persnicket.

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 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 Persnoop detects that the active toolchain's swift-format rejects 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. Three exceptions:

  • In strict mode, these failures fail the build instead of skipping — a hard gate shouldn't silently disarm itself. That includes a configuration naming a rule the active swift-format doesn't know, which --strict treats as an error. The bundled fallback configuration enables two rules Swift 6.0's swift-format predates (AvoidRetroactiveConformances and NoEmptyLinesOpeningClosingBraces), so strict mode on Swift 6.0 needs your own .swift-format.
  • A .swift-format that isn't valid JSON at all (or isn't a JSON object) is always a hard error: that's a problem with your config file, not a toolchain mismatch, and it fails loudly, naming the file (and, on macOS, the line), so you can fix it.
  • Persnipe, the command plugin, always fails in these cases: you asked it to format, so it doesn't report success after formatting nothing.

A container: image pins the toolchain's major.minor version by construction, which is the main reason the Linux recipe above uses one. (swift:6.2 follows 6.2 patch releases; name a full version such as swift:6.2.4 to pin exactly.)

runs-on: ubuntu-latest
container: swift:6.2

If you install the toolchain with swift-actions/setup-swift instead, the action may install an older default Swift when swift-version is omitted. That produces a swift-format cannot use the configuration warning ending in linting skipped while the build still succeeds, which is exactly the silent failure this section warns about. Pin the version explicitly, and pin the action by SHA:

- uses: swift-actions/setup-swift@7ca6abe6b3b0e8b5421b88be48feee39cbf52c6a # v2.4.0
  with:
    swift-version: "6.2"

How It Works

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, pinned to the toolchain doing the build: the Xcode that DEVELOPER_DIR names, or the toolchain TOOLCHAINS selects, if either is set; otherwise the Xcode running the build — not necessarily the xcode-select one — so the linter matches the compiler. A standalone swift.org toolchain is matched only when selected through DEVELOPER_DIR or TOOLCHAINS; without them, xcrun uses its default Xcode.

Otherwise, on Linux, the plugins auto-discover swift-format from the active Swift toolchain. Search order:

  1. $SWIFT_FORMAT environment variable, if set to an absolute path (relative values are ignored with a warning).
  2. Sibling of swift on $PATH — Swift toolchains ship swift-format in the same bin directory as swift. This is the canonical location and matches what dirname $(which swift)/swift-format would produce. If swift is a symlink (e.g. update-alternatives), the plugin also checks beside its resolved target.
  3. /usr/local/bin/swift-format and /usr/bin/swift-format.
  4. swift-format directly 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 lists every path it checked — as a linting skipped warning from Persnoop (an error in strict mode), and as an error from Persnipe.

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 build-command error. The probe verdict is cached in the plugin work directory, keyed on the config's contents, DEVELOPER_DIR, TOOLCHAINS, and the swift-format binary itself, so unchanged incremental builds don't re-run it and a toolchain switch or in-place update does. Only a passing 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-syntax dependency tree to build.
  • Always in sync with your toolchain's Swift version.
  • No binary artifacts to download or manage.

Alternatives

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.

swift-format (Apple/SwiftLang)

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.

Why this plugin uses swift-format

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.

Development

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, on macOS and Linux and on the Swift 6.0 floor. Besides the drift and shared-code checks and a strict lint of the repo, it typechecks the Xcode plugin variants and exercises both plugins against the Examples/CompileCheck fixture. CONTRIBUTING.md lists every check.

Links

License

This project is available under the MIT License. See LICENSE for details.

About

A lightweight SPM plugin that lints and formats Swift source files

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages