Skip to content

Commit 9a2cf3d

Browse files
sinsokuclaude
authored andcommitted
Implement RBS prepend support with comprehensive tests
This commit adds full support for RBS::AST::Members::Prepend to properly handle prepended modules in type checking and method resolution. Key changes: - Add SigPrependNode class to handle prepend declarations - Separate prepended_modules from included_modules in ModuleEntity - Fix method resolution order: prepended modules → class → included modules - Update type compatibility checking to recognize prepended modules - Fix prepend module ordering (last prepended = first in ancestor chain) - Add break after finding first matching prepended method Testing: - Add test for basic prepend functionality - Add test for multiple prepended modules - Add test for prepend and include combination This ensures that when a class prepends a module, instances of that class are correctly recognized as compatible with the module type, and methods from prepended modules properly override class methods following Ruby semantics. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 82cf54b commit 9a2cf3d

8 files changed

Lines changed: 302 additions & 0 deletions

File tree

lib/typeprof/core/ast.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,8 @@ def self.create_rbs_member(raw_decl, lenv)
400400
SigDefNode.new(raw_decl, lenv)
401401
when RBS::AST::Members::Include
402402
SigIncludeNode.new(raw_decl, lenv)
403+
when RBS::AST::Members::Prepend
404+
SigPrependNode.new(raw_decl, lenv)
403405
when RBS::AST::Members::Extend
404406
when RBS::AST::Members::Public
405407
when RBS::AST::Members::Private

lib/typeprof/core/ast/sig_decl.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,55 @@ def install0(genv)
255255
end
256256
end
257257

258+
class SigPrependNode < Node
259+
def initialize(raw_decl, lenv)
260+
super(raw_decl, lenv)
261+
name = raw_decl.name
262+
@cpath = name.namespace.path + [name.name]
263+
@toplevel = name.namespace.absolute?
264+
@args = raw_decl.args.map {|arg| AST.create_rbs_type(arg, lenv) }
265+
end
266+
267+
attr_reader :cpath, :toplevel, :args
268+
def subnodes = { args: }
269+
def attrs = { cpath:, toplevel: }
270+
271+
def define0(genv)
272+
@args.each {|arg| arg.define(genv) }
273+
const_reads = []
274+
const_read = BaseConstRead.new(genv, @cpath.first, @toplevel ? CRef::Toplevel : @lenv.cref, false)
275+
const_reads << const_read
276+
@cpath[1..].each do |cname|
277+
const_read = ScopedConstRead.new(cname, const_read, false)
278+
const_reads << const_read
279+
end
280+
mod = genv.resolve_cpath(@lenv.cref.cpath)
281+
const_read.followers << mod
282+
mod.add_prepend_decl(genv, self)
283+
const_reads
284+
end
285+
286+
def define_copy(genv)
287+
mod = genv.resolve_cpath(@lenv.cref.cpath)
288+
mod.add_prepend_decl(genv, self)
289+
mod.remove_prepend_decl(genv, @prev_node)
290+
super(genv)
291+
end
292+
293+
def undefine0(genv)
294+
mod = genv.resolve_cpath(@lenv.cref.cpath)
295+
mod.remove_prepend_decl(genv, self)
296+
@static_ret.each do |const_read|
297+
const_read.destroy(genv)
298+
end
299+
@args.each {|arg| arg.undefine(genv) }
300+
end
301+
302+
def install0(genv)
303+
Source.new
304+
end
305+
end
306+
258307
class SigAliasNode < Node
259308
def initialize(raw_decl, lenv)
260309
super(raw_decl, lenv)

lib/typeprof/core/env/module_entity.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ def initialize(cpath, outer_module = self)
77
@module_defs = Set[]
88
@include_decls = Set[]
99
@include_defs = Set[]
10+
@prepend_decls = []
11+
@prepend_defs = []
1012

1113
@inner_modules = {}
1214
@outer_module = outer_module
@@ -15,6 +17,7 @@ def initialize(cpath, outer_module = self)
1517
@superclass = nil
1618
@self_types = {}
1719
@included_modules = {}
20+
@prepended_modules = {}
1821
@basic_object = @cpath == [:BasicObject]
1922

2023
# child modules (subclasses and all modules that include me)
@@ -46,6 +49,7 @@ def initialize(cpath, outer_module = self)
4649
attr_reader :superclass
4750
attr_reader :self_types
4851
attr_reader :included_modules
52+
attr_reader :prepended_modules
4953
attr_reader :child_modules
5054

5155
attr_reader :superclass_type_args
@@ -192,6 +196,26 @@ def remove_include_def(genv, node)
192196
genv.add_static_eval_queue(:parent_modules_changed, self)
193197
end
194198

