Creating an SPM build plugin

Posted Friday, September 8, 2023.

Introduction

As seen when creating a CLI in an artifact bundle, we made a CLI and packaged it into an artifact bundle.

In this post we are going to explore using this cli in a SPM build plugin to generate code/resources for us in another target. That target will then be vended as a library with the generated content.

Package Structure

The finished file structure of this package will look like:

.
├── Plugins
│ └── GeneratePlugin
│ └── Plugin.swift
├── Sources
│ └── MyCoolLib
│ └── MyCoolType.swift
├── input.json
├── Package.swift
└── Package.resolved

We are choosing to store our input.json in this package though that is not required

Package.swift

We will use MyCLI.artifactbundle that we created previously as a binary target. Our Package.swift will contain the following:

// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "my-cool-lib",
products: [
.library(name: "MyCoolLib", targets: ["MyCoolLib"])
],
targets: [
.plugin(name: "GeneratePlugin", capability: .buildTool(), dependencies: [
.target(name: "MyCLI")
]),
.target(name: "MyCoolLib", plugins: [
.plugin(name: "GeneratePlugin")
]),
.binaryTarget(name: "MyCLI", path: "../Path/To/my-cli/MyCLI.artifactbundle")
]
)

The binary target could be hosted and referenced by URL, but we are choosing to co-locate them for simplicity

Build Tool Plugin

In our build tool plugin, we will call our previously created CLI tool which will handle generating and writing content to the plugin working directory. This will create "read only" code and resources in any target that utilizes this plugin (in our case - the MyCoolLib target).

On clean builds or if the contents of info.json have changed, this build tool will run. Otherwise the contents generated from the previous run will be cached in DerivedData.

import Foundation
import PackagePlugin

@main
struct Plugin: BuildToolPlugin {
func createBuildCommands(
context: PluginContext,
target: Target
) async throws -> [Command] {
let inputPath = context.package.directory.appending("info.json")
let resourcesPath = context.pluginWorkDirectory.appending("Resources")
let generatedPath = context.pluginWorkDirectory.appending("Generated.swift")

let inputURL = URL(fileURLWithPath: inputPath.string)
let inputData = try Data(contentsOf: inputURL)
let info = try JSONDecoder().decode([String: String].self, from: inputData)
let txtPaths = info.keys.map { resourcesPath.appending("\($0).txt") }

return [
.buildCommand(
displayName: "Generate Code",
executable: try context.tool(named: "my-cli").path,
arguments: [
"--input",
inputPath,
"--generated",
generatedPath,
"--resources",
resourcesPath
],
inputFiles: [inputPath],
outputFiles: [generatedPath] + txtPaths
)
]
}
}

The arguments that we choose in .buildCommand(...) are the arguments that our CLI requires (ie they match 1:1).

I would have thought that including only [generatedPath, resourcesPath] for output files would have been good enough, but it did not work unless explicitly specifying the path of each created resource (x.txt). Hence, we load the info data just to read which txt files will be written by the cli.

MyCoolLib Target

As specified in the CLI's command code, the generated swift code is namespaced under MyCoolType.

Lets create a file in the MyCoolLib target named MyCoolType.swift with the following contents:

public enum MyCoolType {}

Because of the dependency of this target to the build plugin, whenever this target is built, we will have access to the generated code. So if the info.json had the following contents:

{
"hello": "this is a greeting message",
"goodbye": "this is a parting message"
}

We would expect the following:

  • MyCoolType should have 2 static members hello and goodbye
  • Two txt files should have been written
    • hello.txt with the contents this is a greeting message
    • goodbye.txt with the contents this is a parting message
  • the static members will read the message from the appropriate text file

Now we can do the following:

print(MyCoolType.hello) // this is a greeting message
print(MyCoolType.goodbye) // this is a parting message

In a future post, we will explore how to use this for localization generation in an iOS application


Tagged With: