From eb9034876e21491d1df1cb860001e1ba1248197e Mon Sep 17 00:00:00 2001 From: Marc-Andre Lafortune Date: Sat, 13 Jun 2020 21:46:33 -0400 Subject: [PATCH] Add named params to NodePattern --- lib/rubocop/ast/node_pattern.rb | 35 ++++++++++++++--- spec/rubocop/ast/node_pattern_spec.rb | 56 ++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/lib/rubocop/ast/node_pattern.rb b/lib/rubocop/ast/node_pattern.rb index 915d543cb..771c0c238 100644 --- a/lib/rubocop/ast/node_pattern.rb +++ b/lib/rubocop/ast/node_pattern.rb @@ -125,10 +125,11 @@ class Compiler STRING = /".+?"/.freeze METHOD_NAME = /\#?#{IDENTIFIER}[!?]?\(?/.freeze PARAM_CONST = /%[A-Z:][a-zA-Z_:]+/.freeze + KEYWORD_NAME = /%[a-z_]+/.freeze PARAM_NUMBER = /%\d*/.freeze SEPARATORS = /\s+/.freeze - TOKENS = Regexp.union(META, PARAM_CONST, PARAM_NUMBER, NUMBER, + TOKENS = Regexp.union(META, PARAM_CONST, KEYWORD_NAME, PARAM_NUMBER, NUMBER, METHOD_NAME, SYMBOL, STRING) TOKEN = /\G(?:#{SEPARATORS}|#{TOKENS}|.)/.freeze @@ -141,6 +142,7 @@ class Compiler LITERAL = /\A(?:#{SYMBOL}|#{NUMBER}|#{STRING})\Z/.freeze PARAM = /\A#{PARAM_NUMBER}\Z/.freeze CONST = /\A#{PARAM_CONST}\Z/.freeze + KEYWORD = /\A#{KEYWORD_NAME}\Z/.freeze CLOSING = /\A(?:\)|\}|\])\Z/.freeze REST = '...' @@ -199,6 +201,7 @@ def initialize(str, node_var = 'node0') @captures = 0 # number of captures seen @unify = {} # named wildcard -> temp variable @params = 0 # highest % (param) number seen + @keywords = Set[] # keyword parameters seen run(node_var) end @@ -238,6 +241,7 @@ def compile_expr(token = tokens.shift) when LITERAL then compile_literal(token) when PREDICATE then compile_predicate(token) when NODE then compile_nodetype(token) + when KEYWORD then compile_keyword(token[1..-1]) when CONST then compile_const(token[1..-1]) when PARAM then compile_param(token[1..-1]) when CLOSING then fail_due_to("#{token} in invalid position") @@ -626,6 +630,10 @@ def compile_const(const) "#{const} === #{CUR_ELEMENT}" end + def compile_keyword(keyword) + "#{get_keyword(keyword)} === #{CUR_ELEMENT}" + end + def compile_args(tokens) index = tokens.find_index { |token| token == ')' } @@ -661,6 +669,11 @@ def get_param(number) number.zero? ? @root : "param#{number}" end + def get_keyword(name) + @keywords << name + name + end + def emit_yield_capture(when_no_capture = '') yield_val = if @captures.zero? when_no_capture @@ -686,9 +699,15 @@ def emit_param_list (1..@params).map { |n| "param#{n}" }.join(',') end - def emit_trailing_params + def emit_keyword_list(forwarding: false) + pattern = "%s: #{'%s' if forwarding}" + @keywords.map { |k| format(pattern, keyword: k) }.join(',') + end + + def emit_trailing_params(forwarding: false) params = emit_param_list - params.empty? ? '' : ",#{params}" + keywords = emit_keyword_list(forwarding: forwarding) + [params, keywords].reject(&:empty?).map { |p| ", #{p}" }.join end def emit_method_code @@ -788,7 +807,7 @@ def emit_node_search(method_name) else prelude = <<~RUBY return enum_for(:#{method_name}, - node0#{emit_trailing_params}) unless block_given? + node0#{emit_trailing_params(forwarding: true)}) unless block_given? RUBY on_match = emit_yield_capture('node') end @@ -845,11 +864,15 @@ def initialize(str) instance_eval(src, __FILE__, __LINE__ + 1) end - def match(*args) + def match(*args, **rest) # If we're here, it's because the singleton method has not been defined, # either because we've been dup'ed or serialized through YAML initialize(pattern) - match(*args) + if rest.empty? + match(*args) + else + match(*args, **rest) + end end def marshal_load(pattern) diff --git a/spec/rubocop/ast/node_pattern_spec.rb b/spec/rubocop/ast/node_pattern_spec.rb index 40d3edeb1..cff64e5dc 100644 --- a/spec/rubocop/ast/node_pattern_spec.rb +++ b/spec/rubocop/ast/node_pattern_spec.rb @@ -16,8 +16,15 @@ let(:node) { root_node } let(:params) { [] } + let(:keyword_params) { {} } let(:instance) { described_class.new(pattern) } - let(:result) { instance.match(node, *params) } + let(:result) do + if keyword_params.empty? # Avoid bug in Ruby < 2.6 + instance.match(node, *params) + else + instance.match(node, *params, **keyword_params) + end + end shared_examples 'matching' do include RuboCop::AST::Sexp @@ -1160,6 +1167,35 @@ end end + context 'as named parameters' do + let(:pattern) { '%foo' } + let(:matcher) { Object.new } + let(:keyword_params) { { foo: matcher } } + let(:ruby) { '10' } + + context 'when provided as argument to match' do + before { expect(matcher).to receive(:===).with(s(:int, 10)).and_return true } # rubocop:todo RSpec/ExpectInHook + + it_behaves_like 'matching' + end + + context 'when extra are provided' do + let(:keyword_params) { { foo: matcher, bar: matcher } } + + it 'raises an ArgumentError' do + expect { result }.to raise_error(ArgumentError) + end + end + + context 'when not provided' do + let(:keyword_params) { {} } + + it 'raises an ArgumentError' do + expect { result }.to raise_error(ArgumentError) + end + end + end + context 'in a nested sequence' do let(:pattern) { '(send (send _ %2) %1)' } let(:params) { %i[inc dec] } @@ -1798,7 +1834,13 @@ def withargs(foo, bar, qux) MyClass end let(:ruby) { ':hello' } - let(:result) { defined_class.new.send(method_name, node, *params) } + let(:result) do + if keyword_params.empty? # Avoid bug in Ruby < 2.7 + defined_class.new.send(method_name, node, *params) + else + defined_class.new.send(method_name, node, *params, **keyword_params) + end + end if Set[1] === 1 # rubocop:disable Style/CaseEquality let(:hello_matcher) { Set[:hello, :foo] } @@ -1938,6 +1980,16 @@ def withargs(foo, bar, qux) expect(result.is_a?(Enumerator)).to be(true) expect(result.to_a).to match_array %i[hello world] end + + context 'when the pattern contains keyword_params' do + let(:pattern) { '(sym $%foo)' } + let(:keyword_params) { { foo: hello_matcher } } + + it 'returns an enumerator yielding the captures' do + expect(result.is_a?(Enumerator)).to be(true) + expect(result.to_a).to match_array %i[hello] + end + end end context 'when called on non-matching code' do