开发

本节文档将教你如何开发新的 Cops。我们将从生成一个 Cop 模板开始,然后我们将讨论其实现的各个方面(与 AST 交互、自动修正、配置)以及测试。

创建新的 cop

克隆仓库并运行 bundle install(如果尚未完成)。以下 rake 任务只能在 rubocop 项目目录本身内运行。

使用捆绑的 rake 任务 new_cop 生成 cop 模板

$ bundle exec rake 'new_cop[Department/Name]'
[create] lib/rubocop/cop/department/name.rb
[create] spec/rubocop/cop/department/name_spec.rb
[modify] lib/rubocop.rb - `require_relative 'rubocop/cop/department/name'` was injected.
[modify] A configuration for the cop is added into config/default.yml.
Do 4 steps:
  1. Modify the description of Department/Name in config/default.yml
  2. Implement your new cop in the generated file!
  3. Commit your new cop with a message such as
     e.g. "Add new `Department/Name` cop"
  4. Run `bundle exec rake changelog:new` to generate a changelog entry
     for your new cop.

基础知识

RuboCop 使用 parser 库来创建代码的抽象语法树 (AST) 表示。

您可以安装 parser gem 并使用 ruby-parse 命令行工具来检查 AST 在输出中的样子。

$ gem install parser

然后尝试使用 ruby-parse 解析一个简单的整数表示。

$ ruby-parse -e '1'
(int 1)

每个用括号括起来的表达式代表 AST 中的一个节点。第一个元素是节点类型,尾部包含所有表示代码所需信息的所有子节点。

以下是一个示例 - 将字符串值 "John" 赋值给局部变量 name

$ ruby-parse -e 'name = "John"'
(lvasgn :name
  (str "John"))

检查 AST 表示

假设我们要将语句从 !array.empty? 简化为 array.any?

首先,检查错误代码在抽象语法树表示中的返回值。

$ ruby-parse -e '!array.empty?'
(send
  (send
    (send nil :array) :empty?) :!)

现在,是时候使用 RuboCop 的 REPL 调试我们的表达式了

$ bin/console

首先,我们需要声明要匹配的代码,并使用 ProcessedSource,它是一个简单的包装,用于让解析器解释代码并构建 AST

code = '!something.empty?'
source = RuboCop::ProcessedSource.new(code, RUBY_VERSION.to_f)
node = source.ast
# => s(:send, s(:send, s(:send, nil, :something), :empty?), :!)

该节点有一些属性,在旅程中可能会有用

node.type # => :send
node.children # => [s(:send, s(:send, nil, :something), :empty?), :!]
node.source # => "!something.empty?"

实现

编写节点模式规则

您可以编写不使用 NodePattern 的 cop(许多旧的 cop 不使用它),但它通常会简化很多代码,因为手动节点匹配和解构可能非常冗长。

现在您已经熟悉了 AST,您可以了解一下 节点模式 并使用模式来匹配您想要匹配的特定节点。

您可以在 此处 了解有关节点模式的更多信息。

节点模式匹配的内容与 AST 表示的当前输出非常相似,那么让我们从一个非常通用的内容开始

NodePattern.new('send').match(node) # => true

它匹配是因为根节点是 send 类型。现在让我们使用括号来定义子节点的详细信息,从而深入匹配它。如果您不关心内部节点是什么,可以使用 ... 跳过它,只考虑 "一个节点"。

NodePattern.new('(send ...)').match(node) # => true
NodePattern.new('(send (send ...) :!)').match(node) # => true
NodePattern.new('(send (send (send ...) :empty?) :!)').match(node) # => true

有时很难理解您使用模式构建的复杂表达式,那么,如果您对围绕深层的节点模式括号感到困惑,请尝试使用 $ 来捕获内部表达式并准确检查表达式的每个部分

NodePattern.new('(send (send (send $...) :empty?) :!)').match(node) # => [nil, :something]

在内部节点中,严格接收发送不是必需的,因为它也可能是一个字面量数组,例如

![].empty?

上面的代码具有以下表示形式

=> s(:send, s(:send, s(:array), :empty?), :!)

可以使用 ... 跳过内部节点,以确保它只是另一个内部节点

NodePattern.new('(send (send (...) :empty?) :!)').match(node) # => true

换句话说,它表示:“匹配调用 !<expression>.empty? 的代码”。

太好了!现在,让我们实现我们的 cop 来简化此类语句

$ rake 'new_cop[Style/SimplifyNotEmptyWithAny]'

在生成 cop 脚手架后,将节点匹配器更改为与之前获得的表达式匹配

def_node_matcher :not_empty_call?, <<~PATTERN
  (send (send $(...) :empty?) :!)
PATTERN

请注意,我们在 !<expression>.empty? 中添加了一个 $ 符号来捕获“表达式”,它将在稍后变得有用。

熟悉 parserrubocop-ast 提供的 AST 节点钩子。

由于它以 send 类型开头,因此需要实现 on_send 方法,正如 cop 脚手架已经建议的那样

def on_send(node)
  return unless not_empty_call?(node)

  add_offense(node)
end

