From 51e939ba0cf77b62ee7669abcc6ec30efaaaef8c Mon Sep 17 00:00:00 2001 From: Yusuke Endoh Date: Fri, 3 Apr 2026 20:16:35 +0900 Subject: [PATCH] Add Struct.new and Data.define support Recognize `Foo = Struct.new(:bar, :baz)` and `Foo = Data.define(:x, :y)` patterns and generate appropriate class definitions with: - attr_reader for all members - attr_writer for Struct members (Data is frozen) - initialize with positional args (Struct) or keyword args (Data) - Block body support for additional method definitions Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/typeprof/core/ast.rb | 34 ++++- lib/typeprof/core/ast/meta.rb | 130 ++++++++++++++++++ lib/typeprof/core/env/module_entity.rb | 2 + lib/typeprof/core/graph/box.rb | 4 +- lib/typeprof/core/service.rb | 6 +- .../known-issues/struct-new-as-superclass.rb | 14 ++ scenario/misc/struct_new.rb | 71 ++++++++++ 7 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 scenario/known-issues/struct-new-as-superclass.rb create mode 100644 scenario/misc/struct_new.rb diff --git a/lib/typeprof/core/ast.rb b/lib/typeprof/core/ast.rb index 54f05878..43a43945 100644 --- a/lib/typeprof/core/ast.rb +++ b/lib/typeprof/core/ast.rb @@ -102,7 +102,13 @@ def self.create_node(raw_node, lenv, use_result = true, allow_meta = false) when :constant_read_node, :constant_path_node ConstantReadNode.new(raw_node, lenv) when :constant_write_node, :constant_path_write_node - ConstantWriteNode.new(raw_node, AST.create_node(raw_node.value, lenv), lenv) + if (members = detect_struct_new(raw_node.value)) + StructNewNode.new(raw_node, members, :struct, lenv) + elsif (members = detect_data_define(raw_node.value)) + StructNewNode.new(raw_node, members, :data, lenv) + else + ConstantWriteNode.new(raw_node, AST.create_node(raw_node.value, lenv), lenv) + end when :constant_operator_write_node read = ConstantReadNode.new(raw_node, lenv) rhs = OperatorNode.new(raw_node, read, lenv) @@ -526,5 +532,31 @@ def self.create_rbs_type(raw_decl, lenv) raise "unknown RBS type: #{ raw_decl.class }" end end + + def self.detect_struct_new(raw_value) + return nil unless raw_value.type == :call_node + return nil unless raw_value.name == :new + recv = raw_value.receiver + return nil unless recv&.type == :constant_read_node && recv.name == :Struct + extract_symbol_args(raw_value) + end + + def self.detect_data_define(raw_value) + return nil unless raw_value.type == :call_node + return nil unless raw_value.name == :define + recv = raw_value.receiver + return nil unless recv&.type == :constant_read_node && recv.name == :Data + extract_symbol_args(raw_value) + end + + def self.extract_symbol_args(raw_call) + return nil unless raw_call.arguments + members = [] + raw_call.arguments.arguments.each do |arg| + return nil unless arg.type == :symbol_node + members << arg.value.to_sym + end + members + end end end diff --git a/lib/typeprof/core/ast/meta.rb b/lib/typeprof/core/ast/meta.rb index 456aefd8..c739da58 100644 --- a/lib/typeprof/core/ast/meta.rb +++ b/lib/typeprof/core/ast/meta.rb @@ -208,5 +208,135 @@ def install0(genv) Source.new end end + + class StructNewNode < Node + def initialize(raw_node, members, kind, lenv) + super(raw_node, lenv) + case raw_node.type + when :constant_write_node + @static_cpath = lenv.cref.cpath + [raw_node.name] + when :constant_path_write_node + @static_cpath = AST.parse_cpath(raw_node.target, lenv.cref) + else + raise + end + @members = members + @kind = kind # :struct or :data + + # Parse block body if present (Struct.new(:foo) do ... end) + raw_value = raw_node.value + if raw_value.block && raw_value.block.type == :block_node && raw_value.block.body + ncref = CRef.new(@static_cpath, :instance, nil, lenv.cref) + nlenv = LocalEnv.new(lenv.file_context, ncref, {}, []) + @block_body = AST.create_node(raw_value.block.body, nlenv) + end + end + + attr_reader :static_cpath, :members, :kind, :block_body + + def subnodes = { block_body: } + def attrs = { static_cpath:, members:, kind: } + + # Interface expected by MethodDefBox + def req_positionals = @kind == :struct ? @members : [] + def opt_positionals = [] + def rest_positionals = nil + def post_positionals = [] + def req_keywords = @kind == :data ? @members : [] + def opt_keywords = [] + def rest_keywords = nil + def no_keywords = @kind == :struct + + def define0(genv) + mod = genv.resolve_cpath(@static_cpath) + # add_module_def internally calls get_const(name).add_def(self) + cdef = mod.add_module_def(genv, self) + @members.each do |member| + ive = genv.resolve_ivar(@static_cpath, false, member) + ive.add_def(self) + end + @block_body.define(genv) if @block_body + cdef + end + + def define_copy(genv) + mod = genv.resolve_cpath(@static_cpath) + mod.add_module_def(genv, self) + mod.remove_module_def(genv, @prev_node) + @members.each do |member| + ive = genv.resolve_ivar(@static_cpath, false, member) + ive.add_def(self) + ive.remove_def(@prev_node) + end + super(genv) + end + + def undefine0(genv) + mod = genv.resolve_cpath(@static_cpath) + mod.remove_module_def(genv, self) + @members.each do |member| + ive = genv.resolve_ivar(@static_cpath, false, member) + ive.remove_def(self) + end + @block_body.undefine(genv) if @block_body + end + + def install0(genv) + # Register the class singleton type as the constant value + mod_val = Source.new(Type::Singleton.new(genv, genv.resolve_cpath(@static_cpath))) + if @static_cpath + @changes.add_edge(genv, mod_val, @static_ret.vtx) + end + + cpath = @static_cpath + @members.each do |member| + # Use bare `:member` (not `:@member`) so the slot can't collide with a + # user-written @member ivar — Struct/Data fields are not real ivars. + ivar_box = @changes.add_ivar_read_box(genv, cpath, false, member) + ret_box = @changes.add_escape_box(genv, ivar_box.ret) + @changes.add_method_def_box(genv, cpath, false, member, FormalArguments::Empty, [ret_box]) + + if @kind == :struct + # attr_writer (Struct only, Data is frozen) + ive = genv.resolve_ivar(cpath, false, member) + vtx = Vertex.new(self) + @changes.add_edge(genv, vtx, ive.vtx) + writer_ret = @changes.add_escape_box(genv, vtx) + f_args = FormalArguments.new([vtx], [], nil, [], [], [], nil, nil) + @changes.add_method_def_box(genv, cpath, false, :"#{ member }=", f_args, [writer_ret]) + end + end + + # initialize + init_vtxs = @members.map do |member| + ive = genv.resolve_ivar(cpath, false, member) + vtx = Vertex.new(self) + @changes.add_edge(genv, vtx, ive.vtx) + vtx + end + init_ret = @changes.add_escape_box(genv, Source.new(genv.nil_type)) + if @kind == :struct + init_f_args = FormalArguments.new(init_vtxs, [], nil, [], [], [], nil, nil) + else + # Data.define uses keyword arguments + init_f_args = FormalArguments.new([], [], nil, [], init_vtxs, [], nil, nil) + end + @changes.add_method_def_box(genv, cpath, false, :initialize, init_f_args, [init_ret]) + + # Struct.[] is an alias for Struct.new + if @kind == :struct + self_ret = @changes.add_escape_box(genv, Source.new(Type::Instance.new(genv, genv.resolve_cpath(cpath), []))) + @changes.add_method_def_box(genv, cpath, true, :[], init_f_args, [self_ret]) + end + + # Install block body (additional method definitions) + if @block_body + @block_body.lenv.locals[:"*self"] = @block_body.lenv.cref.get_self(genv) + @block_body.install(genv) + end + + mod_val + end + end end end diff --git a/lib/typeprof/core/env/module_entity.rb b/lib/typeprof/core/env/module_entity.rb index 2783f612..dad5dfd0 100644 --- a/lib/typeprof/core/env/module_entity.rb +++ b/lib/typeprof/core/env/module_entity.rb @@ -261,6 +261,8 @@ def find_superclass_const_read next when AST::ModuleNode return nil + when AST::StructNewNode + return [] # inherits from Object (Struct < Object) else raise end diff --git a/lib/typeprof/core/graph/box.rb b/lib/typeprof/core/graph/box.rb index c486bec7..41eed17c 100644 --- a/lib/typeprof/core/graph/box.rb +++ b/lib/typeprof/core/graph/box.rb @@ -935,7 +935,9 @@ def show(output_parameter_names) @f_args.post_positionals.each do |var| args << Type.strip_parens(var.show) end - if @node.is_a?(AST::DefNode) + if @node.respond_to?(:req_keywords) && + @node.req_keywords.size == @f_args.req_keywords.size && + @node.opt_keywords.size == @f_args.opt_keywords.size @node.req_keywords.zip(@f_args.req_keywords) do |name, f_vtx| args << "#{ name }: #{Type.strip_parens(f_vtx.show)}" end diff --git a/lib/typeprof/core/service.rb b/lib/typeprof/core/service.rb index 9755e498..3ee9d08b 100644 --- a/lib/typeprof/core/service.rb +++ b/lib/typeprof/core/service.rb @@ -472,7 +472,7 @@ def dump_declarations(path) out << " " * stack.size + "end" end end - when AST::ClassNode, AST::SingletonClassNode + when AST::ClassNode, AST::SingletonClassNode, AST::StructNewNode if node.static_cpath next if stack.any? { node.is_a?(AST::SingletonClassNode) && (_1.is_a?(AST::ClassNode) || _1.is_a?(AST::ModuleNode)) && node.static_cpath == _1.static_cpath } @@ -496,6 +496,10 @@ def dump_declarations(path) out << " " * stack.size + "include #{ inc_mod.show_cpath }" end end + # Output method definitions from meta nodes (StructNewNode etc.) + node.boxes(:mdef) do |mdef| + out << " " * stack.size + "def #{ mdef.singleton ? "self." : "" }#{ mdef.mid }: " + mdef.show(@options[:output_parameter_names]) + end else stack.pop out << " " * stack.size + "end" diff --git a/scenario/known-issues/struct-new-as-superclass.rb b/scenario/known-issues/struct-new-as-superclass.rb new file mode 100644 index 00000000..6809dacd --- /dev/null +++ b/scenario/known-issues/struct-new-as-superclass.rb @@ -0,0 +1,14 @@ +## update +class Point < Struct.new(:x, :y) +end +Point.new(1, "hello").x + +## assert +class Point < Struct[untyped] + def x: -> Integer + def y: -> String + def x=: (Integer) -> Integer + def y=: (untyped) -> untyped + def initialize: (Integer, String) -> void + def self.[]: (Integer, String) -> Point +end diff --git a/scenario/misc/struct_new.rb b/scenario/misc/struct_new.rb new file mode 100644 index 00000000..9357266b --- /dev/null +++ b/scenario/misc/struct_new.rb @@ -0,0 +1,71 @@ +## update +Foo = Struct.new(:bar, :baz) +f = Foo.new(1, "hello") +f.bar +f.baz +f.bar = 2 +g = Foo[3, "world"] + +## assert +class Foo + def bar: -> Integer + def bar=: (Integer) -> Integer + def baz: -> String + def baz=: (untyped) -> untyped + def initialize: (Integer, String) -> void + def self.[]: (Integer, String) -> Foo +end + +## update +Pt = Data.define(:x, :y) +p = Pt.new(x: 1, y: "hello") +p.x +p.y + +## assert +class Pt + def x: -> Integer + def y: -> String + def initialize: (x: Integer, y: String) -> void +end + +## update +Bar = Struct.new(:n) do + def double + n * 2 + end +end +Bar.new(5).double + +## assert +class Bar + def n: -> Integer + def n=: (untyped) -> untyped + def initialize: (Integer) -> void + def self.[]: (Integer) -> Bar + def double: -> Integer +end + +## update +# The Struct member `v` is not a real Ruby ivar, so a user-written @v inside +# the block body must not share the member's type. +Baz = Struct.new(:v) do + def set_label + @v = "label" + end + def ivar + @v + end +end +Baz.new(42).v +Baz.new(42).ivar + +## assert +class Baz + def v: -> Integer + def v=: (untyped) -> untyped + def initialize: (Integer) -> void + def self.[]: (Integer) -> Baz + def set_label: -> String + def ivar: -> String +end