Swift Package Manager Plugins Tutorial

Photo by Susann Schuster on Unsplash
Photo by Susann Schuster on Unsplash
Swift package manager plugins allow us to customize some actions during build time to meet more complex compilation requirements, such as code generation.

Swift package manager plugins allow us to customize some actions during build time to meet more complex compilation requirements, such as code generation. This article will introduce two plugins and provide complete examples.

The complete code for this chapter can be found in .

Swift Package Manager Plugins

Swift package manager plugins allow us to perform some custom actions during build time, such as code generation. It provides two kinds of plugins, namely build tool plugins and command plugins. Among them, build tool plugins include build commands and prebuild commands.

In the targets of Package.swift, we need to declare our plugin using .plugin(). If we do not specify the plugin code path, it defaults to the folder with the same name as the plugin in the Plugins/ folder. The target must list the plugins to be used in its plugins: parameter.

In the following code, we declare a plugin called SwiftGenPlugin. However, its code will be in the Plugins/SwiftGenPlugin/ folder. In addition, we also declare a target called SwiftPluginExample. And, it uses SwiftGenPlugin.

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

import PackageDescription

let package = Package(
    name: "SwiftPluginExample",
    platforms: [.iOS(.v13)],
    products: [
        .library(name: "SwiftPluginExample", targets: ["SwiftPluginExample"]),
    ],
    targets: [
        .target(
            name: "SwiftPluginExample",
            plugins: [
                .plugin(name: "SwiftGenPlugin"),
            ]
        ),
        
        .plugin(
            name: "SwiftGenPlugin",
            capability: .buildTool()
        ),
    ]
)

We can also let other packages use our plugins. We only need to use .plugin() to export our plugins in products, as follows.

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

import PackageDescription

let package = Package(
    name: "SwiftPluginExample",
    platforms: [.iOS(.v13)],
    products: [
        ...
        .plugin(name: "SwiftGenPlugin", targets: ["SwiftGenPlugin"]),
    ],
    targets: [
        ...
        .plugin(
            name: "SwiftGenPlugin",
            capability: .buildTool()
        ),
    ]
)

Build Tool Plugins

All build tool plugins must implement the protocol BuildToolPlugin. In BuildToolPlugin.createBuildCommands(), we need to return one or several commands. Swift package manager will execute build tool plugins after package resolution and validation to get the commands they return. Then, based on the information provided by the commands, it decides whether to execute the commands at build time.

It should be noted that in createBuildCommands(), we only return commands. The actions will be implemented in the commands. Moreover, these commands must be targets declared in Package.swift using .binaryTarget() or .executableTarget().

import PackagePlugin

@main struct SwiftGenPlugin: BuildToolPlugin {
    func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
        return [
            ...
        ]
    }
}

Build Commands

We can use Command.buildCommand() to create a build command. The Swift package manager will determine whether the command needs to be executed based on the inputFiles parameter. The outputFiles parameter will also be used to determine when the command needs to be executed. Files listed in outputFiles will also be compiled.

extension Command {
    /// Creates a command to run during the build. The executable should be a
    /// tool returned by `PluginContext.tool(named:)`, and any paths in the
    /// arguments list as well as in the input and output lists should be
    /// based on the paths provided in the target build context structure.
    ///
    /// The build command will run whenever its outputs are missing or if its
    /// inputs have changed since the command was last run. In other words,
    /// it is incorporated into the build command graph.
    ///
    /// This is the preferred kind of command to create when the outputs that
    /// will be generated are known ahead of time.
    ///
    /// - parameters:
    ///   - displayName: An optional string to show in build logs and other
    ///     status areas.
    ///   - executable: The executable to be invoked; should be a tool looked
    ///     up using `tool(named:)`, which may reference either a tool provided
    ///     by a binary target or build from source.
    ///   - arguments: Arguments to be passed to the tool. Any paths should be
    ///     based on the paths provided in the target build context.
    ///   - environment: Any custom environment assignments for the subprocess.
    ///   - inputFiles: Input files to the build command. Any changes to the
    ///     input files cause the command to be rerun.
    ///   - outputFiles: Output files that should be processed further according
    ///     to the rules defined by the build system.
    public static func buildCommand(displayName: String?, executable: PackagePlugin.Path, arguments: [CustomStringConvertible], environment: [String : CustomStringConvertible] = [:], inputFiles: [PackagePlugin.Path] = [], outputFiles: [PackagePlugin.Path] = []) -> PackagePlugin.Command
}

Example: Custom Code Generator

Now let’s implement a code generator using the build command. In the following Package.swift, we declare a SwiftPluginExample, which uses VersionGenPlugin. However, the code that actually generates version is implemented in vensiongen.

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

import PackageDescription