199+
def add_prepend_decl(genv, node)
200+
@prepend_decls << node
201+
genv.add_static_eval_queue(:parent_modules_changed, self)
202+
end
203+
204+
def remove_prepend_decl(genv, node)
205+
@prepend_decls.delete(node) || raise
206+
genv.add_static_eval_queue(:parent_modules_changed, self)
207+
end
208+
209+
def add_prepend_def(genv, node)
210+
@prepend_defs << node
211+
genv.add_static_eval_queue(:parent_modules_changed, self)
212+
end
213+
214+
def remove_prepend_def(genv, node)
215+
@prepend_defs.delete(node) || raise
216+
genv.add_static_eval_queue(:parent_modules_changed, self)
217+
end
218+
195219
def update_parent(genv, origin, old_parent, new_parent_cpath)
196220
new_parent = new_parent_cpath ? genv.resolve_cpath(new_parent_cpath) : nil
197221
if old_parent != new_parent
@@ -323,6 +347,30 @@ def on_parent_modules_changed(genv)
323347
any_updated = true
324348
end
325349
end
350+
@prepend_decls.each do |pdecl|
351+
new_parent_cpath = pdecl.static_ret.last.cpath
352+
new_parent, updated = update_parent(genv, pdecl, @prepended_modules[pdecl], new_parent_cpath)
353+
if updated
354+
if new_parent
355+
@prepended_modules[pdecl] = new_parent
356+
else
357+
@prepended_modules.delete(pdecl) || raise
358+
end
359+
any_updated = true
360+
end
361+
end
362+
@prepend_defs.each do |pdef|
363+
new_parent_cpath = pdef.static_ret ? pdef.static_ret.cpath : nil
364+
new_parent, updated = update_parent(genv, pdef, @prepended_modules[pdef], new_parent_cpath)
365+
if updated
366+
if new_parent
367+
@prepended_modules[pdef] = new_parent
368+
else
369+
@prepended_modules.delete(pdef) || raise
370+
end
371+
any_updated = true
372+
end
373+
end
326374
@included_modules.delete_if do |origin, old_mod|
327375
if @include_decls.include?(origin) || @include_defs.include?(origin)
328376
false
@@ -332,6 +380,15 @@ def on_parent_modules_changed(genv)
332380
true
333381
end
334382
end
383+
@prepended_modules.delete_if do |origin, old_mod|
384+
if @prepend_decls.include?(origin) || @prepend_defs.include?(origin)
385+
false
386+
else
387+
_new_parent, updated = update_parent(genv, origin, old_mod, nil)
388+
any_updated ||= updated
389+
true
390+
end
391+
end
335392

336393
if any_updated
337394
@subclass_checks.each do |mcall_box|

lib/typeprof/core/graph/box.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,16 @@ def resolve(genv, changes, &blk)
786786
alias_limit = 0
787787
while ty
788788
unless skip
789+
# First check prepended modules
790+
if !ty.is_a?(Type::Singleton)
791+
if resolve_prepended_modules(genv, changes, base_ty_env, ty, mid) do |me, ty, mid|
792+
yield me, ty, mid, orig_ty
793+
end
794+
break
795+
end
796+
end
797+
798+
# Then check the class/module itself
789799
me = ty.mod.get_method(ty.is_a?(Type::Singleton), mid)
790800
changes.add_depended_method_entity(me) if changes
791801
if !me.aliases.empty?
@@ -804,6 +814,7 @@ def resolve(genv, changes, &blk)
804814
if ty.is_a?(Type::Singleton)
805815
# TODO: extended modules
806816
else
817+
# Finally check included modules
807818
break if resolve_included_modules(genv, changes, base_ty_env, ty, mid) do |me, ty, mid|
808819
yield me, ty, mid, orig_ty
809820
end
@@ -816,6 +827,38 @@ def resolve(genv, changes, &blk)
816827
end
817828
end
818829

830+
def resolve_prepended_modules(genv, changes, base_ty_env, ty, mid, &blk)
831+
found = false
832+
833+
alias_limit = 0
834+
# Process prepended modules in reverse order (last prepended = first in ancestor chain)
835+
ty.mod.prepended_modules.reverse_each do |prep_decl, prep_mod|
836+
if prep_decl.is_a?(AST::SigPrependNode) && prep_mod.type_params
837+
prep_ty = genv.get_instance_type(prep_mod, prep_decl.args, changes, base_ty_env, ty)
838+
else
839+
type_params = prep_mod.type_params.map {|ty_param| Source.new() } # TODO: better support
840+
prep_ty = Type::Instance.new(genv, prep_mod, type_params)
841+
end
842+
843+
me = prep_ty.mod.get_method(false, mid)
844+
changes.add_depended_method_entity(me) if changes
845+
if !me.aliases.empty?
846+
mid = me.aliases.values.first
847+
alias_limit += 1
848+
redo if alias_limit < 5
849+
end
850+
if me.exist?
851+
found = true
852+
yield me, prep_ty, mid
853+
else
854+
found = resolve_prepended_modules(genv, changes, base_ty_env, prep_ty, mid, &blk)
855+
end
856+
break if found
857+
end
858+
859+
found
860+
end
861+
819862
def resolve_included_modules(genv, changes, base_ty_env, ty, mid, &blk)
820863
found = false
821864

