Tapioca の DSL compiler のしくみ


最近、Ruby の型チェッカーである [Sorbet](https://sorbet.org/) とその周辺ツールの [Tapioca](https://github.com/Shopify/tapioca) を色々試してみています。

Tapioca は型定義 (RBI) に関することを色々やってくれるツールで、[RBI 版 DefinitelyTyped](https://github.com/Shopify/rbi-central) から RBI を取得してくれる他、 Rails や他の DSL に対応した RBI を生成してくれたり、 gem から RBI を生成してくれるなど、 RBI に関することを本当に色々やってくれます。(他にも色々機能があります)

…ここで気になるのが、 Tapioca が DSL から RBI を生成する部分です。この記事では、 Tapioca のコードなどに目を通しながら、どのようにして RBI を生成しているのかを整理してみました。

## Tapioca の DSL compiler のしくみ

Tapioca が DSL から 型定義を生成する機構は、 [DSL compiler](https://github.com/Shopify/tapioca#writing-custom-dsl-compilers) といいます。

Tapioca 自身が [ビルドインとして Rails などの DSL に対応した Compiler](https://github.com/Shopify/tapioca/blob/main/manual/compilers.md) を持っているほか、カスタムの DSL Compiler を書いて、独自の DSL に対応した型定義を生成することができます。(※ DSL 自体は Rails 以外のも対応可能ですが、 DSL Compiler は今のところ Rails アプリケーションでのみ使用可能です。)

例として ActionMailer の DSL Compiler を見てみます。 ActionMailer ではインスタンスメソッドに対応したクラスメソッドが自動的に定義されます。以下は、そのクラスメソッドの型定義を生成します。

```ruby
module Tapioca
  module Dsl
    module Compilers
      class ActionMailer < Compiler
        extend T::Sig

        ConstantType = type_member { { fixed: T.class_of(::ActionMailer::Base) } }

        sig { override.returns(T::Enumerable[Module]) }
        def self.gather_constants
          descendants_of(::ActionMailer::Base).reject(&:abstract?)
        end

        sig { override.void }
        def decorate
          root.create_path(constant) do |mailer|
            action_methods_for_constant.each do |mailer_method|
              method_def = constant.instance_method(mailer_method)
              parameters = compile_method_parameters_to_rbi(method_def)
              mailer.create_method(
                mailer_method,
                parameters: parameters,
                return_type: "::ActionMailer::MessageDelivery",
                class_method: true,
              )
            end
          end
        end

        # ...
      end
    end
  end
end
```

> 参考: https://github.com/Shopify/tapioca/blob/v0.11.8/lib/tapioca/dsl/compilers/action_mailer.rb

## Tapioca DSL Compiler はどのような環境で実行されるか

それではこれらの DSL Compiler はどのような環境で実行されているのでしょうか。簡潔に言うと、DSL Compiler は Rails アプリケーションを実際に読み込んだ上で、 DSL Compiler が Ruby オブジェクトから情報を収集し、RBI ファイルとして書き出します。

つまり静的解析ではなく Ruby のリフレクション機構を利用した解析、parser を使った DSL の構文解析をするのではなく、 DSL の評価済みのオブジェクトに対して解析を行う、ということになります。

以下の箇条書きの流れで、実行されます。

* `tapioca dsl` または `tapioca init` コマンドを実行
  1. Rails アプリケーションのコード、 DSL Compiler のコードを順に
      * Ref: https://github.com/Shopify/tapioca/blob/v0.11.8/lib/tapioca/commands/dsl.rb#L98-L103
  2. 各 DSL Compiler が処理の対象としたい定数をリストアップ
      * Ref: https://github.com/Shopify/tapioca/blob/v0.11.8/lib/tapioca/dsl/pipeline.rb#L59-L61
  3. 定数ごと、DSL Compiler ごとに、RBI ファイルの内容を生成する処理を実行
      * Ref: https://github.com/Shopify/tapioca/blob/v0.11.8/lib/tapioca/dsl/pipeline.rb#L173-L174
  4. 定数ごとに、生成した RBI ファイルの内容を書き出し
      * Ref: https://github.com/Shopify/tapioca/blob/v0.11.8/lib/tapioca/commands/dsl.rb#L126-L131

## DSL Compiler を読む

それでは DSL Compiler の構成について見てみましょう。先程の ActionMailer 用の DSL Compiler の実装を参考にしながら見てみます。

```ruby
module Tapioca
  module Dsl
    module Compilers
      class ActionMailer < Compiler
        extend T::Sig

        ConstantType = type_member { { fixed: T.class_of(::ActionMailer::Base) } } # 対象としたい定数の型を定義する型変数

        sig { override.returns(T::Enumerable[Module]) }
        def self.gather_constants # `gather_constants` クラスメソッドで、対象としたい定数をリストアップ
          descendants_of(::ActionMailer::Base).reject(&:abstract?)
        end

        sig { override.void }
        def decorate # `decorate` インスタンスメソッドで、定数 (constant) ごとに RBI ファイルの内容を生成する
          root.create_path(constant) do |mailer| # root は書き込む対象となる RBI ファイルのルートを指すオブジェクト。 `create_path` で定数 (constant) に対応した名前空間を RBI ファイルに追加する。
            action_methods_for_constant.each do |mailer_method|
              method_def = constant.instance_method(mailer_method)
              parameters = compile_method_parameters_to_rbi(method_def)
              mailer.create_method( # メソッドの signature を RBI ファイルに追加する。
                mailer_method,
                parameters: parameters,
                return_type: "::ActionMailer::MessageDelivery",
                class_method: true,
              )
            end
          end
        end

        private

        sig { returns(T::Array[String]) }
        def action_methods_for_constant
          constant.action_methods.to_a
        end
      end
    end
  end
end
```

> 参考: https://github.com/Shopify/tapioca/blob/v0.11.8/lib/tapioca/dsl/compilers/action_mailer.rb

DSL Compiler は Tapioca::Dsl::Compiler クラスを継承し、以下の2つの abstract method を実装します。

* gather_constants: 処理の対象としたい定数をリストアップするクラスメソッド
* decorate: 定数ごとに、RBI ファイルの内容を生成するインスタンスメソッド

実装したファイルは `sorbet/tapioca/compilers` ディレクトリ以下に配置します。このディレクトリに配置した DSL Compiler は、 `tapioca dsl` または `tapioca init` コマンドを実行した際に、自動的に読み込まれます。

(また、各 Gem 内の tapioca/dsl/compilers ディレクトリ以下のファイルも読み込んで、、その中の DSL Compiler も自動的に読み込まれるようです。 Ref: https://github.com/Shopify/tapioca/blob/v0.11.8/lib/tapioca/loaders/dsl.rb#L63-L65)


### Compiler による RBI ファイルへの書き込み方法

`Tapioca::Dsl::Compiler#decorate` での RBI ファイルの内容の生成は、 `root` オブジェクト (RBI::Tree クラスのインスタンス) を通して行います。

このオブジェクトの実装は [rbi gem](https://github.com/Shopify/rbi) で行われています。 Tapioca gem 内にも、[独自の拡張](https://github.com/Shopify/tapioca/blob/v0.11.8/lib/tapioca/rbi_ext/model.rb) が行われていて、これらのメソッドを通して RBI ファイルの内容を生成します。

### Compiler クラスのメソッドに関して

RBI ファイルへの型定義の追加を簡潔に記述できるよう、定数の列挙を簡単にするために、以下のクラスやモジュールのモジュールのメソッドが提供されています。

* [Tapioca::Dsl::Compiler](https://github.com/Shopify/tapioca/blob/v0.11.8/lib/tapioca/dsl/compiler.rb)
* [Tapioca::RBIHelper](https://github.com/Shopify/tapioca/blob/v0.11.8/lib/tapioca/helpers/rbi_helper.rb)
* [Tapioca::Runtime::Reflection](https://github.com/Shopify/tapioca/blob/v0.11.8/lib/tapioca/runtime/reflection.rb)

### より詳しく知るには

* 公式のチュートリアル: https://github.com/Shopify/tapioca#writing-custom-dsl-compilers
* 既存の DSL Compiler の実装: https://github.com/Shopify/tapioca/tree/v0.11.8/lib/tapioca/dsl/compilers