【令和最新版】カスタムCop 作成 ガイド Ruby 静的解析 RuboCop コードフォーマッタ Linter【AutoCorrect: contextual対応】

はじめに

こんにちは。姓は#LR_parser_gangs、名はydahです。最近は子が「となりのトトロ」にハマっており、毎日「となりのトトロ」がリビングで流れています。 全く飽きないのか、毎日のように「となりのトトロ観る〜?(意訳:となりのトトロが観たいのでリモコンを操作して欲しい)」と言い続け1ヶ月が経とうとしています。 これは ANDPAD Advent Calendar 2024 3日目の記事です。

今回はRubyKaigi 2024やKaigi on Rails 2024で秘蔵のesaとして配布した、RuboCopのカスタムCop作成のための資料を公開します。 カスタムCopの作成ができると、自社のコーディング規約合わせた柔軟なルールを定義し管理することができるようになります。

アップストリームに提案を送るのもよいですが、汎用的でない場合や、限定された状況でのみ有用なルールというのは一般的にはアップストリームには取り込まれにくいです。 また、事前に自分たちのコードに適用してみるということも容易にできるので、まずは自分たちのCopとして作成してみるのもよいでしょう。

もしアップストリームに提案を送りたいが、その前に相談したい場合にはrubocop/rubocop-jpで相談してもらうのもいいと思います。Watchしているので、RSpec、Capybara、FactoryBot、RSpec Rails向けのCopの場合は特にお手伝いできるかもしれません1

RuboCopは、Rubyの静的解析ツール(Linter)およびコードフォーマッターです。 デフォルトでは、RuboCopチームが管理しているRuby Style Guideに準拠したガイドラインの多くが適用されます。 コードを解析して発見された問題を報告するだけでなく、RuboCop はそれらの問題を自動的に修正できます。

github.com

RuboCopには提供されているルールだけでなく、自分でカスタムルールも作成できます。 カスタムルールはカスタムCop(Custom Cop)と呼ばれ、独自のルールを作成してRuboCopに適用できます。

docs.rubocop.org

この記事では、RuboCopのカスタムCopを作成する方法や、実際にカスタムCopを作成する際のポイントについて解説します。

カスタムCopの置き場の作成

カスタムCopを作成するためには、まずカスタムCopの置き場を作成する必要があります。 カスタムCopの置き場は、Railsアプリと同じリポジトリで管理する場合と、別リポジトリで管理する場合があります。 簡単にですが、それぞれの方法について説明します。

Railsアプリと同じリポジトリで管理する場合

Railsアプリと同じリポジトリで管理する場合は、lib/rubocop/copディレクトリにカスタムCopを配置します2。 弊社のとあるリポジトリの場合は、lib/rubocop/cop/andpad にカスタムCopを配置しています。

新しくルールを作成したら、それだけではRuboCopが認識しないので、.rubocop.ymlに追記する必要があります。

require:
  - rubocop-rails
  - rubocop-rspec
+  - ./lib/rubocop/cop/andpad/your_custom_cop

+Andpad/YourCustomCop:
+  Enabled: true

これはこの方式で管理している場合には、カスタムCopを作成する毎に .rubocop.yml に追記する必要があるので注意が必要です。

別リポジトリで管理する場合

カスタムCopを別リポジトリで管理する場合は、リポジトリを作成する必要があります。 リポジトリを作成したら、テンプレートを使ってリポジトリを作成するのを推奨します。

公式が提供しているテンプレートを使用する場合

RuboCopのカスタムCopを作成するためのテンプレートがRuboCopの公式として提供されています。

github.com

以下の通りgemをインストールして、テンプレートを使ってリポジトリを作成します。

$ gem install rubocop-extension-generator
$ rubocop-extension generate rubocop-foobar
Creating gem 'rubocop-foobar'...
MIT License enabled in config
Code of conduct enabled in config
Changelog enabled in config
Initializing git repo in /Users/yudai.takada/tmp/rubocop-foobar
      create  rubocop-foobar/Gemfile
      create  rubocop-foobar/lib/rubocop/foobar.rb
      create  rubocop-foobar/lib/rubocop/foobar/version.rb
      create  rubocop-foobar/sig/rubocop/foobar.rbs
      create  rubocop-foobar/rubocop-foobar.gemspec
      create  rubocop-foobar/Rakefile
      create  rubocop-foobar/README.md
      create  rubocop-foobar/bin/console
      create  rubocop-foobar/bin/setup
      create  rubocop-foobar/.gitignore
      create  rubocop-foobar/.github/workflows/main.yml
      create  rubocop-foobar/LICENSE.txt
      create  rubocop-foobar/CODE_OF_CONDUCT.md
      create  rubocop-foobar/CHANGELOG.md
