Swift Digest
SE-0500 | Swift Evolution

Improving package creation with custom templates: SwiftPM Template Initialization

Proposal
SE-0500
Authors
John Bute
Review Manager
Franz Busch
Status
Accepted

01 何が問題だったのか

swift package init は新しいSwiftパッケージを作り始めるための基本的な入り口ですが、用意されているテンプレートは library / executable / tool / build-tool-plugin / command-plugin / macro / empty といった、SwiftPMに組み込みでハードコードされたごくわずかな雛形に限られていました。

しかし実際のプロジェクトでは、「HTTPサーバーを立ち上げたい」「OpenAPI仕様を実装するパッケージを作りたい」「社内標準のディレクトリ構成やCI設定を含んだ状態で始めたい」など、もっと具体的で複雑な初期状態から始めたいケースが多くあります。こうした用途は現状、SwiftPMの外側にあるサードパーティ製のコマンドラインツールや自前のスクリプトで賄われていることがほとんどで、次のような問題を生んでいます。

  • 初期化ワークフローがSwiftPMのエコシステムから切り離されているため、テンプレートの発見・共有・カスタマイズがしにくい
  • ツールごとに配布方法や依存関係の解決方法が異なり、利用者が学び直しを強いられる
  • 企業が社内向けに用意したスキャフォールドを、Swiftパッケージとしての配布経路に乗せにくい

つまり、「初期化の雛形」自体をSwiftパッケージと同じ仕組みで作って配布し、swift package init から第一級の手段として呼び出したい、というのがこのProposalの動機です。

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

swift package init を拡張し、SwiftPMに組み込まれた雛形だけでなく、外部のSwiftパッケージとして配布された「テンプレート」からプロジェクトを生成できるようにします。テンプレートはローカルパス・Gitリポジトリ・Swift package registry のいずれからも取得でき、通常のパッケージ依存と同じ解決経路に乗ります。

利用者側: swift package init の新しいオプション

--type に加えて、テンプレートの所在を指定する --path / --url / --package-id が追加されます。Git やレジストリから取得する場合は --from / --exact / --revision / --branch / --up-to-next-minor-from / --to といったバージョン指定も組み合わせます。--type custom は、これら場所指定のオプションとあわせて「外部パッケージのテンプレートを使う」ことを表します。

テンプレートから生成した直後に swift build を走らせて出力物の妥当性を検証したい場合は、--validate-package を付けます。

利用例は次のようになります。

% swift package init --type PartsService --package-id author.template-example
...
Add a starting database migration routine: [y/N] y

Add a README.md file with an introduction and tour of the code: [y/N] y

Choose from the following:

• Name: include-database
  About: Add full database support to your package.
• Name: exclude-database
  About: Create the package without database integration
include-database

Pick a database system for part storage and retrieval. [sqlite3, postgresql] (default: sqlite3):
sqlite3

ユーザーが対話的に答えた内容に沿って、最終的なディレクトリ構成(Package.swiftSources/Tests/・必要に応じて Scripts/README.md)が生成されます。

作者側: templateTargettemplateProduct

テンプレートを提供する側のパッケージは、PackageDescription に追加される templateTargettemplateProduct を使って自身を宣言します。templateTarget は次の2つのモジュールをひとまとめに表す抽象です。

  • ファイル生成とプロジェクトのセットアップを行う executable ターゲット
  • その executable を安全に起動するコマンドラインプラグインターゲット

プラグイン経由で起動することにより、対応プラットフォームではサンドボックスの中で動き、ネットワークアクセスや任意のパスへの書き込みが防がれます。ネットワークなど追加の権限が必要な場合は templatePermissions で宣言し、実行時にユーザーへの許可プロンプトが出ます。

initialPackageType には、SwiftPMがテンプレート実行前に用意するベースのパッケージ形態(.library / .executable / .tool / .buildToolPlugin / .commandPlugin / .macro / .empty)を指定します。

let package = Package(
    name: "TemplateExample",
    products: .template(name: "Template1"),
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"),
        .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.9.0")
    ],
    targets:
        .template(
            name: "Template1",
            dependencies: [
                .product(name: "ArgumentParser", package: "swift-argument-parser"),
                .product(name: "AsyncHTTPClient", package: "async-http-client")
            ],
            initialPackageType: .executable,
            description: "A simple template that requires network access",
            templatePermissions: [
                .allowNetworkConnections(scope: .none, reason: "Need network access to help generate a template")
            ]
        )
)

テンプレートは必ず templateProduct としてプロダクト宣言する必要があります(宣言しないとマニフェストのコンパイルエラーになります)。これは、利用者側のベースパッケージに作者のパッケージが依存として取り込まれる際に、プロダクトとしての位置付けをはっきりさせるためです。

作者のパッケージは次のようなディレクトリ構成を取ります。

.
├── Package.swift
├── Templates
│   └── Template1
│       └── Template1.swift
├── Plugins
│   └── Template1Plugin
│       └── Template1Plugin.swift
└── Tests
    └── FooTests
        └── FooTests.swift

Templates/<Name>/ にテンプレート本体の実装を、Plugins/<Name>Plugin/ に executable を呼び出すコマンドラインプラグインを置きます。

テンプレート実装の書き方

