RuboCopにカスタムルールを実装する方法について

はじめに

こんにちは、メディアサービス開発部Webアプリケーション開発課のシゲタです。

本記事ではRubocopにカスタムルールを実装する方法について解説します。

Rubyにはコードの静的解析やフォーマットをしてくれるRuboCopというgemが存在します。リンターとして標準的なコーディングスタイルを提案してくれるRuboCopですが、自分でカスタムルールを実装してRuboCopの静的解析に追加することもできます。

チーム開発におけるデファクトスタンダードでもあるRuboCopですが、そのルールを自分で実装したことがある方はおそらく少ないのではないでしょうか。かくいう私も先日初めてカスタムルールを実装してみる機会がありました。

いくつか実装することでRuboCopの仕組みやコード解析について学びがあったり、ルール実装の勘所がわかってきたので、今回併せてご紹介できればと思います。

RuboCopのコード解析について

RuboCopはコードをASTというデータ構造に変換して、ルールに違反しているかどうか検証をしています。

RuboCop::ProcessedSource

ASTの作成にはいくつか手段がありますが、RuboCopに実装されているRuboCop::ProcessedSourceクラスを用いることでコードからASTを作成することができます。

require 'rubocop'

code = 'let(:hoge) { create(:hoge) } '
source = RuboCop::ProcessedSource.new(code, RUBY_VERSION.to_f)
p node = source.ast

# s(:block,
#   s(:send, nil, :let,
#     s(:sym, :hoge)),
#   s(:args),
#   s(:send, nil, :create,
#     s(:sym, :hoge)))

ASTが出力されました。s()で囲まれたのがASTのノードになります。blockやsendというのはノードの種類で、以下のような意味を表しています。

  • block
    • ブロック表記
  • send
    • メソッド呼び出し
  • args
    • 引数のリスト
  • sym
    • シンボル

ここにあげたもの以外にも様々なノードタイプが存在しており、公式ドキュメントから一覧することができます。

ファイルを読み込んでASTを作成する

ファイルを読み込んでASTを作成したい場合はFileクラスを使います。適当なファイルからASTを作成してみます。

# fizzbuzz.rb

def fizzbuzz(n)
  (1..n).each do |num|
    if (num % 3).zero? && (num % 5).zero?
      puts 'FizzBuzz'
    elsif (num % 3).zero?
      puts 'Fizz'
    elsif (num % 5).zero?
      puts 'Buzz'
    else
      puts num
    end
  end
end

fizzbuzz(20)
file = File.open('fizzbuzz.rb')
source = RuboCop::ProcessedSource.new(file.read, RUBY_VERSION.to_f)
p source.ast

# s(:begin,
#     s(:def, :fizzbuzz,
#       s(:args,
#         s(:arg, :n)),
#       s(:block,
#         s(:send,
#           s(:begin,
#             s(:irange,
#               s(:int, 1),
#               s(:lvar, :n))), :each),
#         s(:args,
#           s(:arg, :num)),
#         s(:if,
#           s(:and,
#             s(:send,
#               s(:begin,
#                 s(:send,
#                   s(:lvar, :num), :%,
#                   s(:int, 3))), :zero?),
#             s(:send,
#               s(:begin,
#                 s(:send,
#                   s(:lvar, :num), :%,
#                   s(:int, 5))), :zero?)),
#           s(:send, nil, :puts,
#             s(:str, "FizzBuzz")),
#           s(:if,
#             s(:send,
#               s(:begin,
#                 s(:send,
#                   s(:lvar, :num), :%,
#                   s(:int, 3))), :zero?),
#             s(:send, nil, :puts,
#               s(:str, "Fizz")),
#             s(:if,
#               s(:send,
#                 s(:begin,
#                   s(:send,
#                     s(:lvar, :num), :%,
#                     s(:int, 5))), :zero?),
#               s(:send, nil, :puts,
#                 s(:str, "Buzz")),
#               s(:send, nil, :puts,
#                 s(:lvar, :num))))))),
#     s(:send, nil, :fizzbuzz,
#       s(:int, 20)))

10数行のfizzbuzzを書いたメソッドではありますが、出力されたASTは複雑ですね。ご興味があれば普段開発しているプロダクトコードからASTを作成して眺めてみると面白いかもしれません。1

コードを探索する

RuboCopにはノードパターンという機能が存在します。これは正規表現のようなものでして、ノードパターンを用いることで特定のASTにマッチするコードの探索や取得を行うことができます。