on_send 回调是最常用的,可以通过使用常量 RESTRICT_ON_SEND 限制可接受的方法名称来优化它。

最终的 cop 代码将类似于以下内容

module RuboCop
  module Cop
    module Style
      # `array.any?` is a simplified way to say `!array.empty?`
      #
      # @example
      #   # bad
      #   !array.empty?
      #
      #   # good
      #   array.any?
      #
      class SimplifyNotEmptyWithAny < Base
        MSG = 'Use `.any?` and remove the negation part.'.freeze
        RESTRICT_ON_SEND = [:!].freeze # optimization: don't call `on_send` unless
                                       # the method name is in this list

        def_node_matcher :not_empty_call?, <<~PATTERN
          (send (send $(...) :empty?) :!)
        PATTERN

        def on_send(node)
          return unless not_empty_call?(node)

          add_offense(node)
        end
      end
    end
  end
end

请注意,on_send 将在给定 node 上调用,然后才会调用其子节点的回调 on_<some type>。还有一个回调 after_send,它在处理完子节点后调用。所有类型都有类似的 after_<some type> 回调,除了那些从不包含子节点的类型。

更新规范以涵盖预期语法

describe RuboCop::Cop::Style::SimplifyNotEmptyWithAny, :config do
  it 'registers an offense when using `!a.empty?`' do
    expect_offense(<<~RUBY)
      !array.empty?
      ^^^^^^^^^^^^^ Use `.any?` and remove the negation part.
    RUBY
  end

  it 'does not register an offense when using `.any?` or `.empty?`' do
    expect_no_offenses(<<~RUBY)
      array.any?
      array.empty?
    RUBY
  end
end

如果您的代码包含不同长度的变量,可以使用 %{foo}^{foo}_{foo} 来格式化模板;您还可以使用 […​] 来缩写违规消息

%w[raise fail].each do |keyword|
  expect_offense(<<~RUBY, keyword: keyword)
    %{keyword}(RuntimeError, msg)
    ^{keyword}^^^^^^^^^^^^^^^^^^^ Redundant `RuntimeError` argument [...]
  RUBY

%w[has_one has_many].each do |type|
  expect_offense(<<~RUBY, type: type)
    class Book
      %{type} :chapter, foreign_key: 'book_id'
      _{type}           ^^^^^^^^^^^^^^^^^^^^^^ Specifying the default [...]
    end
  RUBY
end

自动更正

自动更正可以帮助人类自动修复已检测到的违规行为。需要 extend AutoCorrector。方法 add_offense 会产生一个更正器对象,它是 parser 的 TreeRewriter 的一个薄包装器,您可以向其提供有关如何处理违规节点的说明。

让我们从一个简单的规范开始,以涵盖它

it 'corrects `!a.empty?`' do
  expect_offense(<<~RUBY)
    !array.empty?
    ^^^^^^^^^^^^^ Use `.any?` and remove the negation part.
  RUBY

  expect_correction(<<~RUBY)
    array.any?
  RUBY
end

然后在 cop 侧添加自动更正块

extend AutoCorrector

def on_send(node)
  expression = not_empty_call?(node)
  return unless expression

  add_offense(node) do |corrector|
    corrector.replace(node, "#{expression.source}.any?")
  end
end

更正器允许您 insert_afterinsert_beforewrapreplace 特定节点或代码中的任何特定范围。

范围可以在 node.location 上确定,它会为表达式或节点持有的其他内部信息提供特定的范围。

防止覆盖

更正器会检测并防止更正重叠的节点,以防止一个更正覆盖另一个更正。通过多次传递来支持嵌套更正,并跳过对嵌套节点的更正。这可以使用 IgnoredNode 模块来实现

 extend AutoCorrector
+include IgnoredNode

 def on_send(node)
   return unless some_condition?(node)

   add_offense(node) do |corrector|
+    next if part_of_ignored_node?(node)
+
     corrector.replace(node, "...")
   end
+
+  ignore_node(node)
 end

这是因为文件校正的实现是通过重复调查和校正直到文件不再需要校正,这意味着所有嵌套节点最终都会被处理。

请注意,Cop 规范中的 expect_correction 仅断言一次传递后的结果。

按 Ruby 或 Gem 版本限制

一些 cop 应用仅在特定上下文中适用的更改,例如,如果用户具有最低 Ruby 版本。有一些助手可以让你自动约束你的 cop,使其仅在适用时运行。

要求最低 Ruby 版本

如果你的 cop 使用新的 Ruby 语法或标准库 API,它应该只在用户具有目标 Ruby 版本时自动校正,你使用 TargetRubyVersion#minimum_target_ruby_version 设置它。

例如,Performance/SelectMap cop 需要 Ruby 2.7,它引入了 Enumerable#filter_map

module RuboCop::Cop::Performance::SelectMap < Base
  extend TargetRubyVersion

  minimum_target_ruby_version 2.7

  # ...
end

此 cop 不会在 Ruby 2.6 或更早版本上自动校正,它甚至不会报告违规(因为没有更好的替代方案可以推荐)。

要求一个 gem

