节点模式
节点模式是一种 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