Swift Digest
SE-0325 | Swift Evolution

Additional Package Plugin APIs

Proposal
SE-0325
Authors
Anders Bertelrud
Review Manager
Tom Doron
Status
Implemented (Swift 5.6)

01 何が問題だったのか

SE-0303 で SwiftPM に build tool plugin(ビルドツールプラグイン)の仕組みが導入され、パッケージのビルド中に .proto から Swift ソースを生成するといった独自ツールを呼び出せるようになりました。その際にプラグインへ渡されるコンテキストとして TargetBuildContext が定義されましたが、これは「ビルド対象の単一ターゲットに関する最小限の情報」だけを提供するものでした。

// SE-0303 時点のエントリポイント(イメージ)
public protocol BuildToolPlugin: Plugin {
    func createBuildCommands(context: TargetBuildContext) throws -> [Command]
}

TargetBuildContext から取れるのは、呼び出されたターゲット自身の名前・ディレクトリ・入力ファイル、依存ターゲットのフラットなリスト、プラグインの作業ディレクトリ、呼び出せるツール名からパスへのマップといった情報に限られていました。これはコード生成のような「自ターゲットの入力を加工して出力を作る」ビルドツールには足りるものの、次のような用途には情報が不足していました。

  • パッケージグラフ全体を横断して、各パッケージやプロダクト・ターゲットの構造を把握したい
  • 依存ターゲットを「トポロジカル順」で並べ、それぞれに対して search path を組み立てたい
  • Swift ターゲットか Clang ターゲットか、あるいはバイナリターゲットやシステムライブラリターゲットかを区別して扱いたい
  • リポジトリ URL やバージョン、レジストリ ID といったパッケージの素性を取りたい

また、今後 build tool 以外のさまざまな種類のプラグイン(たとえばコマンドプラグインなど)を追加していくうえでも、プラグインに渡せる情報をパッケージグラフ全体を表現できる形に一般化しておくことが望まれていました。

02 どのように解決されるのか

PackagePlugin モジュールに、パッケージグラフ全体を表現できる新しい PluginContext 型と、それを使う新しい BuildToolPlugin のエントリポイントを追加します。既存の TargetBuildContext ベースのエントリポイントはそのまま残るため、既存プラグインは書き換えなしでそのまま動き続けます。

新しい PluginContext

PluginContext は、プラグインが適用されたパッケージ(package)と、プラグインが使える作業ディレクトリ(pluginWorkDirectory)、そしてツール名からツール情報を解決する tool(named:) だけを持つ、小さな構造体です。

public struct PluginContext {
    public let package: Package
    public let pluginWorkDirectory: Path

    public func tool(named name: String) throws -> Tool

    public struct Tool {
        public let name: String
        public let path: Path
    }
}

パッケージやターゲットに関する情報はすべて package から辿れるグラフに切り出されています。package から dependencies をたどることで、プラグインが適用されたパッケージから到達可能なすべてのパッケージを再帰的に取得できます(ルートパッケージでない場合は、グラフ全体のうち到達可能な部分集合のみが見えます)。

パッケージグラフ

パッケージ・プロダクト・ターゲットはそれぞれプロトコルとして定義され、具体的な種類ごとに構造体が用意されます(TargetProductIdentifiable に適合しないのは、異種コレクションを扱えるようにするためで、ID 型エイリアスと id プロパティは用意されています)。

public protocol Package {
    var id: ID { get }
    typealias ID = String
    var displayName: String { get }
    var directory: Path { get }
    var origin: PackageOrigin { get }
    var toolsVersion: ToolsVersion { get }
    var dependencies: [PackageDependency] { get }
    var products: [Product] { get }
    var targets: [Target] { get }
}

public enum PackageOrigin {
    case root
    case local(path: String)
    case repository(url: String, displayVersion: String, scmRevision: String)
    case registry(identity: String, displayVersion: String)
}

PackageOrigin でそのパッケージがルートなのか、ローカル参照なのか、Git リポジトリから来たのか、レジストリから来たのかを識別できます。Git の場合は解決済みの SCM リビジョン(コミットハッシュ)まで取れるため、生成コードにバージョン情報を埋め込みたいプラグインにも使えます。

プロダクトは実行ファイルかライブラリかで具体型が分かれます。

public protocol Product {
    var id: ID { get }
    typealias ID = String
    var name: String { get }
    var targets: [Target] { get }
}

public struct ExecutableProduct: Product {
    public let mainTarget: Target
}

public struct LibraryProduct: Product {
    public let kind: Kind
    public enum Kind { case `static`, `dynamic`, automatic }
}

ターゲットも、Swift/Clang のソースモジュール、バイナリアーティファクト、システムライブラリの 4 種類に分かれます。ソースモジュールに共通の情報は SourceModuleTarget プロトコルに集約されています。

public protocol Target {
    var id: ID { get }
    typealias ID = String
    var name: String { get }
    var directory: Path { get }
    var dependencies: [TargetDependency] { get }
}

public enum TargetDependency {
    case target(Target)
    case product(Product)
}

public protocol SourceModuleTarget: Target {
    var moduleName: String { get }
    var sourceFiles: FileList { get }
    var linkedLibraries: [String] { get }
    var linkedFrameworks: [String] { get }
}

public struct SwiftSourceModuleTarget: SourceModuleTarget {
    public let compilationConditions: [String]
}

public struct ClangSourceModuleTarget: SourceModuleTarget {
    public let preprocessorDefinitions: [String]
    public let headerSearchPaths: [Path]
    public let publicHeadersDirectory: Path?
}