Gem 'rubocop-foobar' was successfully created. For more information on making a RubyGem visit https://bundler.io/guides/creating_gem.html
create rubocop-foobar/lib/rubocop-foobar.rb
create rubocop-foobar/lib/rubocop/foobar/inject.rb
create rubocop-foobar/lib/rubocop/cop/foobar_cops.rb
create rubocop-foobar/config/default.yml
create rubocop-foobar/spec/spec_helper.rb
create rubocop-foobar/.rspec
create rubocop-foobar/.rubocop.yml
update lib/rubocop/foobar.rb
update lib/rubocop/foobar.rb
update lib/rubocop/foobar/version.rb
update rubocop-foobar.gemspec
update rubocop-foobar.gemspec
update Rakefile
update Gemfile
update README.md

It's done! You can start developing a new extension of RuboCop in rubocop-foobar.
For the next step, you can use the cop generator.

  $ bundle exec rake 'new_cop[Foobar/SuperCoolCopName]'

これで、カスタムCopを作成するためのリポジトリの作成が完了しました。

雛形から作成する場合

私がよく使っている雛形(GitHub ActionsやRakeタスクなどを含む)を使ってリポジトリを作成する場合は、以下のリポジトリをテンプレートリポジトリとして作成するという方法もあります。

github.com

your_extension, YourExtension, Your Extensionというファイル名とファイル内の文字列を適宜変更して使ってください。 また、サンプルとしてYourExtension/ExampleというカスタムCopを作成しています。 作成を進めるにあたって不要なので、以下のファイルを削除してください。

  • lib/rubocop/cop/your_extension/example.rb
  • spec/rubocop/cop/your_extension/example_spec.rb

もし、動作しない場合にはお気軽にIssueを立ててください3。 これで、カスタムCopを作成するためのリポジトリの作成が完了しました。

カスタムCopの作成

カスタムCopの雛形の作成

カスタムCopを作成するためには、RuboCop::Cop::Baseを継承したクラスを作成します。 以下の例では、RuboCop::Cop::Baseを継承したRuboCop::Cop::Andpad::YourCustomCopクラスを作成しています。

# lib/rubocop/cop/andpad/your_custom_cop.rb
module RuboCop
  module Cop
    module Andpad
      class YourCustomCop < ::RuboCop::Cop::Base
        MSG = 'Your custom message'

        def on_send(node)
          return unless node.method_name == :your_method

          add_offense(node, message: MSG)
        end
      end
    end
  end
end

もし、カスタムCopを作成するためのリポジトリを作成した場合は、以下のコマンドを実行してカスタムCopを作成できます。

$ bundle exec rake 'new_cop[Andpad/YourCustomCop]'
[create] lib/rubocop/cop/andpad/your_custom_cop.rb
[create] spec/rubocop/cop/andpad/your_custom_cop_spec.rb
[modify] lib/rubocop/cop/foobar_cops.rb - `require_relative 'andpad/your_custom_cop'` was injected.
[modify] A configuration for the cop is added into config/default.yml.
Do 4 steps:
  1. Modify the description of Andpad/YourCustomCop 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 `Andpad/YourCustomCop` cop"
  4. Run `bundle exec rake changelog:new` to generate a changelog entry
     for your new cop.

このコマンドによって、必要なファイルが生成されます。 カスタムCopの雛形は以下のようになります。

# frozen_string_literal: true