テンプレートの executable は Swift Argument Parser を使って、受け取りたい入力をフラグ・オプション・引数として宣言します。分岐を含む構造は subcommands で表現でき、@ParentCommand を使えば親コマンドの共有ロジックにアクセスできます。

@main
struct ServerGenerator: ParsableCommand {
    public static let configuration = CommandConfiguration(
        commandName: "server-generator",
        abstract: "This template gets you started with starting to experiment with servers in swift.",
        subcommands: [CRUD.self, Bare.self]
    )

    @OptionGroup(visibility: .hidden)
    var packageOptions: PkgDir

    @Option(help: "Add a README.md file with an introduction and tour of the code")
    var readMe: Bool = false

    mutating func run() throws { /* ... */ }
}

struct CRUD: ParsableCommand {
    static let configuration = CommandConfiguration(
        commandName: "crud",
        abstract: "Generate CRUD server"
    )

    @Option(help: "Set the logging level.")
    var logLevel: LogLevel = .debug

    @ParentCommand var serverGenerator: ServerGenerator
    @OptionGroup var serverOptions: SharedOptionsServers

    func run() throws {
        serverGenerator.run()
        guard let pkgDir = serverGenerator.packageOptions.packageDir else {
            throw ValidationError("No --pkg-dir was provided.")
        }
        try? FileManager.default.removeItem(atPath: pkgDir.appending("Package.swift"))
        try packageSwift(serverType: .crud)
            .write(toFile: pkgDir.appending("Package.swift"))
    }
}

ファイル生成のスタイル自体(テンプレートエンジンを使うか、文字列補間で書くかなど)は作者の自由で、SwiftPMは呼び出しの枠組みだけを提供します。

SwiftPMとテンプレートの間の契約

SwiftPMはテンプレートの executable を最初に起動するとき、--experimental-dump-help フラグ付きで呼び出します。このフラグは、コマンド木(サブコマンド、引数、オプション、フラグ、必須/任意、表示名、デフォルト値、列挙値など)をJSONとして出力します。SwiftPMはこれを解析して、

  • ユーザーへの対話プロンプトの生成
  • 入力値の妥当性検証
  • 最終的な executable 呼び出しコマンドラインの組み立て

を行います。Swift Argument Parser はこのスキーマ(ToolInfoV0)を実装しており、テンプレート作者には推奨かつサポートされる選択肢です。他の引数パーサーでも、--experimental-dump-help 相当のフラグと互換のJSONスキーマを実装すれば同じエコシステムに参加できます。

パッケージ生成のワークフロー

swift package init がテンプレート指定付きで呼ばれたときのSwiftPMの処理は、大まかに次の順で進みます。

  1. テンプレートのソース種別(local / git / registry)とバージョン要求を解決する
  2. TemplatePathResolver が必要ならクローンやダウンロードを行い、テンプレートパッケージの絶対パスを得る
  3. /tmp/swift-pm-<UUID>/generated-package/.../clean-up/ の一時ワークスペースを作る
  4. 作者パッケージのマニフェストから templateTarget を探し、initialPackageType を決める
  5. 一時ディレクトリにベースパッケージを作り、作者パッケージを依存として追加してビルドする(executable と plugin を準備する)
  6. 必要な権限をユーザーに確認したうえで --experimental-dump-help でスキーマを取得し、対話プロンプトで入力を集めて、引数リストを確定する
  7. サンドボックス(対応プラットフォームのみ)でテンプレートの executable をプラグイン経由で実行し、ソース・マニフェスト・スクリプトなどを生成する
  8. ./build/Package.resolved といった中間成果物を掃除する
  9. 最終成果物をユーザーの出力先にコピーし、--validate-package が指定されていれば swift build を走らせて結果を報告する

途中でエラーが起きた場合も、一時ワークスペースは可能な限り掃除され、元のエラーは握り潰されず保持されます。

テンプレートのテスト: swift test template

テンプレート作者向けに、swift test template サブコマンドが追加されます。テンプレート名と出力先を指定すると、SwiftPMは決定木のルートから葉までの各経路について、

  • 入力が必要なオプション・フラグ・引数を集める
  • その経路でパッケージを生成する
  • 生成結果が swift build でビルドできるかを検証する
  • 結果を表形式でレポートし、失敗時はログファイルを残す

といった一連の処理を自動で行います。<command>-<subcommand>-... の命名で各バリアントが出力先のサブディレクトリに書き出されるため、特定のブランチだけを目視確認したり、絞り込んだ入力で再実行したりするのも容易です。

Future Directions(参考)

今後の方向性として、次のような拡張が speculative に挙げられています(実現を約束するものではありません)。

  • 既存パッケージに対してテンプレートを当てて増築する(新規作成だけでなく enhancement にも使う)方向性
  • Swift package registry 側をテンプレートメタデータに対応させ、検索・発見性を高めること
  • Package.swift を直接書き換えずに依存・ターゲット・プロダクトを追加できるAPIの提供
  • プログラマブルなエンドツーエンドテストのためのライブラリ提供
  • --experimental-dump-help を安定版フラグと安定版スキーマ(ToolInfoV1)に昇格させ、将来的には ToolInfoV2 なども含めたバージョンネゴシエーションで古いSwiftPMとの互換を保つこと
  • 言語非依存のCLIメタデータ標準である OpenCLI への対応による、Swift Argument Parser 以外のパーサーとの相互運用