例として以下のASTにマッチするようなノードパターンをいくつか書いてみます。

require 'rubocop'

code = 'let(:hoge) { create(:hoge) }'
source = RuboCop::ProcessedSource.new(code, RUBY_VERSION.to_f)

p node = source.ast
# s(:block,
#   s(:send, nil, :let,
#     s(:sym, :hoge)),
#   s(:args),
#   s(:send, nil, :create,
#     s(:sym, :hoge)))
  • 先頭ノードが特定のタイプであるか
p RuboCop::AST::NodePattern.new('(block ...)').match(node) # true
p RuboCop::AST::NodePattern.new('(send ...)').match(node) # nil
  • 子要素に特定のノードが含まれているかどうか
RuboCop::AST::NodePattern.new('`(:sym :hoge)').match(node) # true
RuboCop::AST::NodePattern.new('`args').match(node) # true
  • 特定のメソッドが使われているかどうか
p RuboCop::AST::NodePattern.new('`(send _ :create _ )').match(node) # true
p RuboCop::AST::NodePattern.new('`(send _ :build _ )').match(node)  # nil
  • パターンにマッチするコードの取得
p RuboCop::AST::NodePattern.new('(block $... )').match(node)
# [s(:send, nil, :let,
#   s(:sym, :hoge)), s(:args), s(:send, nil, :create,
#   s(:sym, :hoge))]

