From 79a6bd1fc84d66ce98160b66a4a73671d73536c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 01:49:39 +0000 Subject: [PATCH 1/4] Extract block handling from CallBaseNode into BlockNode The parsing of block parameters, the block-local scope, and the installation of the block body lived inside CallBaseNode. Nothing there depends on being a call, and a lambda literal needs exactly the same handling without being a call, so it moves into its own node that a call holds as a subnode. Block parameters now go through AST.parse_params and multi-target binding through a shared Node helper, both of which DefNode already used for the same job. Every node now answers ret_code_range, so the escape box no longer picks a code-range method by node class; a body-bearing node points at its last statement, the rest at themselves. A diagnostic on an empty block therefore points at the block instead of the whole call, and a block on `super do ... end` no longer falls through to a debug `pp`. Co-Authored-By: Claude Fable 5.1 --- lib/typeprof/core/ast/base.rb | 14 ++ lib/typeprof/core/ast/call.rb | 244 ++++++++++++++------------------ lib/typeprof/core/ast/method.rb | 26 +--- lib/typeprof/core/ast/misc.rb | 2 + lib/typeprof/core/env/method.rb | 2 +- lib/typeprof/core/graph/box.rb | 15 +- scenario/rbs/block.rb | 4 +- 7 files changed, 127 insertions(+), 180 deletions(-) diff --git a/lib/typeprof/core/ast/base.rb b/lib/typeprof/core/ast/base.rb index 6f4bf480f..0348a41a9 100644 --- a/lib/typeprof/core/ast/base.rb +++ b/lib/typeprof/core/ast/base.rb @@ -218,6 +218,20 @@ def modified_vars(tbl, vars) end end + # Where a diagnostic about the value this node yields is placed: a node + # with a body points at the body's last statement, the rest at itself. + def ret_code_range = code_range + + def install_multi_targets(genv, multi_targets, positionals, lenv) + multi_targets.each do |idx, raw_multi_target| + param_vtx = positionals[idx] + lefts = raw_multi_target.lefts.map do |n| + lenv.new_var(n.is_a?(Prism::MultiTargetNode) ? nil : n.name, self) + end + @changes.add_masgn_box(genv, param_vtx, lefts, nil, nil) + end + end + def pretty_print_instance_variables super() - [:@raw_node, :@lenv, :@prev_node, :@static_ret, :@changes] end diff --git a/lib/typeprof/core/ast/call.rb b/lib/typeprof/core/ast/call.rb index 2d794240e..2c03df582 100644 --- a/lib/typeprof/core/ast/call.rb +++ b/lib/typeprof/core/ast/call.rb @@ -1,5 +1,99 @@ module TypeProf::Core class AST + # @lenv is the scope the block closes over; the body has its own LocalEnv. + class BlockNode < Node + def initialize(raw_node, lenv, mid) + super(raw_node, lenv) + + @tbl = raw_node.locals + ncref = CRef.new(lenv.cref.cpath, :instance, mid, lenv.cref) + nlenv = LocalEnv.new(lenv.file_context, ncref, {}, lenv.return_boxes) + + @f_args = [] + @multi_targets = {} + @opt_positional_defaults = [] + case raw_node.parameters + when Prism::BlockParametersNode + # `{ || ... }` (empty pipes) and `{ |; x| ... }` (block-local-only) + # yield BlockParametersNode whose inner `parameters` is nil. + h = AST.parse_params(@tbl, raw_node.parameters.parameters, nlenv) + @f_args = h[:req_positionals] + h[:opt_positionals] + @multi_targets = h[:req_multi_targets] + @opt_positional_defaults = h[:opt_positional_defaults] + when Prism::NumberedParametersNode + @f_args = 1.upto(raw_node.parameters.maximum).map {|n| :"_#{n}" } + when Prism::ItParametersNode + @f_args = [:it] + when nil + else + raise "not supported yet: #{ raw_node.parameters.class }" + end + @body = raw_node.body ? AST.create_node(raw_node.body, nlenv) : DummyNilNode.new(code_range, lenv) + end + + attr_reader :tbl, :f_args, :opt_positional_defaults, :body + + def subnodes = { opt_positional_defaults:, body: } + def attrs = { tbl:, f_args: } + + def install0(genv) + blenv = @body.lenv + blenv.forward_args = @lenv.forward_args + @lenv.locals.each {|var, vtx| blenv.locals[var] = vtx } + @tbl.each {|var| blenv.locals[var] = Source.new(genv.nil_type) } + blenv.locals[:"*self"] = blenv.cref.get_self(genv) + + f_args = @f_args.map {|arg| blenv.new_var(arg, self) } + + req_count = f_args.size - @opt_positional_defaults.size + @opt_positional_defaults.each_with_index do |expr, i| + @changes.add_edge(genv, expr.install(genv), f_args[req_count + i]) + end + + install_multi_targets(genv, @multi_targets, f_args, blenv) + + @lenv.locals.each do |var, vtx| + blenv.set_var(var, vtx) + end + vars = [] + @body.modified_vars(@lenv.locals.keys - @tbl, vars) + vars.uniq! + vars.each do |var| + vtx = @lenv.get_var(var) + nvtx = vtx.new_vertex(genv, self) + @lenv.set_var(var, nvtx) + blenv.set_var(var, nvtx) + end + + blenv.locals[:"*expected_block_ret"] = Vertex.new(self) + @body.install(genv) + blenv.add_next_box(@changes.add_escape_box(genv, @body.ret)) + + vars.each do |var| + @changes.add_edge(genv, blenv.get_var(var), @lenv.get_var(var)) + end + + f_ary_arg = Vertex.new(self) + # TODO: support splat "do |a, *b, c|" + f_args.each_with_index do |f_arg, i| + elem_vtx = @changes.add_splat_box(genv, f_ary_arg, i).ret + @changes.add_edge(genv, elem_vtx, f_arg) + end + block = Block.new(self, f_ary_arg, f_args, blenv.next_boxes) + Source.new(Type::Proc.new(genv, block)) + end + + # Block-local variables shadow the outer ones, so writes to them are not + # modifications of the enclosing scope. + def modified_vars(tbl, vars) + super(tbl - @tbl, vars) + end + + def break_vtx = @body.lenv.break_vtx + + def ret_code_range = @body.ret_code_range + end + class CallBaseNode < Node def initialize(raw_node, recv, mid, mid_code_range_loc, raw_args, last_arg, raw_block, lenv, forwarding_arguments: false) super(raw_node, lenv) @@ -14,10 +108,7 @@ def initialize(raw_node, recv, mid, mid_code_range_loc, raw_args, last_arg, raw_ @keyword_args = nil @block_pass = nil - @block_tbl = nil - @block_f_args = nil - @block_opt_positional_defaults = nil - @block_body = nil + @block = nil @safe_navigation = raw_node.respond_to?(:safe_navigation?) && raw_node.safe_navigation? @anonymous_block_forwarding = false @forwarding_arguments = forwarding_arguments @@ -55,47 +146,7 @@ def initialize(raw_node, recv, mid, mid_code_range_loc, raw_args, last_arg, raw_ @anonymous_block_forwarding = true end else - @block_pass = nil - @block_tbl = raw_block.locals - @block_multi_targets = {} - @block_f_args = case raw_block.parameters - when Prism::BlockParametersNode - # `{ || ... }` (empty pipes) and `{ |; x| ... }` - # (block-local-only) yield BlockParametersNode - # whose inner `parameters` is nil. - params = raw_block.parameters.parameters - if params - req = params.requireds.each_with_index.map do |n, i| - if n.is_a?(Prism::MultiTargetNode) - @block_multi_targets[i] = n - nil - else - n.name - end - end - opt = params.optionals.map {|n| n.name } - req + opt - else - [] - end - when Prism::NumberedParametersNode - 1.upto(raw_block.parameters.maximum).map { |n| :"_#{n}" } - when Prism::ItParametersNode - [:it] - when nil - [] - else - raise "not supported yet: #{ raw_block.parameters.class }" - end - ncref = CRef.new(lenv.cref.cpath, :instance, @mid, lenv.cref) - nlenv = LocalEnv.new(@lenv.file_context, ncref, {}, @lenv.return_boxes) - @block_opt_positional_defaults = [] - if raw_block.parameters.is_a?(Prism::BlockParametersNode) && raw_block.parameters.parameters - raw_block.parameters.parameters.optionals.each do |n| - @block_opt_positional_defaults << AST.create_node(n.value, nlenv) - end - end - @block_body = raw_block.body ? AST.create_node(raw_block.body, nlenv) : DummyNilNode.new(code_range, lenv) + @block = BlockNode.new(raw_block, lenv, @mid) end end @@ -108,12 +159,11 @@ def mid_code_range @mid_code_range ||= @lenv.code_range_from_node(@mid_code_range_loc) if @mid_code_range_loc end attr_reader :positional_args, :splat_flags, :keyword_args - attr_reader :block_tbl, :block_f_args, :block_opt_positional_defaults, :block_body, :block_pass, :anonymous_block_forwarding - attr_reader :block_multi_targets + attr_reader :block, :block_pass, :anonymous_block_forwarding attr_reader :safe_navigation, :forwarding_arguments - def subnodes = { recv:, positional_args:, keyword_args:, block_opt_positional_defaults:, block_body:, block_pass: } - def attrs = { mid:, splat_flags:, block_tbl:, block_f_args:, yield:, safe_navigation:, anonymous_block_forwarding:, forwarding_arguments: } + def subnodes = { recv:, positional_args:, keyword_args:, block:, block_pass: } + def attrs = { mid:, splat_flags:, yield:, safe_navigation:, anonymous_block_forwarding:, forwarding_arguments: } def install0(genv) recv = @recv ? @recv.install(genv) : @yield ? @lenv.get_var(:"*given_block") : @lenv.get_var(:"*self") @@ -146,67 +196,8 @@ def install0(genv) a_args = ActualArguments.new(positional_args, @splat_flags, @keyword_args ? @keyword_args.install(genv) : nil, nil) end - if @block_body - block_body = @block_body # kinda type annotationty - block_tbl = @block_tbl || raise - block_body.lenv.forward_args = @lenv.forward_args - @lenv.locals.each {|var, vtx| block_body.lenv.locals[var] = vtx } - block_tbl.each {|var| block_body.lenv.locals[var] = Source.new(genv.nil_type) } - block_body.lenv.locals[:"*self"] = block_body.lenv.cref.get_self(genv) - - blk_f_args = [] - if @block_f_args - @block_f_args.each do |arg| - blk_f_args << block_body.lenv.new_var(arg, self) - end - end - - if @block_opt_positional_defaults && !@block_opt_positional_defaults.empty? - req_count = blk_f_args.size - @block_opt_positional_defaults.size - @block_opt_positional_defaults.each_with_index do |expr, i| - @changes.add_edge(genv, expr.install(genv), blk_f_args[req_count + i]) - end - end - - if @block_multi_targets - @block_multi_targets.each do |idx, raw_multi_target| - param_vtx = blk_f_args[idx] - lefts = raw_multi_target.lefts.map do |n| - block_body.lenv.new_var(n.is_a?(Prism::MultiTargetNode) ? nil : n.name, self) - end - @changes.add_masgn_box(genv, param_vtx, lefts, nil, nil) - end - end - - @lenv.locals.each do |var, vtx| - block_body.lenv.set_var(var, vtx) - end - vars = [] - block_body.modified_vars(@lenv.locals.keys - block_tbl, vars) - vars.uniq! - vars.each do |var| - vtx = @lenv.get_var(var) - nvtx = vtx.new_vertex(genv, self) - @lenv.set_var(var, nvtx) - block_body.lenv.set_var(var, nvtx) - end - - block_body.lenv.locals[:"*expected_block_ret"] = Vertex.new(self) - block_body.install(genv) - block_body.lenv.add_next_box(@changes.add_escape_box(genv, block_body.ret)) - - vars.each do |var| - @changes.add_edge(genv, block_body.lenv.get_var(var), @lenv.get_var(var)) - end - - blk_f_ary_arg = Vertex.new(self) - # TODO: support splat "do |a, *b, c|" - blk_f_args.each_with_index do |f_arg, i| - elem_vtx = @changes.add_splat_box(genv, blk_f_ary_arg, i).ret - @changes.add_edge(genv, elem_vtx, f_arg) - end - block = Block.new(self, blk_f_ary_arg, blk_f_args, block_body.lenv.next_boxes) - blk_ty = Source.new(Type::Proc.new(genv, block)) + if @block + blk_ty = @block.install(genv) elsif @block_pass blk_ty = @block_pass.install(genv) elsif @anonymous_block_forwarding @@ -216,17 +207,16 @@ def install0(genv) end if @forwarding_arguments - a_args = a_args.with_block(blk_ty, omittable: !@block_body && !@block_pass && !@anonymous_block_forwarding) + a_args = a_args.with_block(blk_ty, omittable: !@block && !@block_pass && !@anonymous_block_forwarding) else a_args = a_args.with_block(blk_ty) end box = @changes.add_method_call_box(genv, recv, @mid, a_args, !@recv) - block_body = @block_body - if block_body && block_body.lenv.break_vtx + if @block && @block.break_vtx ret = Vertex.new(self) @changes.add_edge(genv, box.ret, ret) - @changes.add_edge(genv, block_body.lenv.break_vtx, ret) + @changes.add_edge(genv, @block.break_vtx, ret) else ret = box.ret end @@ -238,18 +228,6 @@ def install0(genv) ret end - def block_last_stmt_code_range - if @block_body - if @block_body.is_a?(AST::StatementsNode) - @block_body.stmts.last.code_range - else - @block_body.code_range - end - else - nil - end - end - def retrieve_at(pos, &blk) yield self if mid_code_range&.include?(pos) each_subnode do |subnode| @@ -258,20 +236,6 @@ def retrieve_at(pos, &blk) end end - def modified_vars(tbl, vars) - subnodes.each do |key, subnode| - next unless subnode - if subnode.is_a?(AST::Node) - if key == :block_body - subnode.modified_vars(tbl - self.block_tbl, vars) - else - subnode.modified_vars(tbl, vars) - end - else - subnode.each {|n| n&.modified_vars(tbl, vars) } - end - end - end end class CallNode < CallBaseNode diff --git a/lib/typeprof/core/ast/method.rb b/lib/typeprof/core/ast/method.rb index 620a99337..27a54227c 100644 --- a/lib/typeprof/core/ast/method.rb +++ b/lib/typeprof/core/ast/method.rb @@ -244,8 +244,8 @@ def install0(genv) opt_positionals = @opt_positionals.map {|var| @body.lenv.new_var(var, self) } rest_positionals = @rest_positionals ? @body.lenv.new_var(@rest_positionals, self) : nil post_positionals = @post_positionals.map {|var| @body.lenv.new_var(var, self) } - install_multi_targets(genv, @req_multi_targets, req_positionals) - install_multi_targets(genv, @post_multi_targets, post_positionals) + install_multi_targets(genv, @req_multi_targets, req_positionals, @body.lenv) + install_multi_targets(genv, @post_multi_targets, post_positionals, @body.lenv) req_keywords = @req_keywords.map {|var| @body.lenv.new_var(var, self) } opt_keywords = @opt_keywords.map {|var| @body.lenv.new_var(var, self) } rest_keywords = @rest_keywords ? @body.lenv.new_var(@rest_keywords, self) : nil @@ -317,27 +317,7 @@ def install0(genv) Source.new(Type::Symbol.new(genv, @mid)) end - def install_multi_targets(genv, multi_targets, positionals) - multi_targets.each do |idx, raw_multi_target| - param_vtx = positionals[idx] - lefts = raw_multi_target.lefts.map do |n| - @body.lenv.new_var(n.is_a?(Prism::MultiTargetNode) ? nil : n.name, self) - end - @changes.add_masgn_box(genv, param_vtx, lefts, nil, nil) - end - end - - def last_stmt_code_range - if @body - if @body.is_a?(AST::StatementsNode) - @body.stmts.last.code_range - else - @body.code_range - end - else - nil - end - end + def ret_code_range = @body.ret_code_range def retrieve_at(pos, &blk) if @rbs_method_type diff --git a/lib/typeprof/core/ast/misc.rb b/lib/typeprof/core/ast/misc.rb index 56f0321e0..c20143a42 100644 --- a/lib/typeprof/core/ast/misc.rb +++ b/lib/typeprof/core/ast/misc.rb @@ -16,6 +16,8 @@ def initialize(raw_node, lenv, use_result) attr_reader :stmts + def ret_code_range = @stmts.last.code_range + def subnodes = { stmts: } def install0(genv) diff --git a/lib/typeprof/core/env/method.rb b/lib/typeprof/core/env/method.rb index 97d701cf3..2d7d77e17 100644 --- a/lib/typeprof/core/env/method.rb +++ b/lib/typeprof/core/env/method.rb @@ -424,7 +424,7 @@ def build_keyword_args(genv, changes, node) end class Block - #: (AST::CallBaseNode, Vertex, Array[Vertex], Array[EscapeBox]) -> void + #: (AST::BlockNode, Vertex, Array[Vertex], Array[EscapeBox]) -> void def initialize(node, f_ary_arg, f_args, next_boxes) @node = node @f_ary_arg = f_ary_arg diff --git a/lib/typeprof/core/graph/box.rb b/lib/typeprof/core/graph/box.rb index 57f7e26ec..0171517ba 100644 --- a/lib/typeprof/core/graph/box.rb +++ b/lib/typeprof/core/graph/box.rb @@ -625,20 +625,7 @@ def run0(genv, changes) def wrong_return_type(f_ret_show, changes) actual_ty = @a_ret.show msg = "expected: #{ f_ret_show }; actual: #{ actual_ty }" - case @node - when AST::ReturnNode - changes.add_diagnostic(:code_range, msg, @node) - when AST::DefNode - changes.add_diagnostic(:last_stmt_code_range, msg, @node) - when AST::NextNode - changes.add_diagnostic(:code_range, msg, @node) - when AST::CallNode - changes.add_diagnostic(:block_last_stmt_code_range, msg, @node) - when AST::AttrReaderMetaNode, AST::AttrAccessorMetaNode - changes.add_diagnostic(:code_range, msg, @node) - else - pp @node.class - end + changes.add_diagnostic(:ret_code_range, msg, @node) end end diff --git a/scenario/rbs/block.rb b/scenario/rbs/block.rb index ba756fc7c..3df648ce8 100644 --- a/scenario/rbs/block.rb +++ b/scenario/rbs/block.rb @@ -37,6 +37,6 @@ def test6: -> :ok ## diagnostics (5,2)-(5,10): block is not expected -(11,2)-(11,20): expected: Integer; actual: nil +(11,17)-(11,20): expected: Integer; actual: nil (14,2)-(14,16): block is expected -(17,2)-(17,20): expected: Integer; actual: nil +(17,17)-(17,20): expected: Integer; actual: nil From e1770bc79fd418749ea215b844a8fb858828839c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 01:49:39 +0000 Subject: [PATCH 2/4] Analyze the body of a lambda literal `-> {}` was a stub that produced a bare Proc and never looked inside, so method calls in the body got no diagnostics, classes and methods defined there were not registered, and parameters were untyped. `lambda {}` had none of these gaps because it goes through the block handling of a call. LambdaNode is a BlockNode: a lambda literal builds its scope, parameters and body exactly like a block. It is not modeled as a `lambda` call because `->` is syntax and must not dispatch to a user-defined `lambda` method. Where a lambda differs from a block is how the body leaves. A block's `return` exits the enclosing method and its `break` exits the method that yielded; a lambda's `return` and `break` both exit the lambda, so its body gets its own return boxes and all three escapes join the value the caller of #call receives. Co-Authored-By: Claude Opus 5 --- lib/typeprof/core/ast/call.rb | 20 ++++++++++++++++++-- lib/typeprof/core/ast/value.rb | 11 ++++++++--- scenario/lambda/basic1.rb | 2 +- scenario/lambda/body.rb | 7 +++++++ scenario/lambda/class_in_body.rb | 17 +++++++++++++++++ scenario/lambda/escape.rb | 24 ++++++++++++++++++++++++ scenario/lambda/not_a_call.rb | 17 +++++++++++++++++ scenario/lambda/params.rb | 18 ++++++++++++++++++ 8 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 scenario/lambda/body.rb create mode 100644 scenario/lambda/class_in_body.rb create mode 100644 scenario/lambda/escape.rb create mode 100644 scenario/lambda/not_a_call.rb create mode 100644 scenario/lambda/params.rb diff --git a/lib/typeprof/core/ast/call.rb b/lib/typeprof/core/ast/call.rb index 2c03df582..e3415f177 100644 --- a/lib/typeprof/core/ast/call.rb +++ b/lib/typeprof/core/ast/call.rb @@ -7,7 +7,9 @@ def initialize(raw_node, lenv, mid) @tbl = raw_node.locals ncref = CRef.new(lenv.cref.cpath, :instance, mid, lenv.cref) - nlenv = LocalEnv.new(lenv.file_context, ncref, {}, lenv.return_boxes) + # A `return` in a block exits the enclosing method, so the body writes into + # its return boxes. A lambda's `return` exits the lambda, so it gets its own. + nlenv = LocalEnv.new(lenv.file_context, ncref, {}, lambda? ? [] : lenv.return_boxes) @f_args = [] @multi_targets = {} @@ -66,9 +68,19 @@ def install0(genv) end blenv.locals[:"*expected_block_ret"] = Vertex.new(self) + # Present already when the lambda sits in a method; a top-level one still + # needs it, or ReturnNode drops the returned value on the floor. + blenv.locals[:"*expected_method_ret"] ||= Vertex.new(self) if lambda? @body.install(genv) blenv.add_next_box(@changes.add_escape_box(genv, @body.ret)) + if lambda? + # `return` and `break` leave the lambda itself, so they reach the caller + # of #call the same way the body's own value does. + blenv.return_boxes.each {|box| blenv.add_next_box(box) } + blenv.add_next_box(@changes.add_escape_box(genv, blenv.break_vtx)) if blenv.break_vtx + end + vars.each do |var| @changes.add_edge(genv, blenv.get_var(var), @lenv.get_var(var)) end @@ -89,7 +101,11 @@ def modified_vars(tbl, vars) super(tbl - @tbl, vars) end - def break_vtx = @body.lenv.break_vtx + # A block's `break` leaves the method that yielded, so the call it belongs to + # takes the value; a lambda's `break` leaves the lambda and is wired above. + def lambda? = false + + def break_vtx = lambda? ? nil : @body.lenv.break_vtx def ret_code_range = @body.ret_code_range end diff --git a/lib/typeprof/core/ast/value.rb b/lib/typeprof/core/ast/value.rb index a90e26f64..52e1d407e 100644 --- a/lib/typeprof/core/ast/value.rb +++ b/lib/typeprof/core/ast/value.rb @@ -326,10 +326,15 @@ def install0(genv) end end - class LambdaNode < Node - def install0(genv) - Source.new(genv.proc_type) + # Prism::LambdaNode has the same locals/parameters/body as Prism::BlockNode. + # A lambda literal is a block without a call: `->` never dispatches to a + # user-defined `lambda` method. + class LambdaNode < BlockNode + def initialize(raw_node, lenv) + super(raw_node, lenv, lenv.cref.mid) end + + def lambda? = true end end end diff --git a/scenario/lambda/basic1.rb b/scenario/lambda/basic1.rb index d3818120e..5feec977d 100644 --- a/scenario/lambda/basic1.rb +++ b/scenario/lambda/basic1.rb @@ -16,5 +16,5 @@ def foo ## assert class Object - def foo: -> untyped + def foo: -> Integer end diff --git a/scenario/lambda/body.rb b/scenario/lambda/body.rb new file mode 100644 index 000000000..0212aa8d6 --- /dev/null +++ b/scenario/lambda/body.rb @@ -0,0 +1,7 @@ +## update +f = -> { undefined_in_arrow } +g = lambda { undefined_in_lambda } + +## diagnostics +(1,9)-(1,27): undefined method: Object#undefined_in_arrow +(2,13)-(2,32): undefined method: Object#undefined_in_lambda diff --git a/scenario/lambda/class_in_body.rb b/scenario/lambda/class_in_body.rb new file mode 100644 index 000000000..c79e1ef8d --- /dev/null +++ b/scenario/lambda/class_in_body.rb @@ -0,0 +1,17 @@ +## update +REG = {} +REG["a"] = -> { + class Foo + def bar = 1 + end +} +def foo = Foo.new.bar + +## assert +REG: { } +class Foo + def bar: -> Integer +end +class Object + def foo: -> Integer +end diff --git a/scenario/lambda/escape.rb b/scenario/lambda/escape.rb new file mode 100644 index 000000000..df55ed0cb --- /dev/null +++ b/scenario/lambda/escape.rb @@ -0,0 +1,24 @@ +## update +def foo + f = -> { return 1 } + f.call +end + +def bar + g = -> { break "str" } + g.call +end + +def baz + # The return of a block nested in a lambda leaves the lambda, not baz + h = -> { [1].each { return :sym }; 1.0 } + h.call + nil +end + +## assert +class Object + def foo: -> Integer + def bar: -> String + def baz: -> nil +end diff --git a/scenario/lambda/not_a_call.rb b/scenario/lambda/not_a_call.rb new file mode 100644 index 000000000..396e7fa81 --- /dev/null +++ b/scenario/lambda/not_a_call.rb @@ -0,0 +1,17 @@ +## update +def lambda(&b) = 42 +def f = -> { 1 } +class C + def lambda = "str" + def g = -> { } +end + +## assert +class Object + def lambda: -> Integer + def f: -> Proc +end +class C + def lambda: -> String + def g: -> Proc +end diff --git a/scenario/lambda/params.rb b/scenario/lambda/params.rb new file mode 100644 index 000000000..73bd02a9d --- /dev/null +++ b/scenario/lambda/params.rb @@ -0,0 +1,18 @@ +## update +def foo + x = 1 + f = ->(a, b = "str"; c) { a.undefined_a; b.undefined_b; c.undefined_c; x.undefined_x } + g = ->(a) do a.undefined_in_do end + h = -> { _1.undefined_numbered } + f +end + +## diagnostics +(3,45)-(3,56): undefined method: String#undefined_b +(3,60)-(3,71): undefined method: nil#undefined_c +(3,75)-(3,86): undefined method: Integer#undefined_x + +## assert +class Object + def foo: -> Proc +end From 48cd3a5b0a62b261126e0c9e2838a199d8efdf9c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 21:22:48 +0000 Subject: [PATCH 3/4] Bind the arguments of a lambda call like a method's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lambda literal carried a Block, which models what a yielding method hands to a block: positionals only. So a lambda whose parameters a block cannot express — rest, post, keywords — bound nothing, and the body read those parameters as nil. Its arity was not checked either, and a sole array argument was deconstructed over the parameters the way a block deconstructs one, which a lambda does not do. A lambda is entered like a method, so it now carries the formals a method carries and Proc#call binds against them. The binding itself is the one a method definition already used: pass_arguments moves off MethodDefBox onto FormalArguments, which is what both now hold. Proc#call already received the whole ActualArguments and passed on only the positionals, so the keywords and splat flags were there all along. Co-Authored-By: Claude Opus 5 --- lib/typeprof/core/ast/call.rb | 36 ++++++-- lib/typeprof/core/ast/value.rb | 38 ++++++++ lib/typeprof/core/builtin.rb | 2 +- lib/typeprof/core/env/method.rb | 154 +++++++++++++++++++++++++++++++- lib/typeprof/core/graph/box.rb | 126 +------------------------- scenario/lambda/arity.rb | 26 ++++++ scenario/lambda/call_args.rb | 27 ++++++ 7 files changed, 274 insertions(+), 135 deletions(-) create mode 100644 scenario/lambda/arity.rb create mode 100644 scenario/lambda/call_args.rb diff --git a/lib/typeprof/core/ast/call.rb b/lib/typeprof/core/ast/call.rb index e3415f177..8e569d690 100644 --- a/lib/typeprof/core/ast/call.rb +++ b/lib/typeprof/core/ast/call.rb @@ -11,6 +11,9 @@ def initialize(raw_node, lenv, mid) # its return boxes. A lambda's `return` exits the lambda, so it gets its own. nlenv = LocalEnv.new(lenv.file_context, ncref, {}, lambda? ? [] : lenv.return_boxes) + # parse_params with no parameters returns the canonical empty set, so the + # readers below never have to ask whether there were any. + @params = AST.parse_params(@tbl, nil, nlenv) @f_args = [] @multi_targets = {} @opt_positional_defaults = [] @@ -18,10 +21,10 @@ def initialize(raw_node, lenv, mid) when Prism::BlockParametersNode # `{ || ... }` (empty pipes) and `{ |; x| ... }` (block-local-only) # yield BlockParametersNode whose inner `parameters` is nil. - h = AST.parse_params(@tbl, raw_node.parameters.parameters, nlenv) - @f_args = h[:req_positionals] + h[:opt_positionals] - @multi_targets = h[:req_multi_targets] - @opt_positional_defaults = h[:opt_positional_defaults] + @params = AST.parse_params(@tbl, raw_node.parameters.parameters, nlenv) + @f_args = @params[:req_positionals] + @params[:opt_positionals] + @multi_targets = @params[:req_multi_targets] + @opt_positional_defaults = @params[:opt_positional_defaults] when Prism::NumberedParametersNode @f_args = 1.upto(raw_node.parameters.maximum).map {|n| :"_#{n}" } when Prism::ItParametersNode @@ -35,8 +38,24 @@ def initialize(raw_node, lenv, mid) attr_reader :tbl, :f_args, :opt_positional_defaults, :body + # FormalArguments carries vertices; the keyword names stay on the node, which + # is where FormalArguments#pass_arguments looks them up. + def req_keywords = @params[:req_keywords] + def opt_keywords = @params[:opt_keywords] + def rest_keywords = @params[:rest_keywords] + def opt_keyword_defaults = @params[:opt_keyword_defaults] + def subnodes = { opt_positional_defaults:, body: } - def attrs = { tbl:, f_args: } + # f_args covers only the parameters a block binds, so the rest have to be + # compared too or an edit that only touches them looks like no edit at all. + def attrs = { tbl:, f_args:, formal_names: } + + def formal_names + @params.values_at( + :req_positionals, :opt_positionals, :rest_positionals, :post_positionals, + :req_keywords, :opt_keywords, :rest_keywords, :block, + ) + end def install0(genv) blenv = @body.lenv @@ -53,6 +72,7 @@ def install0(genv) end install_multi_targets(genv, @multi_targets, f_args, blenv) + formals = build_formals(genv, blenv, f_args) @lenv.locals.each do |var, vtx| blenv.set_var(var, vtx) @@ -91,10 +111,14 @@ def install0(genv) elem_vtx = @changes.add_splat_box(genv, f_ary_arg, i).ret @changes.add_edge(genv, elem_vtx, f_arg) end - block = Block.new(self, f_ary_arg, f_args, blenv.next_boxes) + block = Block.new(self, f_ary_arg, f_args, blenv.next_boxes, formals) Source.new(Type::Proc.new(genv, block)) end + # A block is yielded to, and what a yielding method passes is the positional + # list alone; there are no formals to bind beyond it. + def build_formals(genv, blenv, f_args) = nil + # Block-local variables shadow the outer ones, so writes to them are not # modifications of the enclosing scope. def modified_vars(tbl, vars) diff --git a/lib/typeprof/core/ast/value.rb b/lib/typeprof/core/ast/value.rb index 52e1d407e..b8604acf2 100644 --- a/lib/typeprof/core/ast/value.rb +++ b/lib/typeprof/core/ast/value.rb @@ -335,6 +335,44 @@ def initialize(raw_node, lenv) end def lambda? = true + + def subnodes = { opt_positional_defaults:, body:, opt_keyword_defaults: } + + # A lambda is entered like a method, so every parameter kind binds, not just + # the positionals a block is handed. + def build_formals(genv, blenv, f_args) + # f_args is the block-shaped list, required then optional; numbered and `it` + # parameters put names there that @params does not carry. + req_count = f_args.size - @opt_positional_defaults.size + rest = new_formal(blenv, @params[:rest_positionals]) + post = @params[:post_positionals].map {|v| blenv.new_var(v, self) } + req_keywords = self.req_keywords.map {|v| blenv.new_var(v, self) } + opt_keywords = self.opt_keywords.map {|v| blenv.new_var(v, self) } + rest_keywords = new_formal(blenv, self.rest_keywords) + block = new_formal(blenv, @params[:block]) + + if rest + @changes.add_edge(genv, Source.new(genv.gen_ary_type(Vertex.new(self))), rest) + end + # Only the anonymous `**` needs seeding, as it does for a method: a named + # one takes its type from what the call passes. + if self.rest_keywords == :"**anonymous_keyword" + @changes.add_edge(genv, Source.new(genv.gen_hash_type(Vertex.new(self), Vertex.new(self))), rest_keywords) + end + install_multi_targets(genv, @params[:post_multi_targets], post, blenv) + opt_keyword_defaults.zip(opt_keywords) do |expr, vtx| + @changes.add_edge(genv, expr.install(genv), vtx) + end + + FormalArguments.new( + f_args[0, req_count], f_args[req_count..], rest, post, + req_keywords, opt_keywords, rest_keywords, block, + ) + end + + private + + def new_formal(blenv, name) = name ? blenv.new_var(name, self) : nil end end end diff --git a/lib/typeprof/core/builtin.rb b/lib/typeprof/core/builtin.rb index cafaa19ab..a33def99a 100644 --- a/lib/typeprof/core/builtin.rb +++ b/lib/typeprof/core/builtin.rb @@ -25,7 +25,7 @@ def object_class(changes, node, ty, a_args, ret) def proc_call(changes, node, ty, a_args, ret) case ty when Type::Proc - ty.block.accept_args(@genv, changes, a_args.positionals) + ty.block.pass_arguments(@genv, changes, a_args) ty.block.add_ret(@genv, changes, ret) true else diff --git a/lib/typeprof/core/env/method.rb b/lib/typeprof/core/env/method.rb index 2d7d77e17..b23cdc75e 100644 --- a/lib/typeprof/core/env/method.rb +++ b/lib/typeprof/core/env/method.rb @@ -21,6 +21,136 @@ def initialize(req_positionals, opt_positionals, rest_positionals, post_position attr_reader :opt_keywords attr_reader :rest_keywords attr_reader :block + + # Binds a call's actual arguments to these formals. `node` supplies the + # keyword names, which live on the AST node rather than in the vertices. + def pass_arguments(changes, genv, a_args, node) + if a_args.splat_flags.any? + # there is at least one splat actual argument + + lower = @req_positionals.size + @post_positionals.size + upper = @rest_positionals ? nil : lower + @opt_positionals.size + if upper && upper < a_args.positionals.size + meth = changes.node.mid_code_range ? :mid_code_range : :code_range + err = "#{ a_args.positionals.size } for #{ lower }#{ upper ? lower < upper ? "...#{ upper }" : "" : "+" }" + changes.add_diagnostic(meth, "wrong number of arguments (#{ err })") + return false + end + + start_rest = [a_args.splat_flags.index(true), @req_positionals.size + @opt_positionals.size].min + end_rest = [a_args.splat_flags.rindex(true) + 1, a_args.positionals.size - @post_positionals.size].max + rest_vtxs = a_args.get_rest_args(genv, changes, start_rest, end_rest) + + @req_positionals.each_with_index do |f_vtx, i| + if i < start_rest + changes.add_edge(genv, a_args.positionals[i], f_vtx) + else + rest_vtxs.each do |vtx| + changes.add_edge(genv, vtx, f_vtx) + end + end + end + @opt_positionals.each_with_index do |f_vtx, i| + i += @req_positionals.size + if i < start_rest + changes.add_edge(genv, a_args.positionals[i], f_vtx) + else + rest_vtxs.each do |vtx| + changes.add_edge(genv, vtx, f_vtx) + end + end + end + @post_positionals.each_with_index do |f_vtx, i| + i += a_args.positionals.size - @post_positionals.size + if end_rest <= i + changes.add_edge(genv, a_args.positionals[i], f_vtx) + else + rest_vtxs.each do |vtx| + changes.add_edge(genv, vtx, f_vtx) + end + end + end + + if @rest_positionals + rest_vtxs.each do |vtx| + @rest_positionals.each_type do |ty| + if ty.is_a?(Type::Instance) && ty.mod == genv.mod_ary && ty.args[0] + changes.add_edge(genv, vtx, ty.args[0]) + end + end + end + end + else + # there is no splat actual argument + + lower = @req_positionals.size + @post_positionals.size + upper = @rest_positionals ? nil : lower + @opt_positionals.size + if a_args.positionals.size < lower || (upper && upper < a_args.positionals.size) + meth = changes.node.mid_code_range ? :mid_code_range : :code_range + err = "#{ a_args.positionals.size } for #{ lower }#{ upper ? lower < upper ? "...#{ upper }" : "" : "+" }" + changes.add_diagnostic(meth, "wrong number of arguments (#{ err })") + return false + end + + @req_positionals.each_with_index do |f_vtx, i| + changes.add_edge(genv, a_args.positionals[i], f_vtx) + end + @post_positionals.each_with_index do |f_vtx, i| + i -= @post_positionals.size + changes.add_edge(genv, a_args.positionals[i], f_vtx) + end + start_rest = @req_positionals.size + end_rest = a_args.positionals.size - @post_positionals.size + i = 0 + while i < @opt_positionals.size && start_rest < end_rest + f_arg = @opt_positionals[i] + changes.add_edge(genv, a_args.positionals[start_rest], f_arg) + i += 1 + start_rest += 1 + end + + if start_rest < end_rest + if @rest_positionals + (start_rest..end_rest-1).each do |i| + @rest_positionals.each_type do |ty| + if ty.is_a?(Type::Instance) && ty.mod == genv.mod_ary && ty.args[0] + changes.add_edge(genv, a_args.positionals[i], ty.args[0]) + end + end + end + end + end + end + + if a_args.keywords + # TODO: support diagnostics + node.req_keywords.zip(@req_keywords) do |name, f_vtx| + changes.add_edge(genv, a_args.get_keyword_arg(genv, changes, name), f_vtx) + end + + node.opt_keywords.zip(@opt_keywords).each do |name, f_vtx| + changes.add_edge(genv, a_args.get_keyword_arg(genv, changes, name), f_vtx) + end + + if node.rest_keywords + named_keys = node.req_keywords + node.opt_keywords + a_args.keywords.each_type do |kw_ty| + case kw_ty + when Type::Record + rest_fields = kw_ty.fields.reject {|key, _| named_keys.include?(key) } + base = kw_ty.base_type(genv) + rest_record = Type::Record.new(genv, rest_fields, base) + changes.add_edge(genv, Source.new(rest_record), @rest_keywords) + when Type::Hash, Type::Instance + changes.add_edge(genv, Source.new(kw_ty), @rest_keywords) + end + end + end + end + + return true + end + end class ActualArguments @@ -424,18 +554,32 @@ def build_keyword_args(genv, changes, node) end class Block - #: (AST::BlockNode, Vertex, Array[Vertex], Array[EscapeBox]) -> void - def initialize(node, f_ary_arg, f_args, next_boxes) + #: (AST::BlockNode, Vertex, Array[Vertex], Array[EscapeBox], FormalArguments?) -> void + def initialize(node, f_ary_arg, f_args, next_boxes, formals = nil) @node = node @f_ary_arg = f_ary_arg @f_args = f_args @next_boxes = next_boxes + # Set when the body is entered like a method rather than yielded to, which + # is to say for a lambda: the full formals then bind the call's arguments. + @formals = formals end attr_reader :node, :f_args, :next_boxes + # The arguments of a call that enters this body directly, as Proc#call does. + def pass_arguments(genv, changes, a_args) + if @formals + @formals.pass_arguments(changes, genv, a_args, @node) + else + accept_args(genv, changes, a_args.positionals) + end + end + def accept_args(genv, changes, caller_positionals) - if caller_positionals.size == 1 && @f_args.size >= 2 + if caller_positionals.size == 1 && @f_args.size >= 2 && !@formals + # A block deconstructs a sole array argument over its parameters; a + # lambda takes it as the one argument it is. changes.add_edge(genv, caller_positionals[0], @f_ary_arg) else caller_positionals.zip(@f_args) do |a_arg, f_arg| @@ -452,6 +596,10 @@ def add_ret(genv, changes, ret) end class RecordBlock + def pass_arguments(genv, changes, a_args) + accept_args(genv, changes, a_args.positionals) + end + def initialize(node) @node = node @used = false diff --git a/lib/typeprof/core/graph/box.rb b/lib/typeprof/core/graph/box.rb index 0171517ba..8d531caff 100644 --- a/lib/typeprof/core/graph/box.rb +++ b/lib/typeprof/core/graph/box.rb @@ -825,132 +825,8 @@ def run0(genv, changes) end def pass_arguments(changes, genv, a_args) - if a_args.splat_flags.any? - # there is at least one splat actual argument - - lower = @f_args.req_positionals.size + @f_args.post_positionals.size - upper = @f_args.rest_positionals ? nil : lower + @f_args.opt_positionals.size - if upper && upper < a_args.positionals.size - meth = changes.node.mid_code_range ? :mid_code_range : :code_range - err = "#{ a_args.positionals.size } for #{ lower }#{ upper ? lower < upper ? "...#{ upper }" : "" : "+" }" - changes.add_diagnostic(meth, "wrong number of arguments (#{ err })") - return false - end - - start_rest = [a_args.splat_flags.index(true), @f_args.req_positionals.size + @f_args.opt_positionals.size].min - end_rest = [a_args.splat_flags.rindex(true) + 1, a_args.positionals.size - @f_args.post_positionals.size].max - rest_vtxs = a_args.get_rest_args(genv, changes, start_rest, end_rest) - - @f_args.req_positionals.each_with_index do |f_vtx, i| - if i < start_rest - changes.add_edge(genv, a_args.positionals[i], f_vtx) - else - rest_vtxs.each do |vtx| - changes.add_edge(genv, vtx, f_vtx) - end - end - end - @f_args.opt_positionals.each_with_index do |f_vtx, i| - i += @f_args.req_positionals.size - if i < start_rest - changes.add_edge(genv, a_args.positionals[i], f_vtx) - else - rest_vtxs.each do |vtx| - changes.add_edge(genv, vtx, f_vtx) - end - end - end - @f_args.post_positionals.each_with_index do |f_vtx, i| - i += a_args.positionals.size - @f_args.post_positionals.size - if end_rest <= i - changes.add_edge(genv, a_args.positionals[i], f_vtx) - else - rest_vtxs.each do |vtx| - changes.add_edge(genv, vtx, f_vtx) - end - end - end - - if @f_args.rest_positionals - rest_vtxs.each do |vtx| - @f_args.rest_positionals.each_type do |ty| - if ty.is_a?(Type::Instance) && ty.mod == genv.mod_ary && ty.args[0] - changes.add_edge(genv, vtx, ty.args[0]) - end - end - end - end - else - # there is no splat actual argument - - lower = @f_args.req_positionals.size + @f_args.post_positionals.size - upper = @f_args.rest_positionals ? nil : lower + @f_args.opt_positionals.size - if a_args.positionals.size < lower || (upper && upper < a_args.positionals.size) - meth = changes.node.mid_code_range ? :mid_code_range : :code_range - err = "#{ a_args.positionals.size } for #{ lower }#{ upper ? lower < upper ? "...#{ upper }" : "" : "+" }" - changes.add_diagnostic(meth, "wrong number of arguments (#{ err })") - return false - end - - @f_args.req_positionals.each_with_index do |f_vtx, i| - changes.add_edge(genv, a_args.positionals[i], f_vtx) - end - @f_args.post_positionals.each_with_index do |f_vtx, i| - i -= @f_args.post_positionals.size - changes.add_edge(genv, a_args.positionals[i], f_vtx) - end - start_rest = @f_args.req_positionals.size - end_rest = a_args.positionals.size - @f_args.post_positionals.size - i = 0 - while i < @f_args.opt_positionals.size && start_rest < end_rest - f_arg = @f_args.opt_positionals[i] - changes.add_edge(genv, a_args.positionals[start_rest], f_arg) - i += 1 - start_rest += 1 - end - - if start_rest < end_rest - if @f_args.rest_positionals - (start_rest..end_rest-1).each do |i| - @f_args.rest_positionals.each_type do |ty| - if ty.is_a?(Type::Instance) && ty.mod == genv.mod_ary && ty.args[0] - changes.add_edge(genv, a_args.positionals[i], ty.args[0]) - end - end - end - end - end - end - - if a_args.keywords - # TODO: support diagnostics - @node.req_keywords.zip(@f_args.req_keywords) do |name, f_vtx| - changes.add_edge(genv, a_args.get_keyword_arg(genv, changes, name), f_vtx) - end - - @node.opt_keywords.zip(@f_args.opt_keywords).each do |name, f_vtx| - changes.add_edge(genv, a_args.get_keyword_arg(genv, changes, name), f_vtx) - end - - if @node.rest_keywords - named_keys = @node.req_keywords + @node.opt_keywords - a_args.keywords.each_type do |kw_ty| - case kw_ty - when Type::Record - rest_fields = kw_ty.fields.reject {|key, _| named_keys.include?(key) } - base = kw_ty.base_type(genv) - rest_record = Type::Record.new(genv, rest_fields, base) - changes.add_edge(genv, Source.new(rest_record), @f_args.rest_keywords) - when Type::Hash, Type::Instance - changes.add_edge(genv, Source.new(kw_ty), @f_args.rest_keywords) - end - end - end - end - - return true + @f_args.pass_arguments(changes, genv, a_args, @node) end - def normalize_keyword_hash_argument_for_def(a_args) return a_args unless a_args.keywords return a_args if @node.no_keywords diff --git a/scenario/lambda/arity.rb b/scenario/lambda/arity.rb new file mode 100644 index 000000000..eaa7a8bd5 --- /dev/null +++ b/scenario/lambda/arity.rb @@ -0,0 +1,26 @@ +## update +def too_few = ->(x, y) { x }.call(1) +too_few + +def too_many = ->(x) { x }.call(1, 2) +too_many + +# an array is one argument to a lambda, not a list to spread over its parameters +def no_autosplat = ->(x, y) { x }.call([1, "str"]) +no_autosplat + +def optional_ok = ->(x, y = 2) { y }.call(1) +optional_ok + +## diagnostics +(1,29)-(1,33): wrong number of arguments (1 for 2) +(4,27)-(4,31): wrong number of arguments (2 for 1) +(8,34)-(8,38): wrong number of arguments (1 for 2) + +## assert +class Object + def too_few: -> untyped + def too_many: -> untyped + def no_autosplat: -> untyped + def optional_ok: -> Integer +end diff --git a/scenario/lambda/call_args.rb b/scenario/lambda/call_args.rb new file mode 100644 index 000000000..5b2982b58 --- /dev/null +++ b/scenario/lambda/call_args.rb @@ -0,0 +1,27 @@ +## update +def rest = ->(*x) { x }.call(1, "str") +rest + +def lead_and_rest = ->(x, *y) { y }.call(1, 2, 3) +lead_and_rest + +def post = ->(x, *y, z) { z }.call(1, 2, :sym) +post + +def keywords = ->(k: 1) { k }.call(k: "str") +keywords + +def rest_keywords = ->(**kw) { kw }.call(a: 1) +rest_keywords + +def block_param = ->(&b) { b }.call + +## assert +class Object + def rest: -> Array[Integer | String] + def lead_and_rest: -> Array[Integer] + def post: -> :sym + def keywords: -> (Integer | String) + def rest_keywords: -> { a: Integer } + def block_param: -> untyped +end From 46e0689689c92ed7686943e04ce52c9f48e3fadb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 23:16:55 +0000 Subject: [PATCH 4/4] Handle a block parameter list ending in a comma Extracting the block parameters into BlockNode routed them through parse_params, which reads the rest parameter. The previous block-only code read just the requireds and the optionals, so it never met the node `{ |a,| }` puts there: Prism::ImplicitRestNode, which has no #name. A trailing comma is the only way to write a rest without naming it in a block, and it cannot appear in a method definition or a lambda literal, where it is a syntax error. Co-Authored-By: Claude Opus 5 --- lib/typeprof/core/ast/method.rb | 12 +++++++++++- scenario/block/trailing_comma_param.rb | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 scenario/block/trailing_comma_param.rb diff --git a/lib/typeprof/core/ast/method.rb b/lib/typeprof/core/ast/method.rb index 27a54227c..d6f33d704 100644 --- a/lib/typeprof/core/ast/method.rb +++ b/lib/typeprof/core/ast/method.rb @@ -77,7 +77,17 @@ def self.parse_params(tbl, raw_args, lenv) end end - rest_positionals = raw_args.rest ? (raw_args.rest.name || :"*anonymous_rest") : nil + rest_positionals = + case raw_args.rest + when nil + nil + when Prism::ImplicitRestNode + # `{ |a,| }`. The trailing comma says there is a rest without naming + # it, so the node carries no name to ask for. + :"*anonymous_rest" + else + raw_args.rest.name || :"*anonymous_rest" + end req_keywords = [] opt_keywords = [] diff --git a/scenario/block/trailing_comma_param.rb b/scenario/block/trailing_comma_param.rb new file mode 100644 index 000000000..f462e6a46 --- /dev/null +++ b/scenario/block/trailing_comma_param.rb @@ -0,0 +1,17 @@ +## update +def yield_values + yield 1, 2 +end + +def check + yield_values do |x,| + return x + end + nil +end + +## assert +class Object + def yield_values: { (Integer, Integer) -> bot } -> bot + def check: -> Integer? +end