module RuboCop
  module Cop
    module Andpad
      # TODO: Write cop description and example of bad / good code. For every
      # `SupportedStyle` and unique configuration, there needs to be examples.
      # Examples must have valid Ruby syntax. Do not use upticks.
      #
      # @safety
      #   Delete this section if the cop is not unsafe (`Safe: false` or
      #   `SafeAutoCorrect: false`), or use it to explain how the cop is
      #   unsafe.
      #
      # @example EnforcedStyle: bar (default)
      #   # Description of the `bar` style.
      #
      #   # bad
      #   bad_bar_method
      #
      #   # bad
      #   bad_bar_method(args)
      #
      #   # good
      #   good_bar_method
      #
      #   # good
      #   good_bar_method(args)
      #
      # @example EnforcedStyle: foo
      #   # Description of the `foo` style.
      #
      #   # bad
      #   bad_foo_method
      #
      #   # bad
      #   bad_foo_method(args)
      #
      #   # good
      #   good_foo_method
      #
      #   # good
      #   good_foo_method(args)
      #
      class YourCustomCop < Base
        # TODO: Implement the cop in here.
        #
        # In many cases, you can use a node matcher for matching node pattern.
        # See https://github.com/rubocop/rubocop-ast/blob/master/lib/rubocop/ast/node_pattern.rb
        #
        # For example
        MSG = 'Use `#good_method` instead of `#bad_method`.'

        # TODO: Don't call `on_send` unless the method name is in this list
        # If you don't need `on_send` in the cop you created, remove it.
        RESTRICT_ON_SEND = %i[bad_method].freeze

        # @!method bad_method?(node)
        def_node_matcher :bad_method?, <<~PATTERN
          (send nil? :bad_method ...)
        PATTERN

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

          add_offense(node)
        end
      end
    end
  end
end

カスタムCopのテストの作成

カスタムCopを作成する際には、まずテストを書いておくと便利です。 テストを書くことで、カスタムCopが正しく動作しているかを確認できます。

テストですが、GitHub Copilotを使えば凄まじく楽に書けるのでサッと書いてしまいましょう。4 テストの雛形は以下です。

# frozen_string_literal: true

RSpec.describe RuboCop::Cop::Andpad::YourCustomCop, :config do
  let(:config) { RuboCop::Config.new }

  # TODO: Write test code
  #
  # For example
  it 'registers an offense when using `#bad_method`' do
    expect_offense(<<~RUBY)
      bad_method
      ^^^^^^^^^^ Use `#good_method` instead of `#bad_method`.
    RUBY
  end

  it 'does not register an offense when using `#good_method`' do
    expect_no_offenses(<<~RUBY)
      good_method
    RUBY
  end
end

RuboCopにはRuboCopのテストコードを書きやすくするためのヘルパーメソッドがRuboCop::RSpec::ExpectOffenseとして提供されています。

github.com

よく使うのは、expect_offenseexpect_no_offensesです。 これらは、それぞれoffenseが発生することとoffenseが発生しないことをテストするためのヘルパーメソッドです。

expect_offenseの第1引数には、offenseが発生するコードを記述します。 また、^を使ってoffenseが発生する位置を示して、offenseメッセージを記述します。

その他に、よく使うのはexpect_correctionです。 RuboCopには違反を知らせるだけでなく、違反の自動修正をする機能もあります。

docs.rubocop.org

以下のようにRuboCopを実行すると違反の自動修正が行われます。

# 安全だとマークされた自動修正の実行
$ rubocop -a
$ rubocop --auto-correct

# 安全でない自動修正も含む実行
$ rubocop -A
$ rubocop --auto-correct-all

RuboCopで違反を自動修正する場合には、expect_correctionを使ってテストを書くことができます。

# frozen_string_literal: true

RSpec.describe RuboCop::Cop::Andpad::YourCustomCop, :config do
  let(:config) { RuboCop::Config.new }

  it 'registers an offense when using `#bad_method`' do
    expect_offense(<<~RUBY)
      bad_method
      ^^^^^^^^^^ Use `#good_method` instead of `#bad_method`.
    RUBY

    expect_correction(<<~RUBY)
      good_method
    RUBY
  end
end

上記は、bad_methodgood_methodに自動修正することをテストする例です。 このように、expect_correctionに自動修正後のコードを記述することで、違反の自動修正をテストできます。

テストを書くときのポイントとしては思いつく限り色んなパターンを網羅することです。 たとえば、メソッド呼び出しの場合なら、引数がある場合、引数がない場合、複数の引数がある場合などをテストすると良いでしょう。 また、レシーバーの有無や、メソッド呼び出しが複数行にまたがる場合も書いておくとより安心です。

カスタムCopの実装

ではテストが書けたら、実際にカスタムCopを実装していきましょう。

前提知識 : AST(Abstract Syntax Tree)

カスタムCopの実装は、まずはAST(Abstract Syntax Tree)を理解することが重要です。 ASTは、プログラムの構文を木構造で表現したものです。

RuboCopでは、parser gemを使ってRubyのコードをASTに変換します5。 以下のように parser gemをインストールして、ruby-parse コマンドを使用すれば、出力によってASTがどのように構築されているかを確認できます。