public struct BinaryArtifactTarget: Target {
    public let kind: Kind          // .xcframework / .artifactsArchive
    public let origin: Origin      // .local / .remote(url:)
    public let artifact: Path
}

public struct SystemLibraryTarget: Target {
    public let pkgConfig: String?
    public let compilerFlags: [String]
    public let linkerFlags: [String]
}

ソースファイルは FileListSequence)として提供され、各 Filepathtype.source / .header / .resource / .unknown)を持ちます。将来 FileType に新しいケースが追加されても、既存プラグインの tools version を上げない限り動作が壊れないよう、availability アノテーションで制御される想定です。

新しい BuildToolPlugin のエントリポイント

BuildToolPlugin には、新しい PluginContext と、ビルドコマンドを生成する対象の Target を受け取るエントリポイントが追加されます。

public protocol BuildToolPlugin: Plugin {
    func createBuildCommands(
        context: PluginContext,
        target: Target
    ) throws -> [Command]
}

既存の TargetBuildContext を受け取るエントリポイントも残り、新しいエントリポイントのデフォルト実装が内部で旧エントリポイントへ委譲するため、既存のプラグインはそのままで動きます。新しく書くプラグインや、パッケージグラフ全体の情報を使いたいプラグインは、この新しいエントリポイントを実装します。

補助 API

よく必要になる操作として、次の 2 つがまず追加されます。

extension Target {
    // 依存ターゲットの推移的閉包をトポロジカル順で返す
    public var recursiveTargetDependencies: [Target] { get }
}

extension SourceModuleTarget {
    // 指定した拡張子のソースファイルだけを絞り込む
    public func sourceFiles(withSuffix: String) -> FileList
}

使用例: SwiftGen

swiftgen.yml を読んで swiftgen をビルド前に一度だけ走らせる prebuild コマンドを返す例です。context.package.directory でパッケージルート、context.pluginWorkDirectory で書き込み可能な作業ディレクトリ、context.tool(named:) でプラグインが依存する実行ファイルのパスを取れます。

import PackagePlugin

@main
struct SwiftGenPlugin: BuildToolPlugin {
    func createBuildCommands(context: PluginContext, target: Target) throws -> [Command] {
        let swiftGenConfigFile = context.package.directory.appending("swiftgen.yml")
        let genSourcesDir = context.pluginWorkDirectory.appending("GeneratedSources")

        return [.prebuildCommand(
            displayName: "Running SwiftGen",
            executable: try context.tool(named: "swiftgen").path,
            arguments: ["config", "run", "--config", "\(swiftGenConfigFile)"],
            environment: [
                "PROJECT_DIR": "\(context.package.directory)",
                "TARGET_NAME": "\(target.name)",
                "DERIVED_SOURCES_DIR": "\(genSourcesDir)",
            ],
            outputFilesDirectory: genSourcesDir)]
    }
}

使用例: SwiftProtobuf

.proto ファイルごとに protoc を呼び出して Swift ソースを生成する例です。target.recursiveTargetDependencies を使って依存ターゲットのディレクトリから protos 以下を -I search path として組み立て、target.sourceFiles(withSuffix: ".proto") で入力ファイルを抽出しています。

import PackagePlugin
import Foundation

@main
struct MyPlugin: BuildToolPlugin {
    func createBuildCommands(context: PluginContext, target: Target) throws -> [Command] {
        let protocTool = try context.tool(named: "protoc")
        let protocGenSwiftTool = try context.tool(named: "protoc-gen-swift")

        // 依存ターゲットの protos ディレクトリを search path に
        var protoSearchPaths = target.recursiveTargetDependencies.map {
            $0.directory.appending("protos")
        }

        let genSourcesDir = context.pluginWorkDirectory.appending("GeneratedSources")

        // .proto ごとに buildCommand を作る
        let inputFiles = target.sourceFiles(withSuffix: ".proto")
        return inputFiles.map { inputFile in
            let outputName = inputFile.path.stem + ".swift"
            let outputPath = genSourcesDir.appending(outputName)

            var commandArgs = [
                "--plugin=protoc-gen-swift=\(protocGenSwiftTool.path)",
                "--swift_out=\(genSourcesDir)",
            ]
            commandArgs.append(contentsOf: protoSearchPaths.flatMap { ["-I", "\($0)"] })
            commandArgs.append("\(inputFile.path)")

            return .buildCommand(
                displayName: "Generating \(outputName) from \(inputFile.path.stem)",
                executable: protocTool.path,
                arguments: commandArgs,
                inputFiles: [inputFile.path],
                outputFiles: [outputPath])
        }
    }
}

セキュリティ

プラグインは SE-0303 と同じサンドボックス(ネットワーク禁止、書き込み可能な場所も限定)で動作します。この提案は「プラグインに渡す情報」を拡張するだけで、すでにパッケージグラフ上で定義されている情報を見せているに過ぎないため、プラグインに新しい権限を与えるものではありません。

今後の展望

この提案は、build tool plugin 以外の新しい種類のプラグイン(コマンドプラグインなど、パッケージグラフ全体の情報を必要とするもの)を将来導入するための土台づくりという位置づけです。また recursiveTargetDependenciessourceFiles(withSuffix:) のような補助 API も、今後プラグインからよく使われる操作が見えてくるに従って追加されていくことが示唆されています。実現が約束されたものではありませんが、SwiftPM の組み込みサブシステムの一部をプラグインとして再実装していける可能性も見込まれています。