Skip to content

Commit

Permalink
Add named params to NodePattern
Browse files Browse the repository at this point in the history
  • Loading branch information
marcandre committed Jun 14, 2020
1 parent 3c17d59 commit 03f81bc
Show file tree
Hide file tree
Showing 2 changed files with 79 additions and 8 deletions.
31 changes: 25 additions & 6 deletions lib/rubocop/ast/node_pattern.rb
Expand Up @@ -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
Expand All @@ -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 = '...'
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 == ')' }

Expand Down Expand Up @@ -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
Expand All @@ -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 = "%<keyword>s: #{'%<keyword>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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -845,11 +864,11 @@ 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)
match(*args, **rest)
end

def marshal_load(pattern)
Expand Down
56 changes: 54 additions & 2 deletions spec/rubocop/ast/node_pattern_spec.rb
Expand Up @@ -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
Expand Down Expand Up @@ -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] }
Expand Down Expand Up @@ -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] }
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 03f81bc

Please sign in to comment.