swift-mod - SwiftSyntaxベースのコード生成ツールを作った
あけましておめでとうございます。CATS Productivity Team の iOS 担当の青山 (@ra1028fe5) です。
今回は iOS アプリケーション開発で modular architecture 化を進める際の生産性を向上させるために作成した CLI ツールを紹介したいと思います。
はじめに
作ったものは、public
, internal
のようなアクセスコントロールやそれに応じたイニシャライザなど、Swift コードをライブラリ化する際に必要な定形構文を設定したルールに従って自動生成してくれる、フォーマッターとコード生成機の中間的なツールです。
そもそもなぜこんなツールを作成したかと言うと、実装を小さなモジュールに細分化してポータビリティやテスタビリティを高めようと全社的に取り組んでいます。しかし、モジュールをライブラリとして利用するために、アクセスレベルやイニシャライザがボイラープレートになって思うように進まないケースがありました。
というかモノリスに実装されたアプリからモジュールを切り出す過程の作業は筆舌に尽くしがたい辛さです…
また、個人的に OSS ライブラリを複数公開していますが、Swift で OSS を書き始めたころなんかはよく アクセスレベルや公開イニシャライザの実装を忘れてリリースしてしまったりしていました。
@testable import
でテストを書いていると public
にできているかどうかは気づきにくいですね。流石に今はリリースの前に気づきますが、それでも都度注意を払いながらボイラープレートを書いていくのはストレスです。
Sourcery なんかを使ってコードを生成しても良かったのですが、もう少し簡単に、ルールに従って現在のシンタックスを改変してくれるようなものが欲しかったので、自作してみました。
Swift は Apple から AST パーサーライブラリの SwiftSyntax が OSS として公開されているので、もちろん利用しています。
swift-mod
https://github.com/ra1028/swift-mod
swift-mod について
設定ファイルに記述したルールに従って、現在のシンタックスに修正が必要な場合にのみ編集されるようなツールにしています。
元々はアクセスレベルの自動付与と、memberwise initializer の生成だけできれば良かったのですが、途中から凝り始めて様々なルールを追加できるような設計になりました。今後追加するルールのアイディアはないので是非フィードバックをお願いします!
生成されるコードは下記のようなイメージです。
Before:
struct Avenger {
var heroName: String
private var realName: String?
}
After:
public struct Avenger {
public var heroName: String
private var realName: String?
public init(
heroName: String,
realName: String? = nil
) {
self.heroName = heroName
self.realName = realName
}
}
シンプルな例ですが、 internal
だった Avenger
の private
な要素はそのまま、必要なところだけを public
化し、メンバーごとのイニシャライザが生成されています。
現在のシンタックスの要点はそのまま、自動的にそのままライブラリとして利用できるようにする目的です。
生成されるコードのルールやフォーマットは次のような YAML 構成ファイル を利用できます。
# 生成されるコードのフォーマット
format:
indent: 4
lineBreakBeforeEachArgument: true
# 複数のパスに別々のルールを適用できるように複数定義でき、部分実行できる
targets:
main:
paths:
- Sources
excludedPaths:
- '**/main.swift'
rules:
defaultAccessLevel:
accessLevel: openOrPublic
implicitInternal: true
defaultMemberwiseInitializer:
implicitInitializer: true
implicitInternal: true
ignoreClassesWithInheritance: true
定義は apple/swift-format や realm/SwiftLint を見習いつつ、個人的に書きやすくしてみたのですが、いかがでしょうか。
swift-mod init
でテンプレートが生成されるコマンドも用意しました。
フォーマッターではないですが、一応生成されるコードのフォーマットを最低限設定できるように format
キーで設定を行えます。
targets
には任意の名前をキーにして複数の異なるパスに対する設定を行えるようにしています。アプリケーションターゲットの Swift コードは internal
を強制して、ライブラリとして利用されるモジュールは public
にするようなユースケースに対応するためです。
現在ルールは 2 つしかありませんが swift-mod rules
, swift-mod rules --detail [RULE]
でリストとそれぞれの詳細を確認できるようにしています。
それぞれのルールの役割を紹介します。
Default Access Level
IDENTIFIER | OVERVIEW |
---|---|
defaultAccessLevel | Assigns the suitable access level to all declaration syntaxes if not present |
KEY | VALUE | REQUIREMENT | DEFAULT |
---|---|---|---|
accessLevel | |openOrPublic|public|internal|fileprivate|private| | Required | |
implicitInternal | Indicating whether to omit the internal access level |
Optional | true |
アクセスレベルが明示的に指定されていない型や変数、プロパティ、関数などを検出した場合、accessLevel
キーに指定された修飾子を付与します。
明示的に付与しておけば、任意のアクセスレベルにすることもできます。
Before:
struct Foo {
var foo: String
private var bar: Int?
}
After:
public struct Foo {
public var foo: String
private var bar: Int? // <- 明示的に付与してあるアクセスレベルは変化しない
}
ただし下記のように、private
なスコープに定義されたノードはそれ以上のアクセスレベルを付与しても意味がないので、 internal
をデフォルトで付与するように、シンタックスの意味合いを加味しつつ都合の良いレベルを追加してくれるのが便利です。
After:
private struct Foo {
var foo: String // <- 親ノードが `private` を持つので、 `internal` になる
private var bar: Int?
}
ちなみに implicitInternal
を false
に設定すれば、internal
も明示的に付与されます。
extension
なども色々工夫して自動付与するようにしたので長くなりそうなので割愛します。
Default Memberwise Initializer
IDENTIFIER | OVERVIEW |
---|---|
defaultMemberwiseInitializer | Defines a memberwise initializer according to the access level in the type declaration if not present |
KEY | VALUE | REQUIREMENT | DEFAULT |
---|---|---|---|
implicitInitializer | Indicating whether to omit the internal initializer in struct decalaration |
Optional | false |
implicitInternal | Indicating whether to omit the internal access level |
Optional | true |
ignoreClassesWithInheritance | Indicating whether to skip the classes having inheritance including protocol | Optional | false |
こちらは memberwize initializer つまり、class
と struct
の初期化されていない各メンバーを初期化する引数を取るイニシャライザを自動生成するルールです。
Xcode のリファクタ機能に同じものがあるのをご存知でしょうか?
この機能を適用すると次ように、自動的にメンバーの初期化子を生成してくれます。
非常に便利なんですが、なぜかこの機能 class
にしか適用されず、もちろんコマンドラインから利用できるわけではないので、個人のコーディング作業を少しだけ便利にするだけの残念機能です。
struct
は何も書かなければ memberwize initializer が暗黙的に存在しますが、そのアクセスレベルは型に影響されず常に internal
なのでモジュール化の際には役に立ちません。
これを定常的に自動整形でき、且つある程度詳細なルールを組めるツールが欲しかったわけです。
swift-mod
の場合は既にイニシャライザを持っていなければ、これを自動生成します。既にイニシャライザがあれば生成を実行しないので、余計なコードが入らないのも利点です。
- before
struct Foo {
var foo: String
private var bar: Int?
}
- after
struct Foo {
var foo: String
private var bar: Int?
init(
foo: String,
bar: Int? = nil
) {
self.foo = foo
self.bar = bar
}
}
予約語と重複した変数名はバッククォートで囲ってくれますし、指定されている初期値をイニシャライザの引数初期値(Optional は nil)に設定されるので殆どのケースではそのまま正しい Swift コードの構文を生成します。
実際には implicitInitializer
が true
の場合は struct
の internal
なイニシャライザは生成されませんが、このように対象ファイルのすべての型に対して自動生成を行ってくれるので、大きめのエンティティが多い場合に効率的です。
CATS のプロダクトの場合は、今の所 Protobuf で生成されるエンティティを扱いやすくするラッパーエンティティを作成しているので、こういった需要が大きかったです。
おわりに
Swift 製の CLI ツールは久しぶりに作ったんですが、以前よりも環境整っていてだいぶ楽になっていました。
Productivity Team では 表に出しやすいものからそうでないものまで様々な取り組みをしていくので、少しずつ記事にしていきますね。
今回のようなツールの OSS 化もやっていきますので乞うご期待。
おわり