はじめに
こんにちは、メディアサービス開発部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
以下、カスタムルールのざっくりとした実装手順になります。
実装手順
- 雛形を作成する
- マッチャーを実装する
- on_sendコールバックを実装する
- ルールを有効化する
- 自動修正に対応する
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
let
をlet!
に修正するための処理を記述してみました。ポイントとしては 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
とメッセージが出力されております。対象のコードを確認いただくとlet
がlet!
に修正されたことが確認いただけると思います。
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がどのようにコード解析をしているのか、それを踏まえてカスタムルールを自分で実装してみる方法について説明しました。カスタムルールを実装することで、プロジェクトの特性やドメイン知識にフォーカスしたコーディングチェックができるようになります。コーディング規約のうち、レビューでよく指摘されたり見逃されてしまうものは、規約のコード化と称してカスタムルールに落とし込むことを検討してもいいかもしれません。この記事がその参考になれば幸いです。
最後に、ブックウォーカーではエンジニアを募集しています。興味がありましたらぜひ、ブックウォーカーの採用情報ページからご応募ください。
参考
-
ASTはparserをインストールすることで使えるようになる
ruby-parse
コマンドを使うことでも作成できます。こちらの方がよりライトにASTを作成できますが、デバッグのしやすさから私はRuboCop::ProcessedSource
を使うことが多いです。↩ -
デモで実装した
let
をlet!
に自動修正するルールというのは実際に私がプロダクトコードに導入したことのあるルールなのですが、これには上位互換としてすでにRspec/DialectというルールがRuboCopに存在しています。同チームのエンジニアに教えてもらい気づきましたが、例としてちょうどよいので今回はデモに採用しました。↩