$ gem install parser
$ ruby-parse -e 'puts "Hello, World!"'
(send nil :puts
  (str "Hello, World!"))

sendはメソッド呼び出しを表し、nilはレシーバーがないことを表します。 putsメソッドは引数を1つ取るので、引数の部分にstrがあります。strは文字列を表します。

sendやstrはノードの種類を表しています。どんなノードがあるかは以下のリンクを参照してください。

docs.rubocop.org

前提知識 : on_xxxメソッド

RuboCopのカスタムCopを作成する際には、on_xxxメソッドを定義する必要があります。 on_xxxメソッドは、特定のノードが見つかったときに呼び出されるメソッドです。

たとえば、on_sendメソッドはメソッド呼び出しのノードが見つかったときに呼び出されます。

def on_send(node)
  # メソッド呼び出しのノードが見つかったときに呼び出される処理
end

on_sendメソッド以外にも、on_class, on_module, on_def, on_defsなど、on_xxxメソッドが用意されています。 これらのメソッドを使って、特定のノードが見つかったときに処理を行うことができます。

カスタムCopの実装

さて、ここまで来たら実際にカスタムCopを実装していきましょう。 以下の例では、on_sendメソッドを使って、特定のメソッド呼び出しを検出するカスタムCopを作成しています。

# lib/rubocop/cop/andpad/your_custom_cop.rb
module RuboCop
  module Cop
    module Andpad
      class YourCustomCop < ::RuboCop::Cop::Base
        MSG = 'Use `#good_method` instead of `#bad_method`.'

        RESTRICT_ON_SEND = %i[bad_method].freeze

        def_node_matcher :bad_method?, <<~PATTERN
          (send nil? :bad_method ...)
        PATTERN

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

          add_offense(node)
        end
      end
    end
  end
end

この例だと、bad_methodメソッド呼び出しを検出して、"Use #good_method instead of #bad_method.'"というメッセージを出力するカスタムCopを作成しています。 ここで新登場の要素があるのでひとつずつ説明します。

MSG

MSGは、違反が検出されたときに表示されるメッセージです。 MSGには、"Use #good_method instead of #bad_method."というメッセージを設定しています。

たとえば、メッセージを動的に生成したい場合は、add_offenseメソッドの第2引数にキーワード引数としてmessageを渡すことで、メッセージを動的に生成できます。 これはbad_methodが複数あるようなケースに違反した箇所のメソッド名を使用してメッセージを生成する場合などに使えます。

MSG = 'Use `#good_method` instead of `%<bad_method>s`.'

# (中略)

on_send(node)
  return unless bad_method?(node)

  add_offense(node, message: format(MSG, bad_method: node.method_name))
end
RESTRICT_ON_SEND

RESTRICT_ON_SENDは、on_sendメソッドを呼び出す条件を指定するための定数です。 というのも、on_sendメソッドは、すべてのメソッド呼び出しのノードに対して呼び出されるため、非常に多くのノードが対象となります。 そのため、RESTRICT_ON_SENDを使って、特定のメソッド呼び出しのノードに対してのみon_sendメソッドを呼び出すように制限できます。

RESTRICT_ON_SENDには、on_sendメソッドを呼び出す条件となるメソッド名を配列で指定します。 この例では、bad_methodメソッド呼び出しのノードに対してのみon_sendメソッドを呼び出すように制限しています。

RESTRICT_ON_SEND = %i[bad_method].freeze
def_node_matcher

def_node_matcherは、ASTのノードをマッチさせるためのメソッドです。 def_node_matcherは、第1引数にメソッド名、第2引数にマッチさせるノードのパターンを指定します。

この例では、bad_methodメソッド呼び出しのノードをマッチさせるために、def_node_matcherを使ってbad_method?メソッドを定義しています。

def_node_matcher :bad_method?, <<~PATTERN
  (send nil? :bad_method ...)
PATTERN

さまざまな機能があるので、詳しくは以下のリンクを参照してください。

docs.rubocop.org

_...などのワイルドカードや、$を使って対象のノードのキャプチャはよく使うので、覚えておくと便利です。

_の例を挙げると、数値リテラルのノードをマッチさせる場合は、以下のように記述します。

def_node_matcher :number_literal?, <<~PATTERN
  (int _)
end

これなら、1, 2, 3などの数値リテラルのノードをマッチさせることができます。

