Swift Package Manager Plugins 教學

Photo by Susann Schuster on Unsplash
Photo by Susann Schuster on Unsplash
Swift package manager plugins 是一個讓我們可以在編譯時期,自定義一些動作,以滿足更複雜的編譯需求,如 code generation。

Swift package manager plugins 是一個讓我們可以在編譯時期,自定義一些動作,以滿足更複雜的編譯需求,如 code generation。本文章將介紹兩種 plugins 並提供完整的範例。

完整程式碼可以在 下載。

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 上直接執行。

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

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 時,就會自動下載這些工具。這使得編譯的過程,更加簡單方便。

參考

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

You May Also Like
Photo by Chris Murray on Unsplash
Read More

Swift Combine 教學

Swift Combine 是 Apple 用來實現 reactive programming 的函式庫。在 Combine 還沒出來之前,我們一般是使用 RxSwift。不過,使用 Combine 的話,我們就不需要再引入額外的函式庫。而且與 RxSwift 相比,Combine 的效能更好。
Read More
Photo by Ben White on Unsplash
Read More

Swift Concurrency 教學

Swift 5.5 推出了 Swift concurrency。它讓我們用 synchronous 的方式來完成 asynchronous code。大大地降低 asynchronous code 的複雜度。本文章將介紹 Swift concurrency 的基本知識。
Read More
Photo by Heather Barnes on Unsplash
Read More

如何建立並發佈 Swift Packages

Swift packages 是可重複使用的程式碼元件。它可包含程式碼、二進位檔、和資源檔。我們可以很容易地在我們的 app 專案中使用 Swift packages。本文章將介紹如何建立並發佈 Swift packages。
Read More