Motivation
Ruby has many DSLs that define methods or mix in modules dynamically:
define_method(:hello) { "hi" } adds hello to the receiver.Forwardable#def_delegator :@target, :foo generates a delegating method foo.- Rails'
belongs_to :user generates user, user=, build_user, create_user, etc.
TypeProf currently special-cases a few of these in hand-written meta nodes (lib/typeprof/core/ast/meta.rb), but most are not — Rails in particular.
This issue proposes a DSL Plugin system that extends TypeProf to handle these DSLs without touching the core. Third parties can ship support as external gems.
Design
A DSL Plugin fires when a method call resolves to a specific MethodEntity (for example, ActiveRecord::Associations::ClassMethods#belongs_to, not the bare name belongs_to). It then synthesises methods, modules, or mix-in relations as a side effect.
The Registry flow:
- Each
on "X#foo" declaration registers (cpath, mid, singleton) -> PluginClass in TypeProf::Dsl::Registry. Service#new calls Registry.apply(genv) right after Builtin.deploy. For each entry, apply resolves the target MethodEntity and installs a handler into me.builtin.- When that method is later called during analysis, the handler builds a
Scope and calls plugin.install(scope). It returns false so TypeProf still resolves the call's return type through RBS — the plugin only adds side effects.
Plugins read the AST and type-inference Vertices only — TypeProf never boots the user's app and never evaluates strings. Existing hand-written meta nodes (attr_reader, include, Struct.new, etc.) keep working as is; this plugin layer is a pure addition.
Plugin discovery
Plugins bundled with TypeProf core (lib/typeprof/dsl/ruby/*.rb) are auto-loaded on boot. External plugin gems are enabled via either of:
The argument is a gem name. TypeProf requires the gem, which registers its plugins on load.
Plugin authoring
A plugin for Rails' belongs_to. It turns belongs_to :user in Post into def user: -> User? on Post::GeneratedAssociationMethods (a module included by Post).
moduleTypeProfmoduleDslmoduleActiveRecordclassAssociations < TypeProf::Dsl::Baseon"ActiveRecord::Associations::ClassMethods#belongs_to"definstall(scope)# 1. Read the association name.name=scope.arg_symbol(0)orreturn# 2. Resolve the associated class by Rails' naming convention.klass=scope.resolve_const(default_name_for(name))orreturn# 3. Locate or create the module to attach methods to.mod=scope.find_module("GeneratedAssociationMethods")unlessmodmod=scope.create_module("GeneratedAssociationMethods")scope.owner.include_module(mod)end# 4. Define the method.mod.define_method(name,returns: scope.nilable(klass))endendendendendTriggers
| Trigger | Fires when |
|---|
on "X#foo" | Call resolves to instance method X#foo |
on "X.foo" | Call resolves to singleton method X.foo |
on_inherit "X" | A class is defined with X in its ancestor chain |
on_include "X" | A module body executes include X |
on_extend "X" | A module body executes extend X |
on_prepend "X" | A module body executes prepend X |
Inside an on "X#foo" pattern, # always denotes an instance method and . a singleton method on the definition side.
Scope API
Context
scope.mid#: () -> Symbol method name that triggered the pluginscope.owner#: () -> ScopeOwner wrapper for the calling modulescope.has_block?#: () -> bool whether a block was passed
Reading arguments
Each helper extracts a single concrete literal from a positional or keyword argument. Returns nil if the argument is missing, isn't a literal, resolves to multiple values, or is untyped.
Only Symbol and true/false literal values are preserved on Vertices today. String / Integer literals flow as types only, so arg_string / kwarg_string are intentionally omitted.
scope.arg_symbol(idx)#: (Integer) -> Symbol?scope.arg_symbols_from(idx)#: (Integer) -> Array[Symbol]? collect a Symbol rest listscope.kwarg_symbol(:key)#: (Symbol) -> Symbol?scope.kwarg_bool(:key)#: (Symbol) -> bool?
Constructing types
scope.resolve_const("User")#: (String) -> Type? cref-walking lookupscope.nilable(t)#: (Type) -> Type t | nilscope.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 return type of @x.fooscope.untyped#: () -> Type fallbackResolving and creating modules
mod=scope.find_module("ClassMethods")#: (String) -> ModuleEntity? nil if missingmod=scope.create_module("Generated")#: (String) -> ModuleEntity raises if existsmod.include_module(other)#: (ModuleEntity) -> void mix in (idempotent)mod.extend_module(other)#: (ModuleEntity) -> void extend (idempotent)Defining methods on a module
mod.define_method(:foo,params: [...],returns: t)#: (Symbol, params: Array[Type], returns: Type) -> voidmod.define_singleton_method(:bar,params: [...],returns: t)#: (Symbol, params: Array[Type], returns: Type) -> voidmod.define_method_from_block(:baz)#: (Symbol) -> void reuse the passed block as bodymod.define_ivar(:"@count",type: t)#: (Symbol, type: Type) -> void
Notes
- This issue describes the target shape. Details may change during implementation.
- The Scope API will land for core-bundled plugins first. The
--dsl flag and typeprof.conf.jsonc"dsl" key follow once it stabilizes.
Motivation
Ruby has many DSLs that define methods or mix in modules dynamically:
define_method(:hello) { "hi" }addshelloto the receiver.Forwardable#def_delegator :@target, :foogenerates a delegating methodfoo.belongs_to :usergeneratesuser,user=,build_user,create_user, etc.TypeProf currently special-cases a few of these in hand-written meta nodes (
lib/typeprof/core/ast/meta.rb), but most are not — Rails in particular.This issue proposes a DSL Plugin system that extends TypeProf to handle these DSLs without touching the core. Third parties can ship support as external gems.
Design
A DSL Plugin fires when a method call resolves to a specific
MethodEntity(for example,ActiveRecord::Associations::ClassMethods#belongs_to, not the bare namebelongs_to). It then synthesises methods, modules, or mix-in relations as a side effect.The Registry flow:
on "X#foo"declaration registers(cpath, mid, singleton) -> PluginClassinTypeProf::Dsl::Registry.Service#newcallsRegistry.apply(genv)right afterBuiltin.deploy. For each entry,applyresolves the targetMethodEntityand installs a handler intome.builtin.Scopeand callsplugin.install(scope). It returnsfalseso TypeProf still resolves the call's return type through RBS — the plugin only adds side effects.Plugins read the AST and type-inference Vertices only — TypeProf never boots the user's app and never evaluates strings. Existing hand-written meta nodes (
attr_reader,include,Struct.new, etc.) keep working as is; this plugin layer is a pure addition.Plugin discovery
Plugins bundled with TypeProf core (
lib/typeprof/dsl/ruby/*.rb) are auto-loaded on boot. External plugin gems are enabled via either of:CLI:
typeprof --dsl typeprof-rails app.rbConfig: a
"dsl"key intypeprof.conf.jsonc:{ "dsl": ["typeprof-rails"] }The argument is a gem name. TypeProf requires the gem, which registers its plugins on load.
Plugin authoring
A plugin for Rails'
belongs_to. It turnsbelongs_to :userinPostintodef user: -> User?onPost::GeneratedAssociationMethods(a module included byPost).Triggers
on "X#foo"X#fooon "X.foo"X.fooon_inherit "X"Xin its ancestor chainon_include "X"include Xon_extend "X"extend Xon_prepend "X"prepend XInside an
on "X#foo"pattern,#always denotes an instance method and.a singleton method on the definition side.Scope API
Context
Reading arguments
Each helper extracts a single concrete literal from a positional or keyword argument. Returns
nilif the argument is missing, isn't a literal, resolves to multiple values, or isuntyped.Only
Symbolandtrue/falseliteral values are preserved on Vertices today.String/Integerliterals flow as types only, soarg_string/kwarg_stringare intentionally omitted.Constructing types
Resolving and creating modules
Defining methods on a module
Notes
--dslflag andtypeprof.conf.jsonc"dsl"key follow once it stabilizes.