Swift package manager plugins 是一個讓我們可以在編譯時期,自定義一些動作,以滿足更複雜的編譯需求,如 code generation。本文章將介紹兩種 plugins 並提供完整的範例。
Table of Contents
Swift Package Manager Plugins
Swift package manager plugins 讓我們在編譯時,可以執行一些自定義的動作,如 code generation。它提供兩種 plugins,分別是 build tool plugins 和 command plugins。其中 build tool plugins 又有 build commands 和 prebuild commands 兩種。
在 Package.swift 的 targets 裡,我們需要用 .plugin()
來宣告我們的 plugin。如果我們沒有指定 plugin 程式碼路徑的話,其預設為在 Plugins/ 資料夾下,與 plugin 名稱相同的資料夾。target 必須要在它的 plugins:
參數中列出會使用到的 plugins。
如下程式碼中,我們宣告一個 plugin 叫 SwiftGenPlugin。而,它的程式碼會在 Plugins/SwiftGenPlugin/ 資料夾下。另外,我們還宣告一個 target 叫 SwiftPluginExample。然後,它會使用到 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() ), ] )
我們也可以讓其他的 package 使用我們的 plugins。我們只要在 products 中,用 .plugin()
來 export 我們的 plugins,如下。
// 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
所有的 build tool plugins 都必須要實作 protocol BuildToolPlugin
。在 BuildToolPlugin.createBuildCommands()
中,我們要回傳一個或數個 commands。Swift package manager 會在 package resolution 和 validation 之後,執行 build tool plugins 以取得它們回傳的 commands。然後,根據 commands 提供的訊息,在 build time 時決定是否執行該 commands。
該注意的是,我們在 createBuildCommands()
中,只回傳 commands。真正的動作都會實作在 commands 裡面。而且,這些 commands 都必須是用 .binaryTarget()
或 .executableTarget()
宣告在 Package.swift 裡的 targets。
import PackagePlugin @main struct SwiftGenPlugin: BuildToolPlugin { func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { return [ ... ] } }
Build Commands
我們可以用 Command.buildCommand()
來建立一個 build command。Swift package manager 會依據 inputFiles
參數來決定是否需要執行該 command。也會根據 outputFiles
參數來決定何時需要執行該 command。outputFiles
中所列出的檔案,也會被編譯。
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 }
範例:Custom Code Generator
現在讓我們來用 build command 實作一個 code generator。在以下的 Package.swift 中,我們宣告一個 SwiftPluginExample,而它會使用到 VersionGenPlugin。而,真正產生 version 的程式碼是實作在 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
我們可以使用 Command.prebuildCommand()
來建立一個 prebuild command。Swift package manager 會在每一次開始 build 之前,先執行 prebuild commands。然後,Swift package manager 在編譯時,也會編譯 outputFilesDirectory
參數指定的資料夾下的檔案。
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 }
範例:SwiftGen Code Generator
SwiftGen 一個用來將專案裡的 assets、storyboards 等檔案,產生對應的程式碼。
// 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
不同於 build tool plugins 是在 build time 時自動被執行,command plugins 是使用者直接執行的 plugins。
使用者可以在 Swift package manager CLI 下執行 command plugins。
% swift package format-source-code
或是,在 Xcode 上直接執行。
Command Plugins
所有的 command plugins 都必須要實作 protocol CommandPlugin
。不同於 BuildToolPlugin.createBuildCommands()
,我們在 CommandPlugin.performCommand()
中,直接實作動作。
另外,由於 command plugins 是由使用者直接執行,所有不會有 target 依賴於它們。
import PackagePlugin @main struct FormatterPlugin: CommandPlugin { func performCommand(context: PluginContext, arguments: [String]) async throws { ... } }
範例:Swift-Format Formmattor
swift-format 是一個用來編排 Swift 程式碼。我們可以利用 command plugins 將 swift-format 整合到專案裡面。
// 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)") } } } }
結語
除了自己撰寫 plugins 之外,現在已經有很多第三方的 plugins。在 Swift package manager plugin 出來之前,這些第三方的工具是以執行檔的方式存在。我們常常會透過 Homebrew 或是 Gem 來將它們安裝至編譯環境。然而,有了 plugins 之後,我們不需要再額外手動安裝這些工具。 Swift package manager 在 package resolution 時,就會自動下載這些工具。這使得編譯的過程,更加簡單方便。
參考
- Getting Started with Plugins, Swift Package Manager.
- SE-0303, Package Manager Extensible Build Tools, Swift Evolution.
- SE-0332, Package Manager Command Plugins, Swift Evolution.