节点模式

节点模式是一种 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。有一些区别,例如,没有 --legacyfoo(a: 1) 将返回 kwargs,而使用 --legacy 它将返回 hash

从 REPL

> puts RuboCop::AST::ProcessedSource.new('foo(1)', RUBY_VERSION.to_f).ast.to_s
(send nil :foo
  (int 1))

基本节点模式结构

最简单的节点模式只匹配节点类型。例如,int 节点模式将匹配 (int 1) AST(Ruby 代码中的文字 1)。更复杂的节点模式匹配多个子节点。

() 用于匹配元素

用括号括起来的多个匹配器将匹配一个节点,该节点的元素分别匹配相应的匹配器,并且顺序依赖。带有两个整数文字的数组的 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)。任何作为节点的子节点都可以成为嵌套匹配的目标。

_ 用于任何单个节点

_ 将检查特定位置是否存在内容,无论其值如何

  • (int _) 将匹配任何数字

  • (int _ _) 将不匹配,因为 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`

[] 用于“与”

假设你想检查数字是否为 odd? 并且也是正数

(int [odd? positive?]) - 是一个 int,并且值应该是奇数且为正数。

参考 谓词方法 查看 odd? 的工作原理。

! 用于否定

节点模式 (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)

这将匹配这两个方法 foobar,即使这些 return 用于 foobar 并不在同一级别。

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_matcherdef_node_search.

当你定义一个模式时,它会创建一个接受节点并尝试匹配的方法。

让我们创建一个示例,我们试图在表达式中找到符号usercurrent_user,例如:user: current_usercurrent_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_matcherdef_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

nilnil?

请特别注意 nil 的行为。

$ ruby-parse -e 'nil'
(nil)

在这种情况下,nil 隐式匹配诸如:nil(nil)nil_type? 之类的表达式。

但是,nil 也用于表示从简单方法调用中的nothing 进行的调用。

$ ruby-parse -e 'method'
(send nil :method)

然后,对于这种情况,您可以使用谓词nil?。代码可以与以下表达式匹配:

(send nil? :method)

更多资源

想知道它是如何工作的吗?

文档中查看更多详细信息,或直接浏览源代码。它易于阅读和修改。

规范对于理解每个功能也很有用。