diff --git a/proc.c b/proc.c index 4d9998befb..b1b673501e 100644 --- a/proc.c +++ b/proc.c @@ -3464,6 +3464,70 @@ rb_method_compose_to_right(VALUE self, VALUE g) return proc_compose_to_right(self, g); } +/* + * call-seq: + * proc.ruby2_keywords -> proc + * + * Marks the proc as passing keywords through a normal argument splat. + * This should only be called on procs that accept an argument splat + * (*args) but not explicit keywords or a keyword splat. It + * marks the proc such that if the proc is called with keyword arguments, + * the final hash argument is marked with a special flag such that if it + * is the final element of a normal argument splat to another method call, + * and that method call does not include explicit keywords or a keyword + * splat, the final element is interpreted as keywords. In other words, + * keywords will be passed through the proc to other methods. + * + * This should only be used for procs that delegate keywords to another + * method, and only for backwards compatibility with Ruby versions before + * 2.7. + * + * This method will probably be removed at some point, as it exists only + * for backwards compatibility. As it does not exist in Ruby versions + * before 2.7, check that the proc responds to this method before calling + * it. Also, be aware that if this method is removed, the behavior of the + * proc will change so that it does not pass through keywords. + * + * module Mod + * foo = ->(meth, *args, &block) do + * send(:"do_#{meth}", *args, &block) + * end + * foo.ruby2_keywords if foo.respond_to?(:ruby2_keywords) + * end + */ + +static VALUE +proc_ruby2_keywords(VALUE procval) +{ + rb_proc_t *proc; + GetProcPtr(procval, proc); + + rb_check_frozen(procval); + + if (proc->is_from_method) { + rb_warn("Skipping set of ruby2_keywords flag for proc (proc created from method)"); + return procval; + } + + switch (proc->block.type) { + case block_type_iseq: + if (proc->block.as.captured.code.iseq->body->param.flags.has_rest && + !proc->block.as.captured.code.iseq->body->param.flags.has_kw && + !proc->block.as.captured.code.iseq->body->param.flags.has_kwrest) { + proc->block.as.captured.code.iseq->body->param.flags.ruby2_keywords = 1; + } + else { + rb_warn("Skipping set of ruby2_keywords flag for proc (proc accepts keywords or proc does not accept argument splat)"); + } + break; + default: + rb_warn("Skipping set of ruby2_keywords flag for proc (proc not defined in Ruby)"); + break; + } + + return procval; +} + /* * Document-class: LocalJumpError * @@ -3789,6 +3853,7 @@ Init_Proc(void) rb_define_method(rb_cProc, ">>", proc_compose_to_right, 1); rb_define_method(rb_cProc, "source_location", rb_proc_location, 0); rb_define_method(rb_cProc, "parameters", rb_proc_parameters, 0); + rb_define_method(rb_cProc, "ruby2_keywords", proc_ruby2_keywords, 0); /* Exceptions */ rb_eLocalJumpError = rb_define_class("LocalJumpError", rb_eStandardError); diff --git a/test/ruby/test_keyword.rb b/test/ruby/test_keyword.rb index 295b499530..b0199e0c53 100644 --- a/test/ruby/test_keyword.rb +++ b/test/ruby/test_keyword.rb @@ -2684,6 +2684,45 @@ class TestKeywordArguments < Test::Unit::TestCase assert_raise(ArgumentError) { m.call(42, a: 1, **h2) } end + def test_proc_ruby2_keywords + h1 = {:a=>1} + foo = ->(*args, &block){block.call(*args)} + assert_same(foo, foo.ruby2_keywords) + + assert_equal([[1], h1], foo.call(1, :a=>1, &->(*args, **kw){[args, kw]})) + assert_equal([1, h1], foo.call(1, :a=>1, &->(*args){args})) + assert_warn(/The last argument is used as the keyword parameter/) do + assert_equal([[1], h1], foo.call(1, {:a=>1}, &->(*args, **kw){[args, kw]})) + end + assert_equal([1, h1], foo.call(1, {:a=>1}, &->(*args){args})) + assert_warn(/The keyword argument is passed as the last hash parameter/) do + assert_equal([h1, {}], foo.call(:a=>1, &->(arg, **kw){[arg, kw]})) + end + assert_equal(h1, foo.call(:a=>1, &->(arg){arg})) + + [->(){}, ->(arg){}, ->(*args, **kw){}, ->(*args, k: 1){}, ->(*args, k: ){}].each do |pr| + assert_warn(/Skipping set of ruby2_keywords flag for proc \(proc accepts keywords or proc does not accept argument splat\)/) do + pr.ruby2_keywords + end + end + + o = Object.new + def o.foo(*args) + yield *args + end + foo = o.method(:foo).to_proc + assert_warn(/Skipping set of ruby2_keywords flag for proc \(proc created from method\)/) do + foo.ruby2_keywords + end + + foo = :foo.to_proc + assert_warn(/Skipping set of ruby2_keywords flag for proc \(proc not defined in Ruby\)/) do + foo.ruby2_keywords + end + + assert_raise(FrozenError) { ->(*args){}.freeze.ruby2_keywords } + end + def test_ruby2_keywords c = Class.new do ruby2_keywords def foo(meth, *args)