CATS CATS PRODUCTIVITY BLOG

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 だった Avengerprivate な要素はそのまま、必要なところだけを 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-formatrealm/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?
}

ちなみに implicitInternalfalse に設定すれば、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 つまり、classstruct の初期化されていない各メンバーを初期化する引数を取るイニシャライザを自動生成するルールです。

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 コードの構文を生成します。

実際には implicitInitializertrue の場合は structinternal なイニシャライザは生成されませんが、このように対象ファイルのすべての型に対して自動生成を行ってくれるので、大きめのエンティティが多い場合に効率的です。

CATS のプロダクトの場合は、今の所 Protobuf で生成されるエンティティを扱いやすくするラッパーエンティティを作成しているので、こういった需要が大きかったです。

おわりに

Swift 製の CLI ツールは久しぶりに作ったんですが、以前よりも環境整っていてだいぶ楽になっていました。

Productivity Team では 表に出しやすいものからそうでないものまで様々な取り組みをしていくので、少しずつ記事にしていきますね。
今回のようなツールの OSS 化もやっていきますので乞うご期待。

おわり