...の例を挙げると、sumメソッド呼び出しのノードをマッチさせる場合は、以下のように記述します。

def_node_matcher :sum?, <<~PATTERN
  (send nil? :sum ...)
end

これなら、sum(1, 2, 3)であれ、sum(1, 2, 3, n)であれ、sumメソッド呼び出しのノードをマッチさせることができます。

$の例を挙げると、bad_methodメソッド呼び出しの引数をキャプチャする場合は、以下のように記述します。

def_node_matcher :bad_method_with_arg?, <<~PATTERN
  (send nil? :bad_method $...)
end

これを使って、bad_methodメソッド呼び出しの引数をキャプチャできます。

def on_send(node)
  bad_method_with_arg?(node) do |args|
    # argsには、`bad_method`メソッド呼び出しの引数が格納されている
    pp args
    # => [s(:int, 1), s(:int, 2), s(:int, 3)]
    add_offense(node)
  end
end

たとえば、引数が1, 2, 3の場合は、[s(:int, 1), s(:int, 2), s(:int, 3)]という配列がargsに格納されます。 そもそもノードパターンに合わない場合は、nilが返るので、ブロック内に処理が進まないようになっています。 つまり、bad_methodメソッド呼び出しの引数がある場合のみ、add_offenseメソッドを呼び出すようになっています。

また、def_node_search というメソッドもあります。 def_node_searchは、def_node_matcherと同じようにASTのノードをマッチさせるためのメソッドですが、def_node_searchは、ASTのノードを再帰的に探索します。

たとえば、bad_methodメソッド呼び出しのノードを再帰的に探索する場合は、以下のように記述します。

def_node_search :bad_method?, <<~PATTERN
  (send nil? :bad_method ...)
end

これによって以下のようなノードにbad_methodメソッド呼び出しがあることを検出できます。

good_method(good_method(bad_method))
# ruby-parse -e 'good_method(good_method(bad_method))'
# (send nil :good_method
#   (send nil :good_method
#     (send nil :bad_method)))

自動修正の実装

前述しましたがカスタムCopを作成する際には、違反を知らせるだけでなく、違反の自動修正も行うことができます。 違反を自動修正する場合にはRubocop::Cop::AutoCorrectorモジュールをextendして自動修正をする処理を実装します。

www.rubydoc.info

# lib/rubocop/cop/andpad/your_custom_cop.rb
module RuboCop
  module Cop
    module Andpad
      class YourCustomCop < ::RuboCop::Cop::Base
        include RuboCop::Cop::AutoCorrector

        MSG = 'Use `#good_method` instead of `#bad_method`.'

        RESTRICT_ON_SEND = %i[bad_method].freeze

        def_node_matcher :bad_method?, <<~PATTERN
          (send nil? :bad_method ...)
        PATTERN

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

          add_offense(node) do |corrector|
            corrector.replace(node.loc.selector, 'good_method')
          end
        end
      end
    end
  end
end

この例では、bad_methodメソッド呼び出しを検出して、good_methodに自動修正するカスタムCopを作成しています。 add_offenseメソッドのブロック内で、corrector.replaceメソッドを使って、bad_methodメソッド呼び出しをgood_methodに置換しています。

correctorはRuboCop::Cop::Correctorクラスのインスタンスで、Parser::Source::TreeRewriterクラスを継承しています。 そのため、Parser::Source::TreeRewriterクラスのメソッドを使って、ASTのノードを書き換えることができます。

https://www.rubydoc.info/gems/parser/Parser/Source/TreeRewriterwww.rubydoc.info

よく使う操作について、以下に示します。

ノードを削除する場合

Parser::Source::TreeRewriter#remove メソッドを使用して、指定したノードを削除できます。

https://www.rubydoc.info/gems/parser/Parser%2FSource%2FTreeRewriter:removewww.rubydoc.info

たとえば、以下のコードがあるとします。

object.method_name(arg)

そして、以下の通り指定したノードを削除します。

corrector.remove(node.loc.selector)

修正後のコードは次のようになります。

object.(arg)

ノードを挿入する場合

Parser::Source::TreeRewriter#insert_before メソッドまたは Parser::Source::TreeRewriter#insert_after メソッドを使用して、ノードの前後にコードを挿入できます。

www.rubydoc.info

https://www.rubydoc.info/gems/parser/Parser%2FSource%2FTreeRewriter:insert_beforewww.rubydoc.info

