Swift Digest
SE-0303 | Swift Evolution

Package Manager Extensible Build Tools

Proposal
SE-0303
Authors
Anders Bertelrud, Konrad 'ktoso' Malawski, Tom Doron
Review Manager
Tom Doron
Status
Implemented (5.6)

01 何が問題だったのか

Swift Package Manager(SwiftPM)は、ビルド中にパッケージ側のカスタム処理を差し込む手段を持っていませんでした。たとえば SwiftProtobuf.proto ファイルから Swift コードを生成したい、SwiftGen でアセットからソースコードを生成したい、といった「ソースジェネレータ」系のニーズを SwiftPM 単体では満たせませんでした。

そのため、パッケージ作者は次のような運用を強いられていました。

  • 生成コードを事前に手動で生成してリポジトリにコミットしておく
  • CI などパッケージの外側で別途ビルドスクリプトを走らせる
  • SwiftPM でビルドできない前提で、他のビルドシステム(Xcode のビルドフェーズなど)に頼る

これは「比較的単純なカスタマイズしか必要としないパッケージ」にとっても大きな制約で、SwiftPM でビルドできるコードベースの幅を狭めていました。ビルドの前後で生成物に対して追加の加工を行う(ビルド済みバイナリへの後処理など)といったケースも同様に難しく、拡張ポイントを最初から欠いた構造になっていました。

一方で、あらゆる種類の拡張を最初から盛り込もうとすると設計が巨大になり、まとまった時間で実装できません。必要だったのは、「プラグインが SwiftPM を拡張する共通の枠組み」を一度決めた上で、最初は「ビルド時に走らせるコマンドを生成する」という限定的な用途だけを定義 し、後から段階的に能力を追加していける足場です。そのためのプラグインの基本設計と、最初の拡張ポイントである build tool capability が必要でした。

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

SwiftPM に package plugin という新しい仕組みを導入し、パッケージ自身がビルドプロセスを拡張できるようにします。プラグインは「どのような形で SwiftPM を拡張するか」を capability として宣言し、この Proposal ではその最初の capability として buildTool(ビルド時に走らせるコマンドを生成する)を定義します。

プラグイン自体はツールを直接実行するのではなく、「SwiftPM に対してビルド中に走らせてほしいコマンドを組み立てて返す、小さな Swift スクリプト」として振る舞います。パッケージマニフェストが「このパッケージの構造」を宣言的に記述するためのスクリプトであるのと同じように、プラグインは「ビルドに差し込むコマンド」を手続き的に組み立てるためのスクリプトです。

プラグインの宣言と利用

プラグインは新しいターゲット種別 plugin として定義し、利用側ターゲットは target / executableTarget / testTarget に追加された plugins: パラメータで、そのプラグインを明示的にオプトインします。binaryTargetsystemLibrary は実体をビルドしないためプラグインの対象外です。

他パッケージに公開する場合は plugin 製品として product に追加しますが、同一パッケージ内で使うだけなら product 定義は不要です。

利用側(クライアント)パッケージの例です。

// swift-tools-version: 5.6
import PackageDescription

let package = Package(
    name: "MyPackage",
    dependencies: [
        .package(url: "https://github.com/SwiftGen/SwiftGen", from: "6.4.0")
    ],
    targets: [
        .executableTarget(
            name: "MyLibrary",
            plugins: [
                .plugin(name: "SwiftGenPlugin", package: "SwiftGen")
            ]
        )
    ]
)

同一パッケージ内のプラグインを使う場合は package: を省略できます。

.executableTarget(
    name: "MyExe",
    plugins: [.plugin(name: "MySourceGenPlugin")]
)

plugins: に並べた順序が適用順で、先に書いたプラグインの生成物を後続プラグインが入力として扱うことができます。

プラグインを提供する側の書き方

プラグインを提供するパッケージ側では、plugin ターゲットを .buildTool() capability で宣言し、ビルド時に実行する実行ファイル(同一パッケージ内の executableTarget か、binaryTarget が提供するプリビルドバイナリ)を dependencies に並べます。

// swift-tools-version: 5.6
import PackageDescription

let package = Package(
    name: "SwiftGen",
    targets: [
        .plugin(
            name: "SwiftGenPlugin",
            capability: .buildTool(),
            dependencies: ["SwiftGen"]
        ),
        .binaryTarget(
            name: "SwiftGen",
            url: "https://url/to/the/built/swiftgen-executables.zip",
            checksum: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
        ),
    ]
)

プラグインのスクリプト本体は、新しい PackagePlugin ライブラリを使った Swift スクリプトです。BuildToolPlugin プロトコルに適合する型を定義し、@main を付けてエントリポイントにします。createBuildCommands(context:) が SwiftPM から呼ばれ、戻り値として「ビルドで走らせたいコマンドの配列」を返します。

import PackagePlugin

@main struct SwiftGenPlugin: BuildToolPlugin {
    func createBuildCommands(context: TargetBuildContext) throws -> [Command] {
        let configFile = context.packageDirectory.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", "\(configFile)"],
            environment: [
                "PROJECT_DIR": "\(context.packageDirectory)",
                "TARGET_NAME": "\(context.targetName)",
                "DERIVED_SOURCES_DIR": "\(genSourcesDir)",
            ],
            outputFilesDirectory: genSourcesDir)]
    }
}

プラグインスクリプト自体は、初期バージョンでは PackagePlugin と Swift 標準ライブラリしか使えず、他のターゲットが提供するライブラリには依存できません。プラグインは軽いロジックでコマンドを組み立てるだけで、実際の処理は呼び出すツール側が担う想定です。

TargetBuildContext: プラグインに渡される情報