let package = Package(
    name: "SwiftPluginExample",
    platforms: [.iOS(.v13)],
    products: [
        .library(name: "SwiftPluginExample", targets: ["SwiftPluginExample"]),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"),
    ],
    targets: [
        .target(
            name: "SwiftPluginExample",
            plugins: [
                .plugin(name: "VersionGenPlugin"),
            ]
        ),

        .plugin(
            name: "VersionGenPlugin",
            capability: .buildTool(),
            dependencies: ["versiongen"]
        ),
        .executableTarget(
            name: "versiongen",
            dependencies: [
                .product(name: "ArgumentParser", package: "swift-argument-parser"),
            ]
        ),
    ]
)
import Foundation
import ArgumentParser

@main
struct VersionGen: ParsableCommand {
    enum Errors: Error {
        case inputPathMissing
        case outputPathMissing
        case inputFileNotFound
        case illegalInputFile
    }
    
    @Option(name: .shortAndLong, help: "The path of the version input xcconfig file")
    var input: String? = nil
    
    @Option(name: .shortAndLong, help: "The path of the version output source file")
    var output: String? = nil
    
    mutating func run() throws {
        guard let inputPath = input else {
            throw Errors.inputPathMissing
        }
        guard let outputPath = output else {
            throw Errors.outputPathMissing
        }
        
        guard FileManager.default.fileExists(atPath: inputPath) else {
            throw Errors.inputFileNotFound
        }
        
        let inputContent = try String(contentsOfFile: inputPath, encoding: .utf8)
        let inputLines = inputContent.components(separatedBy: .newlines)
        var version = ""
        for line in inputLines {
            let str = line.trimmingCharacters(in: .whitespacesAndNewlines)
            if str.hasPrefix("VERSION") {
                let parts = str.components(separatedBy: "=")
                if parts.count == 2 {
                    version = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
                }
            }
        }
        guard version != "" else {
            throw Errors.illegalInputFile
        }
        
        if FileManager.default.fileExists(atPath: outputPath) {
            try FileManager.default.removeItem(atPath: outputPath)
        }
        
        try """
            struct Version {
                static let version = "(version)"
            }
            """.write(toFile: outputPath, atomically: true, encoding: .utf8)
    }
}
// Version.xcconfig
VERSION = 2.0.0
import PackagePlugin

@main
struct VersionGenPlugin: BuildToolPlugin {
    func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
        let versionConfig = context.package.directory.appending("Version.xcconfig")
        let versionSource = context.pluginWorkDirectory.appending("Version.swift")
        
        return [.buildCommand(
            displayName: "VersionGen plugin",
            executable: try context.tool(named: "versiongen").path,
            arguments: [
                "--input", "(versionConfig)",
                "--output", "(versionSource)",
            ],
            inputFiles: [versionConfig],
            outputFiles: [versionSource]
        )]
    }
}
import UIKit

class HelloWorldViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let label = UILabel()
        label.text = "Version is (Version.version)"
    }
}

Prebuild Commands

We can use Command.prebuildCommand() to create a prebuild command. Swift package manager will execute prebuild commands before each build. Then, when the Swift package manager builds, it will also build the files in the folder specified by the outputFilesDirectory parameter.

extension Command {
    /// Creates a command to run before the build. The executable should be a
    /// tool returned by `PluginContext.tool(named:)`, and any paths in the
    /// arguments list and in the output files directory should be based on
    /// the paths provided in the target build context structure.
    ///
    /// The build command will run before the build starts, and is allowed to
    /// create an arbitrary set of output files based on the contents of the
    /// inputs.
    ///
    /// Because prebuild commands are run on every build, they can have a
    /// significant performance impact and should only be used when there is
    /// no way to know the names of the outputs before the command is run.
    ///
    /// The `outputFilesDirectory` parameter is the path of a directory into
    /// which the command will write its output files. Any files that are in
    /// that directory after the prebuild command finishes will be interpreted
    /// according to the same build rules as for sources.
    ///
    /// - parameters:
    ///   - displayName: An optional string to show in build logs and other
    ///     status areas.
    ///   - executable: The executable to be invoked; should be a tool looked
    ///     up using `tool(named:)`, which may reference either a tool provided
    ///     by a binary target or build from source.
    ///   - arguments: Arguments to be passed to the tool. Any paths should be
    ///     based on the paths provided in the target build context.
    ///   - environment: Any custom environment assignments for the subprocess.
    ///   - outputFilesDirectory: A directory into which the command can write
    ///     output files that should be processed further.
    public static func prebuildCommand(displayName: String?, executable: PackagePlugin.Path, arguments: [CustomStringConvertible], environment: [String : CustomStringConvertible] = [:], outputFilesDirectory: PackagePlugin.Path) -> PackagePlugin.Command
}

Example: SwiftGen Code Generator

SwiftGen is used to generate corresponding code for assets, storyboards and other files in the project.

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

import PackageDescription

let package = Package(
    name: "SwiftPluginExample",
    platforms: [.iOS(.v13)],
    products: [
        .library(name: "SwiftPluginExample", targets: ["SwiftPluginExample"]),
    ],
    targets: [
        .target(
            name: "SwiftPluginExample",
            plugins: [
                .plugin(name: "SwiftGenPlugin"),
            ]
        ),
        
        .plugin(
            name: "SwiftGenPlugin",
            capability: .buildTool(),
            dependencies: ["swiftgen"]
        ),
        .binaryTarget(
            name: "swiftgen",
            url: "https://github.com/SwiftGen/SwiftGen/releases/download/6.6.2/swiftgen-6.6.2.artifactbundle.zip",
            checksum: "7586363e24edcf18c2da3ef90f379e9559c1453f48ef5e8fbc0b818fbbc3a045"
        ),
    ]
)
input_dir: ${TARGET_DIR}
output_dir: ${DERIVED_SOURCES_DIR}

xcassets:
  inputs:
    - Colors.xcassets
  outputs:
    templateName: swift5
    output: Colors.swift
import PackagePlugin

@main
struct SwiftGenPlugin: BuildToolPlugin {
    func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
        let configPath = context.package.directory.appending("ColorGen.yml")
        return [.prebuildCommand(
            displayName: "SwiftGen plugin",
            executable: try context.tool(named: "swiftgen").path,
            arguments: [
                "config",
                "run",
                "--verbose",
                "--config", "(configPath)"
            ],
            environment: [
                "TARGET_DIR": target.directory,
                "DERIVED_SOURCES_DIR": context.pluginWorkDirectory
            ],
            outputFilesDirectory: context.pluginWorkDirectory
        )]
    }
}
import UIKit

class HelloWorldViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let label = UILabel()
        label.textColor = Asset.blue.color
    }
}

Command Plugins

Unlike build tool plugins, which are automatically invoked at build time, command plugins are plugins that are invoked directly by the user.

Users can invoke command plugins under the Swift package manager CLI.

% swift package format-source-code

Or, invoke it directly on Xcode.

Right click on the package, and invoke a command plugin.
Right click on the package, and invoke a command plugin.

Command Plugins

All command plugins must implement protocol CommandPlugin. Different from BuildToolPlugin.createBuildCommands(), we directly implement the action in CommandPlugin.performCommand().

In addition, since command plugins are invoked directly by users, there are no targets that depend on them.

import PackagePlugin

@main
struct FormatterPlugin: CommandPlugin {
    func performCommand(context: PluginContext, arguments: [String]) async throws {
        ...
    }
}

Example: Swift-Format Formmattor

swift-format is a tool for formatting Swift code. We can use command plugins to integrate swift-format into the project.

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

import PackageDescription

let package = Package(
    name: "SwiftPluginExample",
    platforms: [.iOS(.v13)],
    products: [
        .library(name: "SwiftPluginExample", targets: ["SwiftPluginExample"]),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-format.git", from: "509.0.0"),
    ],
    targets: [
        .plugin(
            name: "FormatterPlugin",
            capability: .command(
                intent: .sourceCodeFormatting(),
                permissions: [
                    .writeToPackageDirectory(reason: "Formatting the source files"),
                ]
            ),
            dependencies: [
                .product(name: "swift-format", package: "swift-format"),
            ]
        )
    ]
)
import PackagePlugin
import Foundation

@main
struct FormatterPlugin: CommandPlugin {
    func performCommand(context: PluginContext, arguments: [String]) async throws {
        let formatter = try context.tool(named: "swift-format")
        let config = context.package.directory.appending(".swift-format.json")
        
        for target in context.package.targets {
            guard let target = target as? SourceModuleTarget else { continue }

            let exec = URL(fileURLWithPath: formatter.path.string)
            let args = [
                "--configuration", "(config)",
                "--in-place",
                "--recursive",
                "(target.directory)"
            ]
            let process = try Process.run(exec, arguments: args)
            process.waitUntilExit()
            
            if process.terminationReason == .exit && process.terminationStatus == 0 {
                print("Formatted the source code in (target.directory)")
            } else {
                Diagnostics.error("Formmating the source code failed: (process.terminationReason):(process.terminationStatus)")
            }
        }
    }
}

Conclusion

In addition to writing your own plugins, there are now many third-party plugins. Before the Swift package manager plugin came out, these third-party tools existed in the form of executable files. We often install them into the build environment through Homebrew or Gem. However, with plugins, we no longer need to manually install these tools. Swift package manager will automatically download these tools during package resolution. This makes the build process simpler and more convenient.

Reference

Leave a Reply

Your email address will not be published. Required fields are marked *

You May Also Like