Swift Digest
SE-0394 | Swift Evolution

Package Manager Support for Custom Macros

Proposal
SE-0394
Authors
Boris Buegling, Doug Gregor
Review Manager
Becca Royal-Gordon
Status
Implemented (Swift 5.9)

01 何が問題だったのか

SE-0382SE-0389 によって、マクロ実装をコンパイラとは別プロセスで動く独立した実行可能ファイルとして書く仕組みが言語レベルでは整いました。しかし、マクロは元来「ライブラリとして配って、パッケージから利用する」ことを前提にしています。そのためには、

  • マクロ実装をどうビルドし、
  • どうやって自分のパッケージから別のパッケージが提供するマクロを使えるようにし、
  • そのマクロを使うターゲットに対して、どうやってコンパイラに渡すか

というパッケージマネージャ側の取り回しが必要です。既存のSwiftPMのターゲット種別(ライブラリ・実行可能ファイル・テスト・SE-0303 のパッケージプラグイン)だけでは、「ホスト向けにビルドして、利用側ターゲットのコンパイル中にコンパイラが呼び出す実行可能ファイル」を素直に表現できず、マクロをソースコードとして配布・再利用する手段が欠けていました。

この提案は、SE-0382 / SE-0389 のツール側の対応にあたるもので、マクロ実装をパッケージの一ターゲットとして宣言し、依存関係を通じて利用側に届けるための仕組みをSwiftPMに追加するものです。

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

SwiftPMに新しいターゲット種別として .macro を追加します。.macro ターゲットは「コンパイラと同じホストプラットフォーム向けにビルドされる実行可能ファイル」で、そのターゲットに(推移的に)依存しているターゲットをコンパイルする際、コンパイラが必要に応じて起動して構文木の変換を行います。

.macro ターゲットの宣言

.macro は新しいライブラリ CompilerPluginSupport から提供されます。パッケージマニフェストで import CompilerPluginSupport してから、通常のターゲットと同じ並びで使います。

import PackageDescription
import CompilerPluginSupport

let package = Package(
    name: "MacroPackage",
    dependencies: [
        .package(url: "https://github.com/apple/swift-syntax", from: "509.0.0"),
    ],
    targets: [
        .macro(
            name: "MacroImpl",
            dependencies: [
                .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
                .product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
            ]
        ),
        .target(name: "MacroDef", dependencies: ["MacroImpl"]),
        .executableTarget(name: "MacroClient", dependencies: ["MacroDef"]),
        .testTarget(name: "MacroTests", dependencies: ["MacroImpl"]),
    ]
)

APIは他のターゲット種別と揃えて、name / dependencies / path / exclude / sources / swiftSettings / linkerSettings / plugins を受け取ります。名前が macroTarget ではなく macro なのは、ターゲット宣言のコンテキストでは「ターゲット」の接尾辞が冗長だからで、.plugin と同じ方針です。

マクロの実装・宣言・利用の3層構成

典型的なパッケージは、次の3つのターゲットに役割を分けます。

  1. マクロ実装.macro ターゲット): SwiftSyntaxを使った構文木変換のコード。たとえば #fontLiteral を展開する FontLiteralMacro はここに書きます。

     import SwiftSyntax
     import SwiftCompilerPlugin
     import SwiftSyntaxMacros
    
     @main
     struct MyPlugin: CompilerPlugin {
         var providingMacros: [Macro.Type] = [FontLiteralMacro.self]
     }
    
     public struct FontLiteralMacro: ExpressionMacro {
         public static func expansion(
             of macro: some FreestandingMacroExpansionSyntax,
             in context: some MacroExpansionContext
         ) -> ExprSyntax {
             // 省略: macro.argumentList を加工して ExprSyntax を返す
         }
     }
    
  2. マクロ宣言(通常の .target): macro キーワードでマクロを宣言し、実装を #externalMacro(module:type:) で指し示します。module にはマクロ実装ターゲット名、type には具体的な型名を書きます。

     public enum FontWeight { case thin, normal, medium, semiBold, bold }
    
     public protocol ExpressibleByFontLiteral {
         init(fontLiteralName: String, size: Int, weight: FontWeight)
     }
    
     @freestanding(expression)
     public macro fontLiteral<T>(name: String, size: Int, weight: FontWeight) -> T
         = #externalMacro(module: "MacroImpl", type: "FontLiteralMacro")
         where T: ExpressibleByFontLiteral
    
  3. マクロの利用側(アプリや別ライブラリ): 宣言ターゲットを import するだけで使えます。

     import MacroDef
    
     struct Font: ExpressibleByFontLiteral {
         init(fontLiteralName: String, size: Int, weight: MacroDef.FontWeight) { }
     }
    
     let _: Font = #fontLiteral(name: "Comic Sans", size: 14, weight: .thin)
    

実装ターゲットと宣言ターゲットを分けるのは、利用側のコンパイル時にマクロの宣言だけを見れば済むようにし、SwiftSyntaxなど重い依存を利用側に引き込まないためです。.macro ターゲットに推移的に依存しているターゲットは自動的にそのマクロを利用でき、ライブラリ製品として公開されたマクロは、そのライブラリを使う先でもそのまま使えます。

ビルドとプラグインの受け渡し

SwiftPMは .macro ターゲットをホストプラットフォーム向けの実行可能ファイルとしてビルドし、コンパイラに対して -load-plugin-executable /path/to/.build/debug/MacroImpl#MacroImpl のような形でそのパスを渡します。# 以降はその実行可能ファイルが提供するモジュール名の一覧で、マクロ宣言の #externalMacro(module:) が参照するモジュールと一致させます。

.macro ターゲットが依存できるのは静的にリンクされる製品に限られます。明示的に動的ライブラリとして宣言された製品は、マクロ実行可能ファイルの依存に使えません。

サンドボックスとテスト

マクロ実装は、パッケージプラグインと同じくサンドボックス内で実行され、ファイルシステムやネットワークへのアクセスはできません。これは、マクロが「渡された展開ノードとその子ノード、および MacroExpansionContext 経由で提供される情報」だけに依存するよう促すための制約です。将来、他の情報が必要になった場合には、コンパイラがマクロの問い合わせ内容を追跡できる形で MacroExpansionContext を拡張する方向で対応されます。

マクロ実装のユニットテストは、テストターゲットから .macro ターゲットに依存することで書けます(実行可能ターゲットに対するテストと同じ仕組みです)。

SwiftSyntaxのバージョン運用

マクロはSwiftSyntaxに依存して書きますが、SwiftSyntaxのバージョン番号はSwiftのメジャーバージョンに対応する形で付けられます(例: Swift 5.9なら 509.0.0)。SwiftPMの依存解決はワークスペース全体で行われるため、複数のマクロパッケージに依存した場合でも、SwiftSyntaxのバージョンはひとつに集約されます。マクロ側は from: 指定で依存を書いておくことで、マイナーバージョン違いのSwiftSyntaxを使う複数マクロを組み合わせても、全マクロと互換な最小バージョンが自動で選ばれます。

今後の見通し

CompilerPluginSupport として別ライブラリで提供されているのは、マニフェストAPIを拡張可能にしていくための出発点だからです。将来は、マクロに限らず、ライブラリ側で独自のターゲット種別や製品種別を定義できるようにする方向が speculative に議論されています(実現を約束するものではありません)。