Published on

Build-time actions with SPM plugins

Authors

This week I've published Toledo, a compile-time dependency injection library. It was the first time I used the new SPM plugins. They are young and Xcode still has some issues around them, but the potential great.

Let's see what we'll cover in this article:

  • what are SPM plugins and who needs them anyway?
  • writing build tools and/or command plugins
  • security limitations and a couple of issues
Subscribe and get articles like this in your email!

Plugins solve a simple problem: sometimes you need to perform a task that might modify the codebase before the build. For example, you might want to generate all your Swift models from GraphQL definitions. Or generate code to access various resources, like SwiftGen does.

Some cool stuff plugins can do:

  • format code
  • security audits
  • generate boilerplate code
  • turn warnings into errors
  • prepare various resources
  • and so on

For this kind of build-time tasks you'd normally use a script with Xcode. SPM didn't have a way to nicely bundle these scripts within a package until now. Plugins are the answer to that. They greatly benefit library creators since they're much easier to maintain and distribute cross-platform, but can also be a nice addition to a team that splits code into packages within an app.

Here is the full proposal. It's quite a read, but the API surface isn't that large.

To create a plugin you'd usually have to create at least two targets: one for the plugin itself and the other for the executable that supports the plugin. This executable will do all the work, while the plugin will only forward commands to it.

Important note:

Before you start to write your first plugin, be sure to check the bugs & limitations section. Some features are not working in Xcode and while workarounds are available, following these steps verbatim will fail in the latest (13.3.1) Xcode version.

Here's how one would declare a plugin:

// Package.swift
// ...
targets: [
    // other targets
    .executableTarget(name: "Generator"),
    .plugin(
        name: "GeneratorBuildTool",
        capability: .buildTool(),
        dependencies: [
            .target(name: "Generator")
        ]
    ),
    .plugin(
        name: "GeneratorCommand",
        capability: .command(
            intent: .custom(verb: "generate‐resources",
                            description: "Generates resources."),
            permissions: [
                .writeToPackageDirectory(reason: "Adds generated resources.")
            ]
        ),
        dependencies: [
            .target(name: "Generator")
        ]
    )
]

There are actually two plugins here. Both are using the same generator, but one is a command and the other, is a build tool. The command needs to be manually called with swift package plugin generate‐resources (check the verb above), while the build tool will be automatically called each time one of its input files changes.

Notice how the command can also write outside the sandbox (more about it below), directly to the package directory, as long as it receives permission.

Once you add this to your Package.swift the compiler will complain that there's no Plugins directory with a valid plugin in it, nor can it find the source for the executable. After adding the missing directories/files your package should look like this:

├── Package.swift
├── Plugins
│   └── GeneratorBuildTool
│   │   └── GeneratorBuildTool.swift
│   └── GeneratorCommand
│       └── GeneratorCommand.swift
├── Sources
│   └── FooBar
│   │   └── FooBar.swift
│   └── Generator
│       └── Generator.swift

The three files of interest are: GeneratorBuildTool.swift, GeneratorCommand.swift and Generator.swift. We'll go through each, but first let's see how the plugins communicate with the executable.

Before building your main sources (FooBar.swift in the example above) SPM will build the generator and the plugins. Then, each of the plugins will call the generator as needed, passing any input via command-line arguments.

Possible implementations for the plugins would look like this:

import Foundation
import PackagePlugin

enum GeneratorBuildToolError: Error {
    case failedToListPackageDirectory
    case noFilesToProcess
    case wrongTargetType
}

@main
struct GeneratorBuildTool: BuildToolPlugin {
    func createBuildCommands(context: PluginContext,
                             target: Target) async throws -> [Command]
    {
        let tool = try context.tool(named: "Generator")

        guard let target = target as? SwiftSourceModuleTarget else {
            throw GeneratorBuildToolError.wrongTargetType
        }

        let commands: [Command] = target
            .sourceFiles.map { $0.path }
            .compactMap {
                let filename = $0.lastComponent
                let outputName = $0.stem + "+Generated" + ".swift"
                let outputPath = context.pluginWorkDirectory.appending(outputName)

                return .buildCommand(displayName: "Processing \(filename)",
                                     executable: tool.path,
                                     arguments: [$0, outputPath],
                                     inputFiles: [$0],
                                     outputFiles: [outputPath])
            }

        return commands
    }
}

The GeneratorCommand would be similar.

Going through the code a bit we can see that we first get the generator from the context, so we can use its path later on. Then, we go through all the current target files and return a build command, passing each file as an input to the generator.

SPM will take this list of commands and create shell scripts (.sh) that will invoke the generator with the given arguments each time any of the input files change or on-demand (if the plugin is a command).

In Generator.swift we get the paths using CommandLine:

import Foundation

@main
struct Generator {
    public static func main() throws {
        let inputPath = String(CommandLine.arguments[1])
        let outputPath = String(CommandLine.arguments[2])

        // ...
    }
}
Note:

If you set the outputFiles in the plugin, you'll have to output something for each path, even if just an empty file, otherwise the build will fail.

From here the generator can process the files and all its output will be treated as FooBar target's source files are.

Security

The process that runs the plugin and the executable tool is sandboxed. It can only write to a specific destination on disk and doesn't have access to the network. In the future, more permissions like .writeToPackageDirectory(reason:) will be available, lifting these limitations.

Xcode issues

Plugins work great in SPM-only projects, but Xcode (13.3.1) has some annoying bugs. Here are the ones that I've found:

  1. Xcode tries to build the executable tool (the Generator target) for the current platform, not only for the host platform (macOS). This means that when we're targeting iOS, Xcode will try to compile the executable tool for iOS. This is sometimes not possible. As a workaround, distribute the executable tool as a binary dependency:
.binaryTarget(name: "Generator",
              url: "https://github.com/yourself/generator",
              checksum: "19293jd00ro30482is183943054na1sds91j3212")
  1. Sadly, Xcode doesn't support remote binary dependencies yet either (it hangs fetching them and fails with an unrelated error message). So, instead, you'll have to distribute it as a local binary dependency:
.binaryTarget(name: "Generator",
              path: "./Binaries/Generator.artifactbundle.zip")