lib/typeprof/core/type.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ def check_match(genv, changes, vtx)
107107
changes.add_depended_superclass(ty.mod)
108108

109109
if other_ty.mod.module?
110+
return true if check_match_prepended_modules(genv, changes, ty, other_ty)
110111
return true if check_match_included_modules(genv, changes, ty, other_ty)
111112
end
112113

@@ -117,6 +118,32 @@ def check_match(genv, changes, vtx)
117118
return false
118119
end
119120

121+
def check_match_prepended_modules(genv, changes, ty, other_ty)
122+
ty.mod.prepended_modules.each do |prep_decl, prep_mod|
123+
if prep_decl.is_a?(AST::SigPrependNode) && prep_mod.type_params
124+
prep_ty = genv.get_instance_type(prep_mod, prep_decl.args, changes, {}, ty)
125+
else
126+
type_params = prep_mod.type_params.map {|ty_param| Source.new() } # TODO: better support
127+
prep_ty = Type::Instance.new(genv, prep_mod, type_params)
128+
end
129+
if prep_ty.mod == other_ty.mod
130+
args_all_match = true
131+
prep_ty.args.zip(other_ty.args) do |arg, other_arg|
132+
if other_arg && !arg.check_match(genv, changes, other_arg)
133+
args_all_match = false
134+
break
135+
end
136+
end
137+
return true if args_all_match
138+
end
139+
changes.add_depended_superclass(prep_ty.mod)
140+
141+
return true if check_match_prepended_modules(genv, changes, prep_ty, other_ty)
142+
return true if check_match_included_modules(genv, changes, prep_ty, other_ty)
143+
end
144+
return false
145+
end
146+
120147
def check_match_included_modules(genv, changes, ty, other_ty)
121148
ty.mod.included_modules.each do |inc_decl, inc_mod|
122149
if inc_decl.is_a?(AST::SigIncludeNode) && inc_mod.type_params

scenario/rbs/prepend.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
## update: test.rbs
2+
module M
3+
def foo: () -> String
4+
end
5+
6+
class C
7+
prepend M
8+
9+
def foo: () -> Integer
10+
end
11+
12+
class Object
13+
def accept_m: (M) -> String
14+
end
15+
16+
## update: test.rb
17+
def test
18+
accept_m(C.new)
19+
end
20+
21+
def test2
22+
C.new.foo
23+
end
24+
25+
## assert
26+
class Object
27+
def test: -> String
28+
def test2: -> String
29+
end
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
## update: test.rbs
2+
module M1
3+
def foo: () -> :m1
4+
end
5+
6+
module M2
7+
def foo: () -> :m2
8+
def bar: () -> :m2
9+
end
10+
11+
module M3
12+
def foo: () -> :m3
13+
def bar: () -> :m3
14+
def baz: () -> :m3
15+
end
16+
17+
class C
18+
include M1
19+
prepend M2
20+
include M3
21+
22+
def foo: () -> :c
23+
def bar: () -> :c
24+
def baz: () -> :c
25+
end
26+
27+
## update: test.rb
28+
def test_foo
29+
# Should return :m2 (prepended module wins)
30+
C.new.foo
31+
end
32+
33+
def test_bar
34+
# Should return :m2 (prepended module wins)
35+
C.new.bar
36+
end
37+
38+
def test_baz
39+
# Should return :c (class method overrides included module)
40+
C.new.baz
41+
end
42+
43+
## assert
44+
class Object
45+
def test_foo: -> :m2
46+
def test_bar: -> :m2
47+
def test_baz: -> :c
48+
end

scenario/rbs/prepend_multiple.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
## update: test.rbs
2+
module M1
3+
def foo: () -> :m1
4+
end
5+
6+
module M2
7+
def foo: () -> :m2
8+
def bar: () -> :m2
9+
end
10+
11+
class C
12+
prepend M1
13+
prepend M2
14+
15+
def foo: () -> :c
16+
def bar: () -> :c
17+
end
18+
19+
class Object
20+
def accept_m1: (M1) -> String
21+
def accept_m2: (M2) -> String
22+
end
23+
24+
## update: test.rb
25+
def test_foo
26+
C.new.foo
27+
end
28+
29+
def test_bar
30+
C.new.bar
31+
end
32+
33+
def test_type_m1
34+
accept_m1(C.new)
35+
end
36+
37+
def test_type_m2
38+
accept_m2(C.new)
39+
end
40+
41+
## assert
42+
class Object
43+
def test_foo: -> :m2
44+
def test_bar: -> :m2
45+
def test_type_m1: -> String
46+
def test_type_m2: -> String
47+
end

0 commit comments

Comments
 (0)