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 .
Table of Contents
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.
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
- Getting Started with Plugins, Swift Package Manager.
- SE-0303, Package Manager Extensible Build Tools, Swift Evolution.
- SE-0332, Package Manager Command Plugins, Swift Evolution.