たとえば、以下のコードがあるとします。

object.method_name

そして、以下の通り指定したノードの前後に文字列を挿入します。

corrector.insert_before(node.loc.expression, 'before ')
corrector.insert_after(node.loc.expression, ' after')

修正後のコードは次のようになります。

before object.method_name after

ノードを置換する場合

Parser::Source::TreeRewriter#replace メソッドを使用して、指定したノードを別の内容に置き換えることができます。

https://www.rubydoc.info/gems/parser/Parser%2FSource%2FTreeRewriter:replacewww.rubydoc.info

たとえば、以下のコードがあるとします。

object.method_name

そして、以下の通り指定したノードを置換します。

corrector.replace(node.loc.expression, 'replace')

修正後のコードは次のようになります。

object.replace

このように、RuboCop::Cop::Correctorが提供しているメソッドを活用することで、コードの違反箇所を検出するだけでなく、自動修正をするCopを作成できます。

安全な自動修正と安全でない自動修正の切り替え

RuboCopには、安全な自動修正と安全でない自動修正をマークするためのオプションがあります。 config/default.ymlSafeAutoCorrectを設定することで、安全な自動修正と安全でない自動修正を切り替えることができます。

デフォルトでは、SafeAutoCorrecttrueになっているので、安全な自動修正が有効になっています。 なので、もし自動修正が安全でない場合には、SafeAutoCorrectfalseに設定してください。

Andpad/YourCustomCop:
  SafeAutoCorrect: false

この設定をすることで、安全でない自動修正が有効になります。 つまり、rubocop -arubocop --auto-correctを実行したときにはその違反の自動修正がされないようになります。

また、この安全であるかそうでないかは、もうひとつのオプションであるSafeによっても判断されます。 Safefalseの場合も、SafeAutoCorrectと同様に安全でない自動修正が有効になります。

Andpad/YourCustomCop:
  Safe: false

SafeとSafeAutoCorrectの違いは、Safeはそのルールが安全であるかどうかを示す(つまり、違反そのものがFalse Positiveである可能性があるかどうかを示す)のに対して、SafeAutoCorrectはその違反の自動修正が安全であるかどうかを示すものです。

AutoCorrect: contextualの利用

AutoCorrect: contextualは、RuboCop v1.61 から導入された自動修正のパラメーターです。 RuboCopを通常通り実行した場合には自動修正がされますが、エディターでの編集中には自動修正がされないようにできます。 つまり、rubocop --lsprubocop --editor-modeとして実行した場合、またはRuboCop::LSP.enableが適用されている場合には自動修正がされません。

どういうときにこのパラメーターを使用するかというと、エディターでの編集中に自動修正がされると困るケースがあります。 例えば、RSpec/FocusはRSpecで特定のテストだけを実行するために提供されている機能を使用している場合に違反として検出しますが、エディターでの編集中に自動修正がされてしまうと特定のテストだけ実行できなくなってしまいます。

docs.rubocop.org

# bad
describe MyClass, focus: true do
end

# good
describe MyClass do
end

このような場合には、AutoCorrect: contextualを使用してエディターでの編集中に自動修正がされないようにできます。

RSpec/Focus:
  AutoCorrect: contextual

このようにエディターでの編集中に自動修正がされて困るケースがある場合には、AutoCorrect: contextualを設定してください。

Mixinの利用

RuboCopは、便利なMixinを提供しています。 たとえば、違反の範囲を指定するための便利なMixinとして、RuboCop::Cop::RangeHelpがあります。

www.rubydoc.info

RangeHelpを使うと、違反の範囲を指定するための便利なメソッドが使えます。 たとえば、range_betweenメソッドを使って、違反の範囲を指定できます。

# lib/rubocop/cop/andpad/your_custom_cop.rb
module RuboCop
  module Cop
    module Andpad
      class YourCustomCop < ::RuboCop::Cop::Base
        include RuboCop::Cop::RangeHelp

        MSG = 'Use `#good_method` instead of `#bad_method`.'

        RESTRICT_ON_SEND = %i[bad_method].freeze

        def_node_matcher :bad_method?, <<~PATTERN
          (send nil? :bad_method ...)
        PATTERN

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

          add_offense(node) do |corrector|
            corrector.replace(range_between(node.loc.selector.begin_pos, node.loc.selector.end_pos), 'good_method')
          end
        end
      end
    end
  end
end

その他にも便利なMixinがいくつか提供されている6ので、必要に応じて使ってみてください。

カスタムCopを導入する前に

ここまでは、カスタムCopを作成する方法について解説してきました。 無事テストも通れば今すぐにでも導入してしまいたくなるかもしれませんが、導入する前に確認しておくべきことがいくつかあります。

実際のコードでRuboCopを実行する

カスタムCopを導入する前に、実際のコードでRuboCopを実行してみましょう。 テストコードは通っていても、実際のコードでエラーが発生することがあります。

多くの場合は、自身の開発しているコードベースでRuboCopを実行してみることで、問題がないかを確認できます。 しかし、お手元に大きなコードベースがない場合は、real-world-ruby-appsなどのサンプルアプリケーションを使ってRuboCopを実行してみると良いでしょう。

real-worldシリーズのリポジトリは以下にあります。

それぞれのインストール方法はリポジトリのREADMEに記載されているので、それにしたがってインストールしてRuboCopを実行してみましょう。 ここでポイントがあって、カスタムCopを追加したプロジェクトのルートディレクトリに移動します。

$ cd path/to/your_project

そして、RuboCopを実行します。

$ bundle exec rubocop ../real-world-ruby-apps

RuboCopを実行すると、real-world-ruby-appsのコードベースに対してRuboCopが実行されます。 ただし、Rubyのバージョンが異なる場合は、シンタックスエラーが発生することがあるので、シンタックスエラーが発生した場合にはRubyのバージョンを合わせるか、エラーが発生したファイルを除外して実行すると良いでしょう。

カスタムCop導入時のTips

カスタムCopを追加した際には、既存のコードで違反が発生することがあります。 そのため、カスタムCopを導入するには「一緒に修正するコードをコミットする必要があるだろうか」という疑問が生じると思います。

しかし、RuboCopには.rubocop_todo.ymlというファイルがあり、このファイルには違反を無視するための設定が記述されています。 このファイルを使うことで、違反を無視できます。

このファイルは以下のコマンドを実行することで生成できます。

$ bundle exec rubocop --auto-gen-config

このコマンドを実行すると、.rubocop_todo.ymlが生成されます。 そして、.rubocop.ymlからinherit_fromを使って.rubocop_todo.ymlを読み込むように設定します。

inherit_from: .rubocop_todo.yml

このコマンドはさまざまなオプションがあります。 詳しくは以下のリンクを参照してください。

docs.rubocop.org

私がよく使うオプションは以下の通りです。

$ rubocop --auto-gen-config --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp --no-auto-gen-enforced-style

--no-exclude-limit

デフォルトでは、違反しているファイルが多い場合には以下のようにcop自体を無効化するようになっています。

# Offense count: 23
# Cop supports --auto-correct.
Layout/BlockEndNewline:
  Enabled: false

このオプションを使うことで、違反しているファイルが多い場合でもcop自体を無効化しないようにします。

--no-offense-counts

生成される.rubocop_todo.ymlに違反の数を記述しないようにします。 デフォルトでは、違反の数が記述されています。

# Offense count: 23
# Cop supports --auto-correct.
Layout/BlockEndNewline:
  Enabled: false

しかし、違反の数が記述されていると、違反の数が変わるたびに.rubocop_todo.ymlが変更されてしまうので、違反の数を記述しないようにすると良いでしょう。

--no-auto-gen-timestamp

生成される.rubocop_todo.ymlにタイムスタンプを記述しないようにします。 デフォルトでは、タイムスタンプが記述されています。

# This configuration was generated by
# rubocop --auto-gen-config
# on 2022-01-01 00:00:00 +0900

しかし、タイムスタンプが記述されていると、.rubocop_todo.yml--auto-gen-configで再生成されるたびにタイムスタンプが変わってしまうので、タイムスタンプを記述しないようにすると良いでしょう。

--no-auto-gen-enforced-style

一部の cop は EnforcedStyle を設定可能なオプションとして持っています。 たとえば、Style/AccessModifierDeclarations が該当します。

前述の通り--auto-gen-config を実行して、.rubocop-todo.ymlを作成する場合、以下のどちらかの通りに違反を抑制します。

  • ファイル単位で該当の cop の違反の抑制
  • cop そのものを無効化する設定を追加して抑制 (規定の違反ファイル数を超過した場合)

ですが、EnforcedStyle を持つ cop については動作が異なります。 .rubocop_todo.yml を生成する際に、そのスタイルの設定を追加することによって違反を抑制する場合があります。