RuboCop::AST::NodePattern#matchは引数に渡したノードがパターンにマッチすればtrueまたはノードの一部を返し、マッチしなければnilを返します。また、_ ,, ` , $というのがノードパターンにおいて使われる記号で、以下のような意味を持っています。

  • _
    • 単一の任意ノードのマッチ
    • 単一または複数の任意ノードにマッチ
  • `
    • 子要素が特定のASTで構成されているノードにマッチ
  • $
    • マッチしたノードを取得する

その他にもノードパターンを表現するための記号が存在しております。詳細は公式ドキュメントを参照ください。

カスタムルールを実装する

前項までにRuboCopがASTを用いてコード解析を行なっていること、ノードパターンを用いてASTの探索ができることを説明しました。本項ではカスタムルールの実装方法について解説しつつ、デモとして実際にカスタムルールを実装してみたいと思います。今回は、以下rspecのspecファイルからlet メソッドが使用されていることを検知し、let! へ自動修正するルールを実装してみます。

# demo_spec.rb

require 'rails_helper'

RSpec.describe HogeController do
  context 'xxxxxxxxxxxxxxxxxx' do
    let :num1 do
      1
    end
    
    let(:num2) { 2 }

    it '1 + 2 = 3' do
      expect(num1 + num2).to eq(3)
    end
  end
end

以下、カスタムルールのざっくりとした実装手順になります。

実装手順

  1. 雛形を作成する
  2. マッチャーを実装する
  3. on_sendコールバックを実装する
  4. ルールを有効化する
  5. 自動修正に対応する

1. 雛形を作成する

まずはルールを実装するための雛形を作ります。以下を適当なファイルとして保存します。

# rubocop/custom_cop/must_use_let_exclamation.rb

module RuboCop
  module CustomCop
    class MustUseLetExclamation < RuboCop::Cop::Base
      MSG = 'Must use `let!` instead of `let` in rspec.'
      RESTRICT_ON_SEND = [:let].freeze

      def_node_matcher :use_let?, <<~PATTERN
        (something node pattern)
      PATTERN

      def on_send(node); end
    end
  end
end

ポイント

  • カスタムルールとして定義するクラスはRuboCop::Cop::Baseを継承する必要がある
  • クラス名の一つ上の名前空間/クラス名がルール名になる
    • 今回であればCustomCop/MustUseLetExclamation
  • 以下の定数とメソッドの定義が必要
    • MSG
      • ルールに違反した場合に出力されるメッセージ
    • on_XXXコールバック
      • XXXで指定するタイプのASTが使用された場合に実行されるコールバックメソッド
      • on_send, on_blockなどタイプごとにコールバックが用意されている
    • RESTRICT_ON_SEND
      • on_sendの呼び出しを配列に指定したメソッドに制限する
      • on_sendコールバックを使用しない場合は定義不要
    • def_node_matcher
      • ノードパターンのマッチャーを定義するためのメソッド

2. マッチャーを実装する

letが使用されているかどうか判定するためのマッチャーを実装します。マッチャーはdef_node_matcherを使って定義することができます。

def_node_matcher :use_let?, <<~PATTERN
  (something node pattern)
PATTERN

第一引数には定義するマッチャーの名前、第二引数にはASTを探索するためのノードパターンを指定します。今回は対象コードにletが存在するか判定するためのノードパターンを第二引数に書くことになります。

取り敢えず、対象コードがどのようなASTで構成されているのか調べてみます。

file = File.open('./demo_spec.rb')
source = RuboCop::ProcessedSource.new(file.read, RUBY_VERSION.to_f)
p source.ast

# s(:begin,
#     s(:send, nil, :require,
#       s(:str, "rails_helper")),
#     s(:block,
#       s(:send,
#         s(:const, nil, :RSpec), :describe,
#         s(:const, nil, :HogeController)),
#       s(:args),
#       s(:block,
#         s(:send, nil, :context,
#           s(:str, "xxxxxxxxxxxxxxxxxx")),
#         s(:args),
#         s(:begin,
#           s(:block,
#             s(:send, nil, :let,
#               s(:sym, :num1)),
#             s(:args),
#             s(:int, 1)),
#           s(:block,
#             s(:send, nil, :let,
#               s(:sym, :num2)),
#             s(:args),
#             s(:int, 2)),
#           s(:block,
#             s(:send, nil, :it,
#               s(:str, "1 + 2 = 3")),
#             s(:args),
#             s(:send,
#               s(:send, nil, :expect,
#                 s(:send,
#                   s(:send, nil, :num1), :+,
#                   s(:send, nil, :num2))), :to,
#               s(:send, nil, :eq,
#                 s(:int, 3))))))))

ASTが出力されました。このままだとよくわからないのでletに該当するノードを切り出してみます。

s(:block,
  s(:send, nil, :let,
    s(:sym, :num1)),
  s(:args),
  s(:int, 1)),

s(:block,
  s(:send, nil, :let,
    s(:sym, :num2)),
  s(:args),
  s(:int, 2)),

ここから、letのASTは以下のパターンで構成されていることがわかります。

s(:send, nil, :let,
  s('任意のノード'))

これにマッチするようなノードパターンを書いてdef_node_matcherの第二引数に渡します。

def_node_matcher :use_let?, <<~PATTERN
  (send nil? :let
    $(...))
PATTERN

上記ノードパターンについて解説しますと、sendは引数にレシーバ, メソッド名, メソッドの引数を取ります。letのレシーバはAST上ではnilとなっているので、nilを判定するためにnil?を使います。そして、メソッドの引数は任意のノードなので$(…)としました。

3. on_sendコールバックを実装する

続いてon_sendコールバックを実装します。on_sendはsendノードに対して実行されるコールバックメソッドです。ここには、引数に渡されたノードがルールに違反しているか判定する一連の処理を記述します。

先程定義したuse_let?マッチャーを使いon_sendメソッドを実装すると以下のようになります。

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

  add_offense(node)
end

先ほどdef_node_matcherで定義したマッチャーであるuse_let?でノードがパターンにマッチしているか判定し、マッチしていればadd_offenseでルールに違反したノードを追加します。

4. ルールを有効化する

コードがルールに違反しているかどうか検証するためのカスタムルールとしてはこれで完成です。 現時点でファイルは以下のようになっていると思います。

module RuboCop
  module CustomCop
    class MustUseLetExclamation < RuboCop::Cop::Base
      MSG = 'Must use `let!` instead of `let` in rspec.'
      RESTRICT_ON_SEND = [:let].freeze

      def_node_matcher :use_let?, <<~PATTERN
        (send nil? :let
          $(...))
      PATTERN

      def on_send(node)
        return unless use_let?(node)
      
        add_offense(node)
      end      
    end
  end
end

rubocop.yamlで実装したルールを有効化して、rubocopコマンドで動作確認してみます。

# rubocop.yml

require:
  - ./rubocop/custom_cop/must_use_let_exclamation

CustomCop/MustUseLetExclamation:
  Enabled: true
$ bundle ex rubocop ./demo_spec.rb

Inspecting 1 file
C

Offenses:

demo_spec.rb:9:5: C: CustomCop/MustUseLetExclamation: Must use let! instead of let in rspec.
    let :num1 do
    ^^^^^^^^^
demo_spec.rb:13:5: C: CustomCop/MustUseLetExclamation: Must use let! instead of let in rspec.
    let(:num2) { 2 }
    ^^^^^^^^^^

1 file inspected, 2 offenses detected

let :num1, let :num2に対してlet!の使用を促すメッセージを出力することができました。意図通りに実装できていそうです。

5. 自動修正に対応する

最後に自動修正にも対応できるようにして終わりたいと思います。

自動修正に対応するためにはRuboCop::Cop::AutoCorrectorをextendをする必要があります。また自動修正によって修正されたい内容はadd_offenceブロックに記述します。

add_offenceブロックでは引数として、コードを修正するための各種メソッドが定義されたRuboCop::Cop::Correctorインスタンスを受け取ることができるので、これを使って修正内容を記述していきます。

add_offense(node) do |corrector|
  content = node.parenthesized? ? "let!(#{expression.source})" : "let! #{expression.source}"

  corrector.replace(node, content)
end

letlet!に修正するための処理を記述してみました。ポイントとしては corrector.replaceを使っていることです。これによってノードを修正したい内容で書き換えています。corrector には他にもswap, before_insert, remove_trailingのようなメソッドが実装されておりまして、普段私たちが利用しているRuboCopのルールではこれらメソッドによってコードの書き換えが行われています。

content = node.parenthesized? ? "let!(#{expression.source})" : "let! #{expression.source}"

の一文は、letのlet(:hoge) { ... } , let :hoge do ... end という()の有無に伴う2パターンの記述に対応したものです。node.parenthesized?letの後に() がついているかどうか判定して、それに合わせて修正内容を分岐させています。

上記変更によって最終的なコードは以下のようになります。

module RuboCop
  module CustomCop
    class MustUseLetExclamation < RuboCop::Cop::Base
      extend RuboCop::Cop::AutoCorrector

      MSG = 'Must use `let!` instead of `let` in rspec.'
      RESTRICT_ON_SEND = [:let].freeze

      def_node_matcher :use_let?, <<~PATTERN
        (send nil? :let
          $(...))
      PATTERN

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

        add_offense(node) do |corrector|
          content = node.parenthesized? ? "let!(#{expression.source})" : "let! #{expression.source}"
          corrector.replace(node, content)
        end
      end
    end
  end
end

意図通り実装できているのかrubocopコマンドに-aオプションをつけて動作確認してみます。

$ rubocop ./demo_spec.rb -a

Use parallel by default.
Skipping parallel inspection: only a single file needs inspection
Inspecting 1 file

C

Offenses:

demo_spec.rb:9:5: C: [Corrected] CustomCop/MustUseLetExclamation: Must use let! instead of let in rspec.
    let :num1 do
    ^^^^^^^^^
demo_spec.rb:13:5: C: [Corrected] CustomCop/MustUseLetExclamation: Must use let! instead of let in rspec.
    let(:num2) { 2 }
    ^^^^^^^^^^

1 file inspected, 2 offenses detected, 2 offenses corrected
Finished in 0.34313399996608496 seconds

2 offenses detected, 2 offenses corrected とメッセージが出力されております。対象のコードを確認いただくとletlet!に修正されたことが確認いただけると思います。

require 'rails_helper'

RSpec.describe HogeController do
  context 'xxxxxxxxxxxxxxxxxx' do
    let! :num1 do
      1
    end

    let!(:num2) { 2 }

    it '1 + 2 = 3' do
      expect(num1 + num2).to eq(3)
    end
  end
end

これにて自動修正に対応したカスタムルールの実装は完了になります。2

まとめ

本記事はRuboCopがどのようにコード解析をしているのか、それを踏まえてカスタムルールを自分で実装してみる方法について説明しました。カスタムルールを実装することで、プロジェクトの特性やドメイン知識にフォーカスしたコーディングチェックができるようになります。コーディング規約のうち、レビューでよく指摘されたり見逃されてしまうものは、規約のコード化と称してカスタムルールに落とし込むことを検討してもいいかもしれません。この記事がその参考になれば幸いです。

最後に、ブックウォーカーではエンジニアを募集しています。興味がありましたらぜひ、ブックウォーカーの採用情報ページからご応募ください。

参考


  1. ASTはparserをインストールすることで使えるようになるruby-parseコマンドを使うことでも作成できます。こちらの方がよりライトにASTを作成できますが、デバッグのしやすさから私はRuboCop::ProcessedSourceを使うことが多いです。
  2. デモで実装したletlet!に自動修正するルールというのは実際に私がプロダクトコードに導入したことのあるルールなのですが、これには上位互換としてすでにRspec/DialectというルールがRuboCopに存在しています。同チームのエンジニアに教えてもらい気づきましたが、例としてちょうどよいので今回はデモに採用しました。