Categories
iOS Swift Swift Package

Setting up a build tool plugin for a Swift package

Swift package manager supports creating command and build plugins. In this blog post we’ll take a closer look at build plugins since these enable us to tap into the package build process and add extra steps like generating some code for the package. Command plugins, on the other hand, add extra commands which we can invoke from the command line or from Xcode since Xcode automatically exposes these commands in the UI. The example problem we are going to tackle is creating a build plugin which takes in a JSON and outputs some Swift code which in turn is used by the package. For making things more interesting, we’ll hook up a custom executable target which contains custom logic for generating that code. In many other cases, we could use existing tools like Sourcery and skip our own executable target.

Plugins run in a sandbox environment, so they can only write to a pluginWorkDirectory. So in our case we’ll write our generated code in that folder and tell the build command to include the generated file while building the target. This wasn’t immediately clear for me that this happens automatically if I set the generated file path as an output file of the build command. But let’s start with setting up a build plugin and then adding the executable target which the build plugin ends up calling and which generates the code.

We’ll name the build plugin as “ToomasKitPlugin” since the example package is named as “ToomasKit”. Plugins need to be defined in the Package.swift where we also let the Swift build system know which target runs the plugin. The final Package.swift looks like this which includes a library with its target and testing target, plugin, and executable target.

// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "ToomasKit",
platforms: [
.iOS(.v16)
],
products: [
.library(
name: "ToomasKit",
targets: ["ToomasKit"]
),
],
targets: [
.target(
name: "ToomasKit",
plugins: [
.plugin(name: "ToomasKitPlugin")
]
),
.executableTarget(
name: "CodeGenerator"
),
.plugin(
name: "ToomasKitPlugin",
capability: .buildTool(),
dependencies: ["CodeGenerator"]
),
.testTarget(
name: "ToomasKitTests",
dependencies: ["ToomasKit"]
),
]
)
view raw Package.swift hosted with ❤ by GitHub

Here we can see that the plugin goes under the targets section and since we are interested in creating build plugins the capability is set to buildTool. The other option is command plugin, which was mentioned before. Also, we add a plugin dependency since the plugin ends up calling an executable which we will create ourselves. Secondly, we need to add the plugin to the library target, since Swift build system needs to know which target runs this plugin. Time to create the build plugin. Plugins go, by default, to <PackageRoot>/Plugins folder and since our plugin is named as ToomasKitPlugin the full path is <PackageRoot>/Plugins/ToomasKitPlugin. Build tool plugins need to conform to BuildToolPlugin protocol, and we also need to annotate the struct with @main for letting Swift know which type is the entry point of the plugin. Build tool plugins need to implement a createBuildCommands function which returns a list of build commands to run. At the time of writing the post there are available pre-build and build commands where the former runs every single time we build and the latter only when there is a change in input or output files which the command refers to. In our case, the input file is a JSON file and the output is the generated Swift file. The JSON file is part of the package target, so we can get a path to the file using the target.directory API. The output can only be written to pluginWorkDirectory since plugins run in a sandbox. All the paths which are added to outputFiles get included with the build. Since we are generating Swift code the package will use, we add it to outputFiles. Now when we have the plugin configured, let’s take a look at the CodeGenerator implementation, which is an executable target the plugin runs.

import Foundation
import PackagePlugin
@main
struct ToomasKitPlugin: BuildToolPlugin {
func createBuildCommands(context: PackagePlugin.PluginContext, target: PackagePlugin.Target) async throws -> [PackagePlugin.Command] {
let inputJSON = target.directory.appending("Source.json")
let output = context.pluginWorkDirectory.appending("GeneratedEnum.swift")
return [
.buildCommand(displayName: "Generate Code",
executable: try context.tool(named: "CodeGenerator").path,
arguments: [inputJSON, output],
environment: [:],
inputFiles: [inputJSON],
outputFiles: [output])
]
}
}

The CodeGenerator executable target takes in two paths: input and output, where the input is the JSON file and output is the final generated Swift file. It will then decode the JSON, generate an enum based on the JSON content and writes it to the output file. This is just a simple example, but I would like to highlight the fact that using swift-argument-parser probably makes sense for a little bit more complicated executables.

@main
@available(macOS 13.0.0, *)
struct CodeGenerator {
static func main() async throws {
// Use swift-argument-parser or just CommandLine, here we just imply that 2 paths are passed in: input and output
guard CommandLine.arguments.count == 3 else {
throw CodeGeneratorError.invalidArguments
}
// arguments[0] is the path to this command line tool
let input = URL(filePath: CommandLine.arguments[1])
let output = URL(filePath: CommandLine.arguments[2])
let jsonData = try Data(contentsOf: input)
let enumFormat = try JSONDecoder().decode(JSONFormat.self, from: jsonData)
let code = """
enum \(enumFormat.name): CaseIterable {
\t\(enumFormat.cases.map({ "case \($0)" }).joined(separator: "\n\t"))
}
"""
guard let data = code.data(using: .utf8) else {
throw CodeGeneratorError.invalidData
}
try data.write(to: output, options: .atomic)
}
}
struct JSONFormat: Decodable {
let name: String
let cases: [String]
}
@available(macOS 13.00.0, *)
enum CodeGeneratorError: Error {
case invalidArguments
case invalidData
}

If we build the package, we can see that the plugin runs our CodeGenerator, the generated Swift file gets included with the package, and we can use the generated code in other files. Exactly what we wanted.

The full example project can be found here: SwiftExampleToomasKit.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

2 replies on “Setting up a build tool plugin for a Swift package”