例を挙げると Style/ClassAndModuleChildren の場合、以下の2つのスタイルがあります。

EnforcedStyle: nested (default)

# good
# have each child on its own line
class Foo
  class Bar
  end
end

EnforcedStyle: compact

# good
# combine definitions as much as possible
class Foo::Bar
end

すべてが compact のスタイルで書かれていた場合には auto-gen-config を実行した場合に .rubocop_todo.yml は以下のように生成します。

Style/ClassAndModuleChildren:
  EnforcedStyle: compact

通常の場合には、既に統一されているスタイルがあった場合に、それを知ることができるため便利な機能です。 しかし、--auto-gen-configしても違反が残ってしまうケースがありました。

それは Layout/SpaceInsideHashLiteralBraces の以下のようなケースです。

# test.rb
a = {

}

この場合に bundle exec rubocop --auto-gen-config を実行すると .rubocop_todo.yml は以下のように生成します。

# .rubocop_todo.yml

# Offense count: 1
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyleForEmptyBraces.
# SupportedStyles: space, no_space, compact
# SupportedStylesForEmptyBraces: space, no_space
Layout/SpaceInsideHashLiteralBraces:
  EnforcedStyle: space

この状態で再度 bundle exec rubocop を実行すると、以下の違反が残っていることがわかります。

test.rb:1:6: C: [Correctable] Layout/SpaceInsideHashLiteralBraces: Space inside empty hash literal braces detected.
a = { ...

これは確かにすべてのファイルで EnforcedStyle: space というスタイルに適合したコードなので、EnforcedStyle: space を設定することで違反を抑制しようとします。
しかし、この cop には以下の設定オプションも存在しています。

EnforcedStyleForEmptyBraces: no_space (default)

# The `no_space` EnforcedStyleForEmptyBraces style enforces that
# empty hash braces do not contain spaces.

# bad
foo = { }
bar = {    }
baz = {
}

# good
foo = {}
bar = {}
baz = {}

なので、実際には違反が残り続けるという現象が発生していました。 そのため、このようなケースも防げるように --no-auto-gen-enforced-style オプションを使用することを推奨します。

その他便利なツール

rubocop-todo-regenerator

github.com

特定のラベルを PR につけると .rubocop_todo.yml を再生成したコミットを積んでくれるカスタムアクションです。

新しいルールを追加したときはひとまず .rubocop_todo.yml に追加してルールの追加のみを実施することが多いと思います。 ただ、大きなプロジェクトだと .rubocop_todo.yml の再生成に時間がかかることがあります。

そこで、 ルールを追加するコミットを積んだ Pull Request を作ってしまって、このワークフローを起動します。 そうすることで GitHub Actions に.rubocop_todo.yml の再生成のコミットを積むのを任せることができます。

ワークフローの例は以下の通りです。

# .github/workflows/rubocop-todo-regenerator.yml
name: rubocop-todo-regenerator
on:
  pull_request:
    types:
      - labeled
jobs:
  run:
    runs-on: ubuntu-latest
    steps:
      - uses: ydah/rubocop-todo-regenerator@main
        with:
          github_token: ${{ secrets.WRITABLE_GITHUB_TOKEN }}

まとめ

さて、ここまでカスタムCopを作成する方法について解説してきました。 カスタムCopを作成することで、自身のプロジェクトに適したルールを追加できます。

作り方についても解説しましたが、便利なMixinなどは実際に似たようなことをやろうとしているCopがあるかどうかを調べると良いでしょう。 そのコードを参考にしてみると、より効率的にカスタムCopを作成できると思います。

少しでも本記事が皆様のカスタムCopを作成するための一助になれば幸いです。


  1. 著者はRuboCop RSpec Teamとして、RSpec、Capybara、FactoryBot、RSpec Rails向けのRuboCop拡張のコミッターをしています。
  2. リポジトリで他に適切な場所があればそちらに配置してもよいと思います。
  3. あまり定期的に新しいリポジトリを作成することはないので、気付いていないことが多いです。
  4. 最近使ってみたのですが、かなりいい感じにテストコードを書いてくれて感動しました。細かいところは手直しする必要があるかもしれませんが、かなり助かります。
  5. 厳密にはparser gemで構文解析後にrubocop-astというRuboCop用のAST拡張に変換しています。
  6. https://github.com/rubocop/rubocop/tree/master/lib/rubocop/cop/mixin