Skip to content

Proposal: DSL Plugin system for dynamic method-defining DSLs #460

Description

@sinsoku

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:

  1. Each on "X#foo" declaration registers (cpath, mid, singleton) -> PluginClass in TypeProf::Dsl::Registry.
  2. 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.
  3. 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:

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

  • Config: a "dsl" key in typeprof.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 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))endendendendend

Triggers

TriggerFires 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 fallback

Resolving 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions