5.1さらうどん

@giginetの技術ブログ。ゲーム開発、iOS開発、その他いろいろ

Swift MacroをSwift PackageなしでXcodeで扱う

Swift MacroってXcodeから使えないの?

Swift 5.9からSwift Macroが実用段階になったが、WWDCの動画でも、公式ドキュメントでもSwift Packageから作成することが前提となっている。

targets: [
    // Macro implementation that performs the source transformations.
    .macro(
        name: "MyProjectMacros",
        dependencies: [
            .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
            .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
        ]
    ),


    // Library that exposes a macro as part of its API.
    .target(name: "MyProject", dependencies: ["MyProjectMacros"]),
]

Xcodeで作成したtargetに組み込むときもパッケージの依存を組み込み、マクロフレームワークをimportするだけで利用可能になるようだ。

しかしXcodeGUIではMacroを含むモジュールに依存関係を設定する方法はない。通常の依存関係と異なり、Macroはビルドdestinationが特殊なので、これを内部的にどうやって設定しているのか気になった。 (例えばiOSアプリにMacroを組み込むとき、アプリケーション自体はiOS向けにビルドするが、Macroは常にmacOSで実行可能な形でビルドされなくてはならない)

XcodeやxcodebuildのレイヤーではMacroを依存として指定する方法がないのだが、swiftcのレイヤーでは何らかの方法でmacroへの依存関係を指定しているはずだ。

-load-plugin-executable

調べてみたところ、どうやら -load-plugin-executable というオプションがswiftcに生えていた。

この辺の記事にほとんど書いていた。

$ swiftc --help
  -load-plugin-executable <path>#<module-names>
                          Path to an executable compiler plugins and providing module names such as macros

この -load-plugin-executable でmacroターゲットのバイナリを指定すると良いらしい。Xcodeでは OTHER_SWIFT_FLAGS にこれを指定する。

実際に、適当なMacroを含むパッケージをXcodeプロジェクトに追加し、利用してみたときのログを見ていると、同じフラグを渡していることが確認できる。

Special Thanks : mtj0928/userinfo-representable

Swift MacroをSwift PackageなしでXcodeだけで作る

そして、調べてみると、SwiftPMにおける.macro ターゲットは、実は単なるmacOS向けの executableTarget とほぼ同等なものであることがわかった。

-load-plugin-executable で要求されるバイナリは、実は単に CompilerPlugin に適合した型をエントリーポイントとしたexecutableを要求しているだけのようだ。 あとでswiftc側でどういうバイナリを想定しているかの内部実装も読んでみたい。

ここまでわかると、Swift Packageを使わずとも、Xcodeだけでマクロを捏造できるのでは?と思って試してみた。

Macroターゲット側 (MyMacro)

  1. XcodemacOSのCommand Line Toolターゲットを作る
  2. swift-syntaxをSwift Packageとして追加して依存関係にする
  3. 普通にmacroを書く

ここで実装するマクロはなんでもよいので、公式ドキュメントのサンプルにある fourCharacterCode の実装を使うことにした。

重要なのは、executableのエントリーポイントとして、CompilerPluginを指定することだ。

import SwiftCompilerPlugin

@main
struct MyMacro: CompilerPlugin {
    var providingMacros: [Macro.Type] = [FourCharacterCode.self]
}

アプリターゲット側 (MyApp)

  1. Build Phaseのdependenciesに MyMacro を追加する
    • 単にビルド前にexecutableを作っておきたいだけ
  2. OTHER_SWIFT_FLAGS-load-plugin-executable $BUILT_PRODUCTS_DIR/../$CONFIGURATION/MyMacro#MyMacroを指定
    • これは BUILT_PRODUCTS_DIR に入っている MyMacro の成果物を指定している
    • 単に $BUILT_PRODUCTS_DIR だとアプリ側のdestinationになってしまうので ../$CONFIGURATIONmacOS側のdestinationにする
    • この指定は特殊で、 swiftc --helpによると、<executable_file_path>#<target_name> らしい
  3. シンボルの解決のために#externalMacroを仮宣言する
    • これをすることで、依存解決時はMacroが存在する体で扱い、実際にビルドするときに2で指定したバイナリを実行する

@freestanding(expression)
macro fourCharacterCode<T>(for value: T) -> UInt32? = #externalMacro(module: "MyMacro", type: "FourCharacterCode")

こうすることで、自作のMacroをXcodeターゲットだけで実装することができた 🎉

Swift Macroのビルドキャッシュ

では、このような複雑な構造を取って一体どんなメリットがあるかというと、ビルドキャッシュの利用という面でXcodeターゲットで管理していた方が扱いやすい面があるのではないかなと思う。

現状、Swift Macroはswift-syntaxへの依存が必須で、どの方法で作ってもswift-syntaxを依存として持つ必要がある。swift-syntaxはデカいので、単純なマクロを1つ作っただけで、ビルド時間が激増してしまうという問題があった。

僕の開発しているScipioなどで、swift-syntaxをprebuiltなXCFrameworkとして維持しておくことができるので、Xcodeで作成したMacroターゲットがprebuilt版のswift-syntaxに依存するようにしておけば、ビルド時間を大きく改善できそう。

また、Xcodeを使わないにしても、swift buildで作成したexecutableをキャッシュしておいて、-load-plugin-executableで利用することで、毎回swift-syntaxやmacroをビルドする必要がなくなる(アーキテクチャの問題はあるが)

ScipioはSwift PackageからXCFrameworkを生成するためのビルドツールだが、現在、macroを使ったターゲットからフレームワークを作ることができないという問題がある。理論的には今回紹介した方法で対応できそう。(先にmacroターゲットをexecutableとしてビルドして、framework側からビルドフラグを立てる)

いかがでしたか

業務上で調査して面白かったのでちょっと記事を書いてみた。ここ数年全く技術記事を書いていないという危機感があり今年はこれぐらいの技術記事を定期的に書いていきたいと思っていたのでちょうど良い。 気合入れると続かないので30分以内でガッと書けるやつを目指していきたい。

純粋な技術記事としては8年ぶりの投稿かも・・・・・・