What is a build tool plugin, how does it generate code, and what does it replace?

16.8.5 · L5 · Interview

Predict first

Where does the generated file live, and does the app see it?

// === Package.swift ===
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
    name: "App",
    targets: [
        .executableTarget(name: "GenTool"),
        .plugin(name: "GenPlugin", capability: .buildTool(), dependencies: ["GenTool"]),
        .executableTarget(name: "App", plugins: ["GenPlugin"]),
    ]
)
// === Sources/GenTool/main.swift ===
import Foundation
let out = CommandLine.arguments[1]
try! "let generatedGreeting = \"hello from a build tool\"\n".write(toFile: out, atomically: true, encoding: .utf8)
// === Plugins/GenPlugin/plugin.swift ===
import PackagePlugin
@main struct GenPlugin: BuildToolPlugin {
    func createBuildCommands(context: PluginContext, target: Target) throws -> [Command] {
        let out = context.pluginWorkDirectoryURL.appending(path: "Generated.swift")
        return [.buildCommand(displayName: "Generate greeting",
                              executable: try context.tool(named: "GenTool").url,
                              arguments: [out.path], inputFiles: [], outputFiles: [out])]
    }
}
// === Sources/App/main.swift ===
print(generatedGreeting)
// === run.sh ===
echo "App prints: $(swift run App 2>&1 | tail -1)"
echo "Generated.swift lives in the build directory: $(find .build -name Generated.swift | head -1 | grep -c plugins)"
echo "and not in Sources: $(ls Sources/App)"
  • it is written under .build (plugins work directory), compiled into App, and absent from Sources
  • it is written into Sources/App
  • the plugin cannot run during swift run

Mental model. A build tool plugin is a conveyor-belt station with labelled in and out trays: because the trays are declared, the factory only runs the station when something in the in-tray changed.

A build tool plugin runs as part of the build and produces files for a target: the target lists it in plugins: ["GenPlugin"], the plugin (a Swift file in Plugins/ conforming to BuildToolPlugin) returns commands that name an executable, its arguments, and the input and output files. In the check, the plugin runs a small GenTool executable that writes Generated.swift into the plugin’s work directory (inside .build); the build system compiles that file into the App target, so print(generatedGreeting) works without any generated file in Sources. Declaring inputs and outputs is what makes it incremental: a build command (.buildCommand) reruns only when its inputs change and its outputs are needed, while a .prebuildCommand runs every build and is for cases where outputs cannot be known up front. This is how packages run code generators (SwiftGen, Sourcery-style tools, Protobuf and OpenAPI generators, SwiftLint as a check).

It replaces a “Run Script” build phase and a checked-in generated file. Benefits: it works for command-line and Xcode builds, is declared next to the target, runs in a sandbox (writes only to its work directory; network is denied), and is cacheable. Costs: you must build the tool first (it is a target in the graph, so a heavy tool slows clean builds, which is why prebuilt binary tools via binaryTarget are common), Xcode asks the user to trust a plugin the first time (CI passes -skipPackagePluginValidation, which xcodebuild -help lists, and -skipMacroValidation for macros; treat these flags as granting trust and use them only for dependencies you have reviewed), and plugin code adds another API to keep compatible with new tools versions. Prefer a plugin over scripts when the output is source or resources that other code compiles; keep a script for steps with side effects outside the build (uploading symbols, tagging a release).

The plugin’s output goes to its work directory and is compiled as part of the target.

Common traps

  • Leaving inputs and outputs undeclared. The tool then runs on every build or its output is not picked up.
  • Skipping plugin validation on CI for unreviewed packages. It turns code in a dependency into code that runs during your build.

They might follow up with

  • When is prebuildCommand right? When the set of generated files depends on the contents the tool reads, so you cannot name the outputs in advance.
  • Can a plugin edit my sources? Not a build tool plugin; command plugins can with permission (next question).

Related questions

  • 16.8.6 What is a command plugin, what can it touch, and what does the permission model look like?
  • 16.7.4 Why does SwiftPM refuse a dependency that uses unsafeFlags, and what should you do instead?
  • 16.1.1 What are targets, products and modules in a Swift package, and what does a first Package.swift look like?
  • 16.2.2 What is an XCFramework, what do module maps and library evolution have to do with it, and when do you use a…
  • 16.6.1 A CI pipeline takes hours on a large modular app. How do you analyse and cut it: affected builds, caching…

← What are package traits, and how do they differ from build…   What is a command plugin, what can it touch, and what does the… →