Package Manager Command Plugins
01 何が問題だったのか
SE-0303 で SwiftPM に build tool plugin の仕組みが導入されました。これは .proto ファイルから Swift のソースを生成するなど、ビルドのたびに自動的に実行されるコード生成ツールを定義するためのものです。ビルドグラフに安全に組み込むという性質上、パッケージディレクトリ内のファイルを書き換えられないなどの制約が課されています。
一方で、ユーザーが利用したくなるパッケージ関連の処理には、ビルドの一部として自動実行する性格のものではなく、ユーザーが明示的に起動したいものが多くあります。たとえば次のような処理です。
- ドキュメントの生成(
doccの実行など) - ソースコードの整形(フォーマッタの実行)
- テスト結果レポートの生成
- リリースビルドの後処理(配布用アーカイブの作成など)
これらの処理は build tool plugin の枠組みには収まりません。build tool plugin はビルドグラフに組み込まれる前提なので、ユーザーの要求に応じて任意のタイミングで呼び出したり、パッケージディレクトリを書き換えたり、必要に応じてビルドやテストを自分から起動したり、といった自由度を持たせにくいのです。
また、こうしたプラグインの仕組みは SwiftPM の CLI だけでなく、Swift Package をサポートする IDE からも同じように利用できる必要があります。そのため、特定のホストに依存しない形で「パッケージに対するユーザー起動のコマンド」を定義できる、汎用的な仕組みが求められていました。
02 どのように解決されるのか
SwiftPM のプラグインに新しい capability として command を導入します。command plugin は build tool plugin と違い、ビルド時に自動実行されるのではなく、ユーザーが swift package <verb> の形で明示的に起動します。IDE からも同様にユーザー操作で起動されることを想定しています。
コマンドの意図(intent)と verb
command plugin はその「意図」を宣言します。意図はあらかじめ用意されたもの(ドキュメント生成、ソースコードフォーマットなど)か、カスタムの verb を指定します。意図ごとに CLI で使う verb が決まっており、たとえばドキュメント生成の plugin は swift package generate-documentation で起動されます。
パッケージマニフェストでは次のように宣言します。
// swift-tools-version: 5.6
import PackageDescription
let package = Package(
name: "MyDocCPlugin",
products: [
.plugin(name: "MyDocCPlugin", targets: ["MyDocCPlugin"]),
],
targets: [
.plugin(
name: "MyDocCPlugin",
capability: .command(
intent: .documentationGeneration()
)
)
]
)
intent には次のものが用意されています。
.documentationGeneration()— verb はgenerate-documentation.sourceCodeFormatting()— verb はformat-source-code.custom(verb:description:)— 任意の verb を付けたカスタムコマンド
同じ意図やカスタム verb を持つ plugin が依存グラフ中に複数ある場合は、MyPlugin:do-something のように plugin ターゲット名で修飾して呼び出します。
権限(permissions)
command plugin は build tool plugin と同様、対応プラットフォームではサンドボックス内で実行され、デフォルトではパッケージディレクトリへの書き込みもネットワークアクセスもできません(一時ディレクトリへの書き込みは可能)。
フォーマッタのようにパッケージ内のソースを書き換える必要がある場合は、permissions で .writeToPackageDirectory(reason:) を要求します。
.plugin(
name: "MyFormatterPlugin",
capability: .command(
intent: .sourceCodeFormatting(),
permissions: [
.writeToPackageDirectory(reason: "This command reformats source files")
]
),
dependencies: [
.product(name: "swift-format", package: "swift-format"),
]
)
reason はユーザーに許可を求めるときに表示される文言です。CLI では TTY 経由でプロンプトが出され、TTY でない場合はエラーになります。CI などで対話を避けたい場合は swift package --allow-writing-to-package-directory <verb> で事前に許可できます。IDE ではそれぞれに適した UI で確認が行われます。
ネットワークアクセスの許可はこの提案には含まれておらず、将来の提案に委ねられています。
plugin の実装
command plugin は PackagePlugin モジュールの CommandPlugin プロトコルに適合する型として実装します。
public protocol CommandPlugin: Plugin {
func performCommand(
context: PluginContext,
arguments: [String]
) async throws
var packageManager: PackageManager { get }
}
context にはパッケージグラフや作業ディレクトリ、context.tool(named:) による外部ツールのパス解決など、すべての plugin に共通する情報が入っています。arguments にはユーザーが verb の後ろに書いた文字列がそのまま渡されるので、--target Foo のようなオプションは plugin 側で解釈します。
実際の処理は、Foundation の Process で外部ツールを起動したり、Foundation の API でファイルを読み書きしたりして進めます。build tool plugin のように「コマンドを宣言してあとで実行してもらう」形ではなく、performCommand が呼ばれた時点でそのまま処理を行い、完了してから戻るモデルです。
ドキュメント生成の例
docc を呼び出してドキュメントを生成する plugin は次のように書けます。packageManager.getSymbolGraph(for:options:) でシンボルグラフの生成をホスト(SwiftPM あるいは IDE)に依頼している点がポイントです。
import PackagePlugin
import Foundation
@main
struct MyDocCPlugin: CommandPlugin {
func performCommand(
context: PluginContext,
arguments: [String]
) async throws {
let doccTool = try context.tool(named: "docc")
let outputDir = context.pluginWorkDirectory.appending("Outputs")
for target in context.package.targets {
guard let target = target as? SourceModuleTarget else { continue }
let doccCatalog = target.sourceFiles.first { $0.path.extension == "docc" }
let symbolGraphInfo = try await packageManager.getSymbolGraph(
for: target,
options: .init(
minimumAccessLevel: .public,
includeSynthesized: false,
includeSPI: false))
let doccExec = URL(fileURLWithPath: doccTool.path.string)
var doccArgs = ["convert"]
if let doccCatalog = doccCatalog {
doccArgs += ["\(doccCatalog.path)"]
}
doccArgs += [
"--fallback-display-name", target.name,
"--fallback-bundle-identifier", target.name,
"--fallback-bundle-version", "0",
"--additional-symbol-graph-dir", "\(symbolGraphInfo.directoryPath)",
"--output-dir", "\(outputDir)",
]
let process = try Process.run(doccExec, arguments: doccArgs)
process.waitUntilExit()
}
}
}
PackageManager サービス
CommandPlugin から利用できる packageManager プロパティは、SwiftPM あるいは plugin をホストする IDE への窓口です。build tool plugin では利用できなかった、次のような機能を呼び出せます。
build(_:parameters:)— 指定した製品・ターゲット・全体のビルドを実行する。BuildConfiguration(.debug/.release)やログの詳細度、追加のコンパイラフラグなどが指定でき、結果として成否・ログ・生成物(実行ファイル、動的/静的ライブラリ)の一覧が返る。test(_:parameters:)— 全テストまたは正規表現で絞り込んだテストを実行する(swift test --filterと同等)。コードカバレッジも収集可能で、テストターゲット/テストケース/個々のテストごとの結果が返る。getSymbolGraph(for:options:)— 指定ターゲットのシンボルグラフをホストに生成させ、そのディレクトリを受け取る。アクセスレベル、synthesized メンバー、SPI の扱いを指定できる。
たとえば配布用アーカイブを作る plugin は、build を呼んでリリースビルドを作らせてから、その成果物を zip でまとめる、という流れで書けます。
let result = try await packageManager.build(
.product(productName),
parameters: .init(configuration: .release, logging: .concise)
)
guard result.succeeded else { throw Error("couldn't build product") }
let builtExecutables = result.builtArtifacts.filter { $0.kind == .executable }
API は意図的に最小限に絞られており、今後の提案で拡張される前提です。また、SwiftPM 以外のビルドシステムを持つ IDE でも実装できる範囲に留める、という方針が置かれています。
コマンドの呼び出しと発見
依存グラフから自分のパッケージで利用できる command plugin を一覧するには、次を使います。
❯ swift package plugin --list --capability=command
--json で機械可読な出力にもできます。個々のパッケージが定義する plugin は swift package describe の出力にも含まれます。
build tool plugin と違い、command plugin を使うためにマニフェストの plugins: で明示的に結び付ける必要はありません。依存パッケージが提供する command plugin はそのまま verb として使えます。
Future Directions
この提案では API を意図的に小さく保っており、今後の拡張が見込まれています(あくまで方向性で、実現が約束されているわけではありません)。
- plugin が SwiftArgumentParser 相当の仕組みで入力パラメータを宣言し、SwiftPM や IDE がそれに応じた UI を提供できるようにする
PackageManagerAPI の拡張(より細かいビルド制御、ビルド・テスト進捗のインクリメンタルな受け取り、構造化されたビルドログなど)- plugin からの進捗報告の仕組み
- ネットワークアクセスや、ユーザー指定の書き込み先ディレクトリなど、より柔軟な権限モデル