如果你的 cop 依赖于 gem 的存在,你可以使用 RuboCop::Cop::Base.requires_gem 声明它。

例如,要声明 MyCop 应该只在捆绑包使用版本在 1.2.34.5.6 之间的 my-gem 时应用

class MyCop < Base
  requires_gem "my-gem", ">= 1.2.3", "< 4.5.6"

  # ...
end

你可以使用 与你的 Gemfile 相同的语法 指定任何 gem 要求。

特殊情况:Rails

从历史上看,rubocop-rails 中的许多 cop 实际上并不特定于 Rails 本身,而是它的一些组件(例如 ActiveSupport)。这些依赖关系是使用 TargetRailsVersion.minimum_target_rails_version 声明的。

例如,Rails/Pluck cop 需要 ActiveSupport 6.0,它引入了 Enumerable#pluck

module RuboCop::Cop::Rails::Pluck < Base
  extend TargetRailsVersion

  minimum_target_rails_version 6.0

  #...
end

运行测试

RuboCop 支持两个解析器引擎:Parser gem 和 Prism。默认情况下,测试使用 Parser 执行

$ bundle exec rake spec

要使用对 Prism 的实验性支持运行所有测试,请使用 bundle exec prism_spec,要执行单个文件的测试,请指定环境变量 PARSER_ENGINE=parser_prism

例如,PARSER_ENGINE=parser_prism spec/rubocop/cop/style/hash_syntax_spec.rb

bundle exec rake 运行 Parser gem 和 Prism 解析器的测试。

ascii_spec rake 任务默认不运行。因为 ascii_spec 任务已经有一段时间没有失败了。但是,ascii_spec 任务将在 CI 中继续检查。

在 CI 中,合并所需的所有测试都会执行。请调查是否有任何失败。

配置

每个 cop 可以保存一个配置,你可以引用实例中的 cop_config,它将返回一个包含在 .rubocop.yml 文件中声明的选项的哈希表。

例如,假设我们想让配置可配置,以便替换可以使用除 .any? 之外的其他方法。

Style/SimplifyNotEmptyWithAny:
  Enabled: true
  ReplaceAnyWith: "size > 0"

然后在自动更正方法中,你只需要使用 cop_config 即可。

def on_send(node)
  expression = not_empty_call?(node)
  return unless expression

  add_offense(node) do |corrector|
    replacement = cop_config['ReplaceAnyWith'] || 'any?'
    corrector.replace(node, "#{expression.source}.#{replacement}")
  end
end

文档

每个新的 cop 都需要解释和示例,以便社区能够轻松地理解其目的。此文档由 yard 生成,并直接添加到 cop.rb 文件中。对于每个 SupportedStyle 和你在 cop 中包含的唯一配置,都需要有示例。示例必须具有有效的 Ruby 语法。不要使用反引号。

module Department
  # Description of your cop. Include description of ALL config options. Particularly
  # ones that take booleans and arrays, because we generally do not show examples for
  # configs with these value types.
  #
  # @example EnforcedStyle: bar
  #   # Description about this particular option
  #
  #   # bad
  #   bad_example1
  #   bad_example2
  #
  #   # good
  #   good_example1
  #   good_example2
  #
  # @example EnforcedStyle: foo (default)
  #   # Description about this particular option
  #
  #   # bad
  #   bad_example1
  #   bad_example2
  #
  #   # good
  #   good_example1
  #   good_example2
  #
  # @example AnyUniqueConfigKeyThatIsAString: qux (default)
  #   # Description about this particular option
  #
  #   # bad
  #   bad_example1
  #   bad_example2
  #
  #   # good
  #   good_example1
  #   good_example2
  #
  # @example AnyUniqueConfigKeyThatIsAString: thud
  #   # Description about this particular option
  #
  #   # bad
  #   bad_example1
  #   bad_example2
  #
  #   # good
  #   good_example1
  #   good_example2
  #
  class YourCop
    # ...

注意所有文档部分的放置和间距。例如,配置键按字母顺序排列,指定了 (default),并且在 class YourCop 之前有一行空行。虽然代码库中的并非所有示例都遵循这种确切的格式,但我们努力使它保持一致。非常欢迎改进 RuboCop 文档的 PR。

在真实代码库中测试你的 cop

通常,最好检查你的 cop 是否在重要的代码库(例如 Rails 或你正在处理的某个大型项目)上正常工作,以确保它在各种不同的语法中都能正常工作。

有几种方法可以做到这一点。两种常见的方法

  1. 在你的本地 rubocop 仓库中,运行 exe/rubocop ~/your/other/codebase

  2. 在另一个代码库的 Gemfile 中,设置指向你的本地仓库的路径,如下所示:gem 'rubocop', path: '/full/path/to/rubocop'。然后在你的代码库中运行 rubocop

使用方法 #2,你也可以使用 RuboCop 扩展仓库的本地版本,例如 rubocop-rspec

为了快速执行并且避免与其他正在运行的代码检查器混淆,您可以在命令行中使用 `--only` 参数来根据您的代码检查器名称进行过滤。

$ rubocop --only Style/SimplifyNotEmptyWithAny