diff --git a/docs/modules/ROOT/pages/node_pattern.adoc b/docs/modules/ROOT/pages/node_pattern.adoc index e94588967..6be2605ec 100644 --- a/docs/modules/ROOT/pages/node_pattern.adoc +++ b/docs/modules/ROOT/pages/node_pattern.adoc @@ -409,6 +409,26 @@ has_user_data?(node, /^pass(word)?$/i) has_user_data?(node, ->(key) { # return true or false depending on key }) ---- +== `%keyword` for named parameters + +Arguments can be passed as named parameters. They will be matched using `===`. +Defaults values can be passed to `def_node_matcher` and `def_node_search`. + +[source,ruby] +---- +def_node_matcher :interesting_call?, '(send _ %method ...)', + method: Set[:transform_values, :transform_keys, + :transform_values!, :transform_keys!, + :to_h].freeze + +# Usage: + +interesting_call?(node) # use the default methods +interesting_call?(node, method: /^transform/) # match anything starting with 'transform' +---- + +Named parameters as arguments to custom methods are not supported. + == `%CONST` for constants Constants can be included in patterns. They will be matched using `===`, so diff --git a/lib/rubocop/ast/node_pattern.rb b/lib/rubocop/ast/node_pattern.rb index 771c0c238..8f1a3a2c0 100644 --- a/lib/rubocop/ast/node_pattern.rb +++ b/lib/rubocop/ast/node_pattern.rb @@ -78,6 +78,12 @@ module AST # # for consistency, %0 is the 'root node' which is # # passed as the 1st argument to #match, where the # # matching process starts + # '(send _ %keyword)' # arguments can also be passed as keyword + # # parameters; they will also be compared with + # # the AST using #=== so you can pass Procs, Regexp + # # in addition to Nodes or literals. + # # Note that the macros `def_node_pattern` and + # # `def_node_search` accept default values for these. # '(send _ %CONST)' # the named constant will be compared with # # the AST using #=== so you can pass Procs, Regexp # # in addition to Nodes or literals. @@ -784,21 +790,32 @@ def self.tokens(pattern) pattern.scan(TOKEN).reject { |token| token =~ /\A#{SEPARATORS}\Z/ } end - def def_helper(base, src) + def def_helper(base, method_name, **defaults) location = caller_locations(3, 1).first + unless defaults.empty? + base.send :define_method, method_name do |*args, **values| + send method_name, *args, **defaults, **values + end + method_name = :"without_defaults_#{method_name}" + end + src = yield method_name base.class_eval(src, location.path, location.lineno) end - def def_node_matcher(base, method_name) - def_helper(base, <<~RUBY) - def #{method_name}(node = self#{emit_trailing_params}) - #{emit_method_code} - end - RUBY + def def_node_matcher(base, method_name, **defaults) + def_helper(base, method_name, **defaults) do |name| + <<~RUBY + def #{name}(node = self#{emit_trailing_params}) + #{emit_method_code} + end + RUBY + end end - def def_node_search(base, method_name) - def_helper(base, emit_node_search(method_name)) + def def_node_search(base, method_name, **defaults) + def_helper(base, method_name, **defaults) do |name| + emit_node_search(name) + end end def emit_node_search(method_name) @@ -839,8 +856,9 @@ module Macros # yield to the block (passing any captures as block arguments). # If the node matches, and no block is provided, the new method will # return the captures, or `true` if there were none. - def def_node_matcher(method_name, pattern_str) - Compiler.new(pattern_str, 'node').def_node_matcher(self, method_name) + def def_node_matcher(method_name, pattern_str, **keyword_defaults) + Compiler.new(pattern_str, 'node') + .def_node_matcher(self, method_name, **keyword_defaults) end # Define a method which recurses over the descendants of an AST node, @@ -849,8 +867,9 @@ def def_node_matcher(method_name, pattern_str) # If the method name ends with '?', the new method will return `true` # as soon as it finds a descendant which matches. Otherwise, it will # yield all descendants which match. - def def_node_search(method_name, pattern_str) - Compiler.new(pattern_str, 'node').def_node_search(self, method_name) + def def_node_search(method_name, pattern_str, **keyword_defaults) + Compiler.new(pattern_str, 'node') + .def_node_search(self, method_name, **keyword_defaults) end end diff --git a/spec/rubocop/ast/node_pattern_spec.rb b/spec/rubocop/ast/node_pattern_spec.rb index cff64e5dc..3eb2db534 100644 --- a/spec/rubocop/ast/node_pattern_spec.rb +++ b/spec/rubocop/ast/node_pattern_spec.rb @@ -1827,10 +1827,11 @@ def withargs(foo, bar, qux) end) end + let(:keyword_defaults) { {} } let(:method_name) { :my_matcher } let(:line_no) { __LINE__ + 2 } let(:defined_class) do - MyClass.public_send helper_name, method_name, pattern + MyClass.public_send helper_name, method_name, pattern, **keyword_defaults MyClass end let(:ruby) { ':hello' } @@ -1989,6 +1990,34 @@ def withargs(foo, bar, qux) expect(result.is_a?(Enumerator)).to be(true) expect(result.to_a).to match_array %i[hello] end + + # rubocop:disable RSpec/NestedGroups + context 'when helper is called with default keyword_params' do + let(:keyword_defaults) { { foo: :world } } + + it 'is overriden when calling the matcher' do + expect(result.is_a?(Enumerator)).to be(true) + expect(result.to_a).to match_array %i[hello] + end + + context 'and no value is given to the matcher' do + let(:keyword_params) { {} } + + it 'uses the defaults' do + expect(result.is_a?(Enumerator)).to be(true) + expect(result.to_a).to match_array %i[world] + end + end + + context 'some defaults are not params' do + let(:keyword_defaults) { { bar: :world } } + + it 'raises an error' do + expect { result }.to raise_error(ArgumentError) + end + end + end + # rubocop:enable RSpec/NestedGroups end end