节点模式
节点模式是一种 DSL,它使用简单的字符串来帮助在抽象语法树中查找特定节点。
它让人想起正则表达式的简单性,但用于查找 Ruby 代码的特定节点。
历史
节点模式由 Alex Dowad 引入,它解决了 RuboCop 贡献者长期面临的一个问题
-
能够声明性地定义节点搜索、匹配和捕获的规则。
以下代码属于 Style/ArrayJoin 规则,它更倾向于使用 Array#join
而不是 Array#*
。然后它尝试查找类似 %w(one two three) * ", "
的代码,并建议使用 #join
代替。
它也可以是一个整数数组,代码不会检查它。但是,它会检查发送的参数是否为字符串。
def on_send(node)
receiver_node, method_name, *arg_nodes = *node
return unless receiver_node && receiver_node.array_type? &&
method_name == :* && arg_nodes.first.str_type?
add_offense(node, location: :selector)
end
这段代码在规则中被替换为一个新的匹配器,它与上面的代码做相同的事情。
def_node_matcher :join_candidate?, '(send $array :* $str)'
并且 on_send
方法被简化为一个方法使用。
def on_send(node)
join_candidate?(node) { add_offense(node, location: :selector) }
end
Ruby 抽象语法树 (AST)
解析器将 Ruby 源代码转换为以文本形式表示的树结构。一个简单的整数文字,比如 1
,在 AST 中表示为 (int 1)
。一个带有两个整数文字的方法调用
foo(1, 2)
表示为
(send nil :foo
(int 1)
(int 2)
)
每个节点都用一个序列表示。第一个元素是节点类型。其他元素是子节点。它们是可选的,并且取决于节点类型。例如
-
nil
只是(nil)
-
1
是(int 1)
-
[1]
是(array (int 1))
-
[1, 2]
是(array (int 1) (int 2))
-
foo
是(send nil :foo)
-
foo(1)
是(send nil :foo (int 1))
获取 AST 表示
从命令行使用 ruby-parse
$ ruby-parse --legacy -e 'foo(1)'
(send nil :foo
(int 1))
使用 --legacy ruby-parse 标志来获取 与 RuboCop AST 返回的相同 AST。有一些区别,例如,没有 --legacy ,foo(a: 1) 将返回 kwargs ,而使用 --legacy 它将返回 hash 。
|
(
和 )
用于匹配元素
用括号括起来的多个匹配器将匹配一个节点,该节点的元素分别匹配相应的匹配器,并且顺序依赖。带有两个整数文字的数组的 Ruby 代码,[1, 2]
在 AST 中表示为 (array (int 1) (int 2))
,可以使用 (array int int)
节点模式匹配。
对于一个文字整数,例如 1
的 Ruby 代码,在 AST 中表示为 (int 1)
-
int
节点模式将精确匹配节点,仅查看节点类型 -
(int 1)
节点模式将精确匹配节点 -
(int 2)
节点模式将不匹配
(
和 )
用于嵌套匹配
Ruby 代码中带有两个整数文字作为参数的方法调用,foo(1, 2)
在 AST 中表示为 (send nil :foo (int 1) (int 2))
,可以使用 (send nil? :foo int int)
节点模式匹配。要匹配第一个参数是文字 1
的方法调用,请使用 (send nil? :foo (int 1) int)
。任何作为节点的子节点都可以成为嵌套匹配的目标。
...
用于多个后续节点
_
匹配任何单个节点,...
匹配任意数量的节点。
例如,你想找到对 sum
方法的调用,无论参数数量如何,无论是 sum(1, 2)
还是 sum(1, 2, 3, n)
。首先,让我们检查它在 AST 中的样子
$ ruby-parse -e 'sum(1, 2)'
(send nil :sum
(int 1)
(int 2))
或者更多子节点
$ ruby-parse -e 'sum(1, 2, 3, n)'
(send nil :sum
(int 1)
(int 2)
(int 3)
(send nil :n))
以下表达式将只匹配具有 2 个参数的调用
(send nil? :sum _ _)
相反,以下表达式将匹配任意数量的参数(因此匹配以上两个示例)
(send nil? :sum ...)
请注意,...
可以出现在序列中的任何位置,例如 (send nil? :sum ... int)
将不再匹配第二个示例,因为最后一个参数不是整数。
嵌套 ...
也受支持;唯一的限制是 ...
和其他“可变长度”模式只能在一个序列中出现一次。例如,(send ... :sum ...)
不受支持。
*
、+
、?
用于重复
处理可变数量节点的另一种方法是使用 *
、+
、?
来表示特定模式应该匹配任意次数、至少一次和最多一次。
继续前面的示例,要查找整数文字的总和,我们可以使用
(send nil? :sum int*)
这将匹配我们的第一个示例 sum(1, 2)
,但不会匹配另一个 sum(1, 2, 3, n)
这种模式也会匹配没有参数的 sum
调用,这可能不是我们想要的。
使用 +
可以确保只匹配至少有一个参数的求和。
(send nil? :sum int+)
?
可以将匹配限制为 0 或 1 个节点。以下示例将匹配任何三个整数文字的求和,可以选择后面跟着一个方法调用
(send nil? :sum int int int send ?)
请注意,我们必须在 send
和 ?
之间加一个空格,因为 send?
将被视为谓词(下面会介绍)。
<>
用于匹配任何顺序
你可能不关心要匹配的节点的精确顺序。在这种情况下,你可以将节点放在括号之外
(send nil? :sum <(int 2) int>)
这将匹配我们的第一个示例 (sum(1, 2)
)。
但是它不会匹配我们的第二个示例,因为它指定方法调用 sum
必须正好有两个参数。
你可以在右括号之前添加 ...
来允许额外的参数
(send nil? :sum <(int 2) int ...>)
这将匹配我们的两个示例,但不会匹配 sum(1.0, 2)
或 sum(2)
,因为找到了括号中的第一个节点,但没有找到第二个节点 (int
)。
{}
用于“或”(并集)
让我们更复杂一点,引入浮点数
$ ruby-parse -e '1'
(int 1)
$ ruby-parse -e '1.0'
(float 1.0)
-
({int | float} _)
- int 或 float 类型,无论值如何
并集的分支可以包含多个项
-
(array {int int | range})
- 匹配包含两个整数或单个范围元素的数组
如果所有分支都只有一个项,你可以省略 |
,因此 {int | float}
可以简化为 {int float}
。
在检查符号或字符串时,可以使用正则表达式字面量来实现类似的效果
(send _ /to_s|inspect/) # => matches calls to `to_s` or `inspect`
!
用于否定
节点模式 (send nil? :sum !int _)
将匹配一个 sum
调用,其中第一个参数 **不是** 一个字面量整数。例如:
-
它将匹配
sum(2.0, 3)
,因为第一个参数是float
类型 -
它将不匹配
sum(2, 3)
,因为第一个参数是int
类型
否定运算符与其他节点模式语法元素 {} 、[] 、() 、$ 一起使用,但仅与那些针对单个元素的元素一起使用。例如 $!(int 1) 、!{false nil} 、![#positive? #even?] 将起作用,而 !{int int | sym} 、!{int int | sym sym} 以及 <> 的任何使用都不会起作用。
|
$
用于捕获
您可以使用 $
前缀表达式来捕获元素或节点以及您的搜索。例如,在元组 (int 1)
中,您可以使用 (int $_)
捕获值。
您还可以捕获多个内容,例如
(${int float} $_)
可以使用 $
在开括号之前捕获整个元组
$({int float} _)
或者删除括号并直接从节点头匹配
${int float}
所有可变长度模式 (...
、*
、+
、?
、<>
) 都被捕获为数组。
以下模式将有两个捕获,都是数组
(send nil? $int+ (send $...))
^
用于父级
可以使用 ^
字符检查父级。
例如,以下模式将找到任何具有两个子节点且父节点是哈希的节点
(^hash _key $_value)
可以在序列开头以外的地方使用 ^
;在这种情况下,它是相对于该子节点(即当前节点)的。一种情况也使用多个 ^
向上移动多个级别。例如,前面的示例基本上与以下示例相同
(pair ^^hash $_value)
`
用于后代
`
字符可用于搜索节点及其所有后代。例如,如果要查找方法定义中的任何位置的 return
语句,我们可以编写
(def _method_name _args `return)
这将匹配这两个方法 foo
和 bar
,即使这些 return
用于 foo
和 bar
并不在同一级别。
def foo # (def :foo return 42 # (args) end # (return # (int 42))) def bar # (def :bar return 42 if foo # (args) nil # (begin end # (if # (send nil :foo) # (return # (int 42)) nil) # (nil)))
谓词方法
以 ?
结尾的词是谓词方法,在目标上调用以查看它是否匹配匹配对象支持的任何 Ruby 方法可以使用。
示例
-
int_type?
可以在这里替换(int _)
使用。
并将表达式重构为允许 int 或 float 类型
-
{int_type? float_type?}
可以在这里替换({int float} _)
使用
您也可以在节点级别使用它,询问每个子节点
-
(int odd?)
仅与奇数匹配,向当前数字询问。
#
用于调用函数
有时,我们想添加额外的逻辑。假设我们正在搜索素数,所以我们有一个方法来检测它
def prime?(n)
if n <= 1
false
elsif n == 2
true
else
(2..n/2).none? { |i| n % i == 0 }
end
end
我们可以直接在表达式中使用#prime?
函数
(int #prime?)
你也可以在常量上调用方法。假设你定义了
module Util
def self.palindrome?(str)
str == str.reverse
end
end
你可以像这样引用它
(str #Util.palindrome?)
谓词和函数调用的参数
参数可以传递给谓词和函数调用,例如字面量、参数
def divisible_by?(value, divisor)
value % divisor == 0
end
使用此函数的示例模式
(int #divisible_by?(42)) (send (int _value) :+ (int #divisible_by?(_value))
参数本身可以是模式,在这种情况下,将传递一个响应===
的匹配器。这使得模式可组合
def_node_matcher :global_const?, '(const {nil? cbase} %1)'
def_node_matcher :class_creator, '(send #global_const?({:Class :Module}) :new ...)'
使用节点匹配器宏
RuboCop 基础包含两种有用的方法,可以使用节点模式以简单的方式在 Ruby 中使用。你可以使用宏来定义方法。基础是def_node_matcher 和 def_node_search.
当你定义一个模式时,它会创建一个接受节点并尝试匹配的方法。
让我们创建一个示例,我们试图在表达式中找到符号user
和 current_user
,例如:user: current_user
或 current_user: User.first
,因此这里的目标是选择所有键
$ ruby-parse -e ':current_user'
(sym :current_user)
$ ruby-parse -e ':user'
(sym :user)
$ ruby-parse -e '{ user: current_user }'
(hash
(pair
(sym :user)
(send nil :current_user)))
我们最小的匹配器可以在简单的节点sym
中获得它
def_node_matcher :user_symbol?, '(sym {:current_user :user})'
使用多个匹配器组合复杂的表达式
现在让我们深入结合前面的表达式,并且如果当前符号是从初始化方法调用的,也匹配,例如
$ ruby-parse --legacy -e 'Comment.new(user: current_user)'
(send
(const nil :Comment) :new
(hash
(pair
(sym :user)
(send nil :current_user))))
我们也可以重用它并检查它是否是一个构造函数
def_node_matcher :initializing_with_user?, <<~PATTERN
(send _ :new (hash (pair #user_symbol?)))
PATTERN
%
用于参数
参数可以传递给匹配器,作为外部方法参数,或者用于比较元素。方法参数的示例
def multiple_of?(n, factor)
n % factor == 0
end
def_node_matcher :int_node_multiple?, '(int #multiple_of?(%1))'
# ...
int_node_multiple?(node, 10) # => true if node is an 'int' node with a multiple of 10
参数可用于直接匹配节点
def_node_matcher :has_sensitive_data?, '(hash <(pair (_ %1) $_) ...>)'
# ...
has_sensitive_data?(node, :password) # => true if node is a hash with a key +:password+
# matching uses ===, so to match strings or symbols, 'pass' or 'password' one can:
has_sensitive_data?(node, /^pass(word)?$/i)
# one can also pass lambdas...
has_sensitive_data?(node, ->(key) { # return true or false depending on key })
Array#=== 永远不会匹配单个节点元素(所以不要传递数组),但 Set#=== 是 Set#include? 的别名(仅限 Ruby 2.5+),因此对于匹配许多可能的字面量/节点非常有用。
|
%param_name
用于命名参数
参数可以作为命名参数传递。它们将使用===
进行匹配(参见上面的%
)。
与位置参数相反,可以将默认值传递给 def_node_matcher
和 def_node_search
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'
命名参数作为自定义方法的参数也受支持。
CONST
或 %CONST
用于常量
模式中可以包含常量。它们将使用===
进行匹配,因此除了字面量和节点之外,还可以使用Regexp / Set / Proc。
SOME_CALLS = Set[:transform_values, :transform_keys,
:transform_values!, :transform_keys!,
:to_h].freeze
def_node_matcher :interesting_call?, '(send _ SOME_CALLS ...)'
自定义方法的参数也支持常量。
注释
您可以在节点模式的末尾使用'# '
在行尾添加注释。
def_node_matcher :complex_stuff, <<~PATTERN
(send
{#global_const?(:Kernel) nil?} # check for explicit call like Kernel.p too
{:p :pp} # let's consider `pp` also
$... # capture all arguments
)
PATTERN