Skip to content

Instantly share code, notes, and snippets.

@sinsoku
Created June 8, 2026 14:41
Show Gist options
  • Select an option

  • Save sinsoku/e7f2786cf84bc5ae70a7f64329a8d33c to your computer and use it in GitHub Desktop.

Select an option

Save sinsoku/e7f2786cf84bc5ae70a7f64329a8d33c to your computer and use it in GitHub Desktop.
Proposal: 動的メソッド定義 DSL のための DSL Plugin システム

ruby/typeprof#460 の日本語版。


動機(Motivation)

Ruby にはメソッドを動的に定義したり、モジュールを mix-in したりする DSL が数多くあります。

  • define_method(:hello) { "hi" } はレシーバに hello を追加する
  • Forwardable#def_delegator :@target, :foo は委譲メソッド foo を生成する
  • Rails の belongs_to :useruser / user= / build_user / create_user などを生成する

TypeProf は現状、これらの一部のみを手書きの MetaNode(lib/typeprof/core/ast/meta.rb)で特別扱いしていますが、大部分は未対応 — 特に Rails 関連が顕著です。

この Issue では、TypeProf 本体に手を入れずにこれらの DSL に対応するための DSL Plugin システムを提案します。サードパーティが外部 gem として配布することも可能になります。

設計

DSL Plugin はメソッド呼び出しが 具体的な MethodEntity に解決された時 に発火します(例:ActiveRecord::Associations::ClassMethods#belongs_tobelongs_to という名前そのものではない)。発火後、Plugin はメソッド・モジュール・mix-in 関係などを副作用として合成します。

Registry の流れ:

  1. on "X#foo" 宣言が (cpath, mid, singleton) -> PluginClassTypeProf::Dsl::Registry に登録する。
  2. Service#newBuiltin.deploy 直後に Registry.apply(genv) を呼ぶ。登録された各エントリについて、apply は対象の MethodEntity を解決して me.builtin にハンドラを仕込む。
  3. 後にそのメソッドが解析中に呼ばれると、ハンドラは Scope を構築して plugin.install(scope) を呼ぶ。false を返すことで TypeProf は呼び出しの戻り値を引き続き RBS で解決し、Plugin は副作用の追加だけを行う。

Plugin が読み取るのは AST と型推論の Vertex のみで、TypeProf はユーザーのアプリを boot することも、文字列を eval することもしません。既存の手書き MetaNode(attr_reader / include / Struct.new など)はそのまま動作し、Plugin 層は純粋な追加機能として共存します。

Plugin の探索

TypeProf 本体に同梱される Plugin(lib/typeprof/dsl/ruby/*.rb)は起動時に自動ロードされます。外部 Plugin gem は次のいずれかで有効化します:

  • CLI: typeprof --dsl typeprof-rails app.rb

  • 設定ファイル: typeprof.conf.jsonc"dsl" キー:

    {
      "dsl": ["typeprof-rails"]
    }

引数は gem 名です。TypeProf が gem を require し、gem 側はロード時に Plugin を登録します。

Plugin の書き方

Rails の belongs_to を解釈する Plugin の例です。Post クラス内の belongs_to :user を、Post::GeneratedAssociationMethodsPost が include する)上の def user: -> User? に変換します。

module TypeProf
  module Dsl
    module ActiveRecord
      class Associations < TypeProf::Dsl::Base
        on "ActiveRecord::Associations::ClassMethods#belongs_to"

        def install(scope)
          # 1. アソシエーション名を読み取る
          name = scope.arg_symbol(0) or return

          # 2. Rails の命名規約からアソシエーションのクラスを解決する
          klass = scope.resolve_const(default_name_for(name)) or return

          # 3. メソッドを定義する先のモジュールを取得 or 作成する
          mod = scope.find_module("GeneratedAssociationMethods")
          unless mod
            mod = scope.create_module("GeneratedAssociationMethods")
            scope.owner.include_module(mod)
          end

          # 4. メソッドを定義する
          mod.define_method(name, returns: scope.nilable(klass))
        end
      end
    end
  end
end

トリガー

トリガー 発火条件
on "X#foo" 呼び出しがインスタンスメソッド X#foo に解決された時
on "X.foo" 呼び出しが特異メソッド X.foo に解決された時
on_inherit "X" クラスの祖先チェーンに X が含まれる場合
on_include "X" モジュール本体で include X が実行された時
on_extend "X" モジュール本体で extend X が実行された時
on_prepend "X" モジュール本体で prepend X が実行された時

on "X#foo"# / .定義側の特異性 を表します。

Scope API

Context

scope.mid          #: () -> Symbol           Plugin を発火させたメソッド名
scope.owner        #: () -> ScopeOwner       呼び出し元モジュールのラッパ
scope.has_block?   #: () -> bool             呼び出しがブロックを持つか

引数の読み取り

各ヘルパーは位置引数 / キーワード引数から単一の具体リテラルを取り出します。引数が無い、リテラルでない、複数値の union、untyped の場合は nil を返します。

現在、Vertex 上でリテラル値が保持されるのは Symboltrue/false のみです。String / Integer のリテラルは型のみ流れるため、arg_string / kwarg_string は意図的に省いています。

scope.arg_symbol(idx)         #: (Integer) -> Symbol?
scope.arg_symbols_from(idx)   #: (Integer) -> Array[Symbol]?    Symbol の残り全部を集める

scope.kwarg_symbol(:key)      #: (Symbol) -> Symbol?
scope.kwarg_bool(:key)        #: (Symbol) -> bool?

型の構築

scope.resolve_const("User")            #: (String) -> Type?         cref を辿る定数解決
scope.nilable(t)                       #: (Type) -> Type             t | nil
scope.array_of(t)                      #: (Type) -> Type             Array[t]
scope.hash_of(k, v)                    #: (Type, Type) -> Type       Hash[k, v]
scope.method_return_type(:"@x", :foo)  #: (Symbol, Symbol) -> Type   @x.foo の戻り値型
scope.untyped                          #: () -> Type                 フォールバック

モジュールの解決と作成

mod = scope.find_module("ClassMethods")     #: (String) -> ModuleEntity?    無ければ nil
mod = scope.create_module("Generated")       #: (String) -> ModuleEntity     既存なら例外

mod.include_module(other)                    #: (ModuleEntity) -> void       mix in(idempotent)
mod.extend_module(other)                     #: (ModuleEntity) -> void       extend(idempotent)

モジュールへのメソッド定義

mod.define_method(:foo, params: [...], returns: t)            #: (Symbol, params: Array[Type], returns: Type) -> void
mod.define_singleton_method(:bar, params: [...], returns: t)  #: (Symbol, params: Array[Type], returns: Type) -> void
mod.define_method_from_block(:baz)                            #: (Symbol) -> void                                       渡されたブロックをメソッド本体に流用
mod.define_ivar(:"@count", type: t)                           #: (Symbol, type: Type) -> void

備考

  • この Issue は最終形の概要です。細かい点は実装時に調整します。
  • Scope API はまず本体同梱の Plugin から始めます。安定したら --dsl フラグと typeprof.conf.jsonc"dsl" キーを追加します。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment