Swift Digest
SE-0303 | Swift Evolution

Package Managerの拡張可能なビルドツール

Package Manager Extensible Build Tools

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

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

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 を指定する既存パッケージには影響しません。

03 今後の見通し

この Proposal は最初の一歩として意図的に最小限の機能に絞られており、以下のような拡張方向が示されています。いずれもあくまで将来の構想であり、実現を約束するものではありません。

型安全なプラグインオプション

クライアント側のマニフェストからプラグインへ、ターゲットごとに設定値を渡したいという要求があります。["Visibility": "Public"] のような文字列辞書で渡す案も考えられますが、利用できるキーや値が分かりづらく、"public" でも通るのかといった曖昧さが残ります。

そこでプラグイン側が PluginOptions のような Codable な型を提供し、マニフェストでは型付きの値として渡せる仕組みが望まれています。

// 将来構想のイメージ(現時点では利用できません)
.plugin(name: "Foo", options: FooOptions(visibility: .public))

ただし、これを実現するにはプラグインが定義した型をマニフェストから参照できる必要があり、マニフェストのパース前にプラグインモジュールのコンパイルが必要になるなど、設計上の課題が残されています。

ビルドツール用の独立した依存グラフ

現状では、ビルドツールとそのクライアントが同じパッケージ依存グラフを共有するため、バージョン要求が衝突するケース(たとえばビルドツールが Yams v3.x を、クライアントが v4.x を必要とする場合)には対応できません。

ビルドツール側に独立したパッケージグラフを持たせる方向が示されていますが、SwiftPM 本体やそれを利用する IDE の双方に大きな変更が必要となるため、別の課題として将来検討されます。

プラグインスクリプト自身が他ターゲットのライブラリを使えるようにする

初期バージョンでは、プラグインスクリプトは PackagePlugin と Swift 標準ライブラリしか利用できません。プラグインが他ターゲットのライブラリに依存できるようにする方向もありますが、それを実現するには、本来のパッケージビルドに先立ってプラグインが必要とするライブラリを段階的にビルドする仕組みが必要になります。

prebuildCommand から SwiftPM ビルドのツールを呼び出せるようにする

初期実装では、prebuildCommand の依存ツールは binaryTarget が提供するプリビルドバイナリのみで、SwiftPM がビルドする executableTarget は使えません。これは SwiftPM のビルドプラン構築上の制約に由来するもので、将来的に解消したいと述べられています。

ビルドコマンドの動的な出力

buildCommand は出力ファイル名が事前に分かることを前提にしています。コマンドの実行結果として生成される新しいファイルがビルドルールに再投入され、追加の処理を引き起こせる仕組みも将来の拡張対象です。これも SwiftPM 本体やそれを利用する IDE のビルドシステム側のサポートが必要になります。

ポストビルドコマンド

ビルド時のコマンドに加え、ビルドとテストがすべて完了した後に走るコマンド を扱える capability の追加が示されています。テスト結果や coverage を含むビルド全体の情報を構造化形式で受け取り、レポート生成や通知などに使うことが想定されています。

Linter / formatter 向けの専用 capability

Linter が出した fix-it を SwiftPM や IDE に伝える仕組みや、コードの書き換えを行う formatter のための専用 capability も検討されています。formatter のようにソースコードを書き換える操作は、通常のビルドの副作用としてではなく、開発者が明示的に呼び出すアクションとして提供する方向が示唆されています。

ターゲットプラットフォーム情報の提供

TargetBuildContext は現状、対象ターゲットの構造に関する情報しか提供せず、ビルド対象の OS やアーキテクチャといったプラットフォーム情報は含まれていません。コードジェネレータやリンカに近い高度なツールを実装するためには、これらの情報を参照できるようにする必要があります。

より高機能な Path

PackagePlugin が提供する Path 型は意図的にシンプルに保たれています。将来的には SwiftSystemFilePath 型に揃える、あるいはパッケージやビルドシステムが扱う各ディレクトリからの相対位置を保持できる、よりドメイン固有の表現へ置き換えるといった拡張が考えられています。利用できる API は tools version に紐付くため、既存のプラグインスクリプトはそのまま動作することが想定されています。

プラグインスクリプトのテスト支援

現状はテスト用のフィクスチャを介してプラグインを動かすことでしか検証できません。swift package コマンドから特定の入力でプラグインを直接呼び出せるようにするなど、プラグイン自体をテストするための支援も検討されています。

信頼できるプラグインの明示的な承認

他パッケージ由来のビルドツールを実行することには本質的なリスクがあるため、依存グラフ上のどこに置かれていても、ルートパッケージが承認したプラグインだけを有効にする仕組みも将来の検討対象として挙げられています。