createBuildCommands に渡される context には、対象ターゲットに関する情報一式が入っています。主要なものは次のとおりです。

  • targetName / moduleName: ターゲット名・モジュール名
  • targetDirectory / packageDirectory: ターゲットソース/パッケージのディレクトリパス
  • inputFiles: ターゲットの入力ファイル(先に適用された他プラグインの生成物も含む)
  • dependencies: 対象ターゲットの依存閉包にあるターゲット群(トポロジカル順)
  • pluginWorkDirectory: プラグインおよび生成コマンドが書き込みに使える作業ディレクトリ。ビルド間で内容は保持される
  • builtProductsDirectory: ビルド成果物が書き出されるディレクトリ
  • tool(named:): 依存として宣言した実行ファイル(executable target またはバイナリツール)の path を名前で引くための関数

プラグインはパッケージディレクトリに対して読み取り専用アクセスを持ち、pluginWorkDirectory 以下にのみ書き込みができます(サンドボックス内で実行されます)。この Proposal では「マニフェストからプラグインに型安全にオプションを渡す」仕組みは含まれないため、設定ファイル(swiftgen.yml のような独自形式)を置いてプラグイン側で読むのが想定される使い方です。

buildCommandprebuildCommand

プラグインが返せるコマンドは次の 2 種類で、出力ファイル名が事前に分かるかどうかで使い分けます。

  • buildCommand: 出力ファイル名が事前に決まる場合に使います。入出力を宣言することでビルドグラフに組み込まれ、入力が変わった時・出力が無い時だけ走ります。Protobuf のように入力ファイル名から出力ファイル名が決まる「翻訳系」ツールに適しています。
  • prebuildCommand: 出力ファイル名が入力ファイル「内容」次第で決まるなど、事前に決められないときに使います。ビルドのたびに毎回走り、指定した outputFilesDirectory の中身をビルドシステムが入力として拾います。SwiftGen のように設定ファイル次第で任意の名前のファイルが出るツールに適しています。

prebuildCommand は毎回走るためパフォーマンスへの影響が大きく、内容が変わっていないファイルは出力のタイムスタンプを維持するなど、ツール側でキャッシュを効かせることが推奨されます。出力が事前に分かるなら buildCommand の方が望ましいとされています。

Protobuf のように「入力ファイルごとにコマンドを 1 個ずつ作る」パターンは buildCommand を使います。

import PackagePlugin

@main struct MyPlugin: BuildToolPlugin {
    func createBuildCommands(context: TargetBuildContext) throws -> [Command] {
        let protocTool = try context.tool(named: "protoc")
        let protocGenSwiftTool = try context.tool(named: "protoc-gen-swift")
        let genSourcesDir = context.pluginWorkDirectory.appending("GeneratedSources")

        let inputFiles = context.inputFiles.filter { $0.path.extension == "proto" }
        return inputFiles.map { inputFile in
            let outputName = inputFile.path.stem + ".swift"
            let outputPath = genSourcesDir.appending(outputName)
            return .buildCommand(
                displayName: "Generating \(outputName) from \(inputFile.path.stem)",
                executable: protocTool.path,
                arguments: [
                    "--plugin=protoc-gen-swift=\(protocGenSwiftTool.path)",
                    "--swift_out=\(genSourcesDir)",
                    "\(inputFile.path)",
                ],
                inputFiles: [inputFile.path],
                outputFiles: [outputPath])
        }
    }
}

ビルド成果物の後処理(ビルド後のバイナリを加工するなど)も、成果物ディレクトリのファイルを入力にする buildCommand として表現できます。ビルドとテストの完了後に走る本格的な「ポストビルド」は、将来の別 capability として拡張される想定です。

診断

プラグインスクリプトからは Diagnostics.error(_:) / Diagnostics.warning(_:) / Diagnostics.remark(_:) で SwiftPM に診断を伝えられます。error が 1 件でも出るか、メイン関数から例外がスローされると、プラグインは失敗と見なされます。print() で出力した内容はデバッグ出力として扱われます。生成されたコマンド自身のエラーは、通常のビルドコマンドと同じ扱いで表示されます。

サンドボックスとセキュリティ

プラグインスクリプトはネットワークアクセス不可、書き込み可能なのは指定された中間ディレクトリのみというサンドボックス内で実行されます。プラグインが生成した各コマンドも同様にサンドボックス化され、出力として宣言したディレクトリにしか書き込めません。他パッケージが提供するビルドツールを動かすことには本質的なリスクがあるため、将来的には「ルートパッケージが承認したプラグインだけを有効にする」仕組みも検討対象になっています。

PackagePlugin API の進化と互換性

PackageDescription と同様に、PackagePlugin の API もパッケージの Swift Tools Version に紐付いた availability で管理され、将来 API が拡張されても古い tools version を使うプラグインには影響しないように段階的に進化させられます。この新機能は Swift Tools Version 5.6 以降で利用可能になり、それより前の tools version を指定する既存パッケージには影響しません。

Future Directions

この Proposal は意図的に最小限にとどまっており、以下は将来的な拡張方向として示されています(実現を約束するものではありません)。

  • ビルドツールの依存グラフをクライアントと分離し、異なるバージョンの同じパッケージを共存させられるようにする
  • プラグインスクリプト自身が他のターゲットが提供するライブラリを使えるようにする
  • prebuildCommand から SwiftPM ビルドのツールを呼び出せるようにする
  • マニフェストからプラグインに型安全にオプションを渡す仕組み
  • ビルドとテストの完了後に走るコマンドのための、新しい capability
  • Linter / formatter 向けの専用 capability(fix-it を SwiftPM / IDE に伝える仕組みなど)
  • ターゲットプラットフォーム情報(OS・アーキテクチャなど)を TargetBuildContext から参照する仕組み
  • Path 型をより高機能にする(SwiftSystem.FilePath への揃え込みなど)