Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NodePattern: Add Constants and keyword parameters #35

Merged
merged 2 commits into from Jun 26, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -13,6 +13,7 @@
* [#41](https://github.com/rubocop-hq/rubocop-ast/pull/41): Add `delimiters` and related predicates for `RegexpNode`. ([@owst][])
* [#46](https://github.com/rubocop-hq/rubocop-ast/pull/46): Basic support for [non-legacy AST output from parser](https://github.com/whitequark/parser/#usage). Note that there is no support (yet) in main RuboCop gem. ([@marcandre][])
* [#48](https://github.com/rubocop-hq/rubocop-ast/pull/48): Support `Parser::Ruby28` for Ruby 2.8 (3.0) parser. ([@koic][])
* [#35](https://github.com/rubocop-hq/rubocop-ast/pull/35): NodePattern now accepts `%named_param` and `%CONST`. The macros `def_node_pattern` and `def_node_search` accept default named parameters. ([@marcandre][])

## 0.0.3 (2020-05-15)

Expand Down
40 changes: 40 additions & 0 deletions docs/modules/ROOT/pages/node_pattern.adoc
Expand Up @@ -413,6 +413,46 @@ NOTE: `Array#===` will never match a single node element (so don't pass arrays),
but `Set#===` is an alias to `Set#include?` (Ruby 2.5+ only), and so can be
very useful to match within many possible literals / Nodes.

== `%param_name` for named parameters

Arguments can be passed as named parameters. They will be matched using `===`
(see `%` above).

Contrary to positional arguments, 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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be a good idea to accept arrays here as well and just implicitly convert them to sets. Seems to me this will result in nicer end user API, if it's more flexible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't you aware of my machiavelic plan to convert the whole world to Sets? ;-)

I like that currently the API is simple, we use <your object> === <node element>

It would be easy to convert the values to Sets at the level of def_node_matcher, but it would be a bad idea to do it at the matcher call level, so I'm afraid there would be confusion...

I should add a note in the doc that Array#=== will never work on a AST element.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

: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 also supported.

== `%CONST` for constants

Constants can be included in patterns. They will be matched using `===`, so
+Regexp+ / +Set+ / +Proc+ can be used in addition to literals and +Nodes+:

[source,ruby]
----
SOME_CALLS = Set[:transform_values, :transform_keys,
:transform_values!, :transform_keys!,
:to_h].freeze

def_node_matcher :interesting_call?, '(send _ %SOME_CALLS ...)'

----

Constants as arguments to custom methods are also supported.

== `nil` or `nil?`

Take a special attention to nil behavior:
Expand Down
98 changes: 76 additions & 22 deletions lib/rubocop/ast/node_pattern.rb
Expand Up @@ -82,6 +82,11 @@ module AST
# # for consistency, %0 is the 'root node' which is
# # passed as the 1st argument to #match, where the
# # matching process starts
# '(send _ %named)' # arguments can also be passed as named
# # parameters (see `%1`)
# # Note that the macros `def_node_pattern` and
# # `def_node_search` accept default values for these.
# '(send _ %CONST)' # the named constant will act like `%1` and `%named`.
# '^^send' # each ^ ascends one level in the AST
# # so this matches against the grandparent node
# '`send' # descends any number of level in the AST
Expand Down Expand Up @@ -125,10 +130,12 @@ class Compiler
NUMBER = /-?\d+(?:\.\d+)?/.freeze
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_NUMBER, NUMBER,
TOKENS = Regexp.union(META, PARAM_CONST, KEYWORD_NAME, PARAM_NUMBER, NUMBER,
METHOD_NAME, SYMBOL, STRING)

TOKEN = /\G(?:#{SEPARATORS}|#{TOKENS}|.)/.freeze
Expand All @@ -140,6 +147,8 @@ class Compiler
FUNCALL = /\A\##{METHOD_NAME}/.freeze
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 @@ -198,6 +207,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 @@ -237,6 +247,8 @@ 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")
when nil then fail_due_to('pattern ended prematurely')
Expand Down Expand Up @@ -620,6 +632,14 @@ def compile_param(number)
"#{get_param(number)} === #{CUR_ELEMENT}"
end

def compile_const(const)
"#{get_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 All @@ -631,12 +651,14 @@ def compile_args(tokens)
end

def compile_arg(token)
name = token[1..-1]
case token
when WILDCARD then
name = token[1..-1]
when WILDCARD
access_unify(name) || fail_due_to('invalid in arglist: ' + token)
when LITERAL then token
when PARAM then get_param(token[1..-1])
when KEYWORD then get_keyword(name)
when CONST then get_const(name)
when PARAM then get_param(name)
when CLOSING then fail_due_to("#{token} in invalid position")
when nil then fail_due_to('pattern ended prematurely')
else fail_due_to("invalid token in arglist: #{token.inspect}")
Expand All @@ -655,6 +677,15 @@ def get_param(number)
number.zero? ? @root : "param#{number}"
end

def get_keyword(name)
@keywords << name
name
end

def get_const(const)
const # Output the constant exactly as given
end

def emit_yield_capture(when_no_capture = '')
yield_val = if @captures.zero?
when_no_capture
Expand All @@ -680,9 +711,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 @@ -759,21 +796,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)
Expand All @@ -782,7 +830,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 @@ -814,8 +862,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,
Expand All @@ -824,8 +873,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

Expand All @@ -839,11 +889,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)
Expand Down