Rubocop でカスタムルールを作る

こんにちは。
マネーフォワードでサーバーエンジニアをやっている江口です。

先日、開発中のプロダクトに Rubocop のカスタムルールを追加するチャレンジをしてみました。
その中で、手を動かしながら調べた内容をご紹介したいと思います。

Rubocop を全く知らない方でも読み物になるように、大まかな概要をおさらいしたうえで、本題に入ります。

Rubocop とは

プログラマは誰しも、わかりやすさのため、あるいは効率化のために、自分なりのルールを守ってプログラムを書いています。わざわざ明示的に定めていないかもしれませんが、下記のようなルールはどのようなプログラムであっても満たすべきでしょう。

  • インデントを揃える
  • 未使用変数を使わない
  • if 文で2回以上同じ分岐をしない

Rubocop は、Ruby プログラムがこのようなルールを満たしているかチェックするツールです。変更を加えるときに Rubocop のチェックを通すことで、プログラムが劣化するのをある程度回避できます。特に、チームで開発している場合には、レビュアーの負担が軽くなることでしょう。一部のルールについては自動修正にも対応しているため、手作業で直す手間を省くこともできます。

Rubocop の使い方

試しに、未使用変数を含む、簡単なプログラムを書いてみましょう。

hoge = 10
fuga = 20

puts(fuga)

これを rubocop コマンドに与えます。

rubocop test.rb

このコマンドは、ファイルの中身を解析し、問題のあった行を出力します。実際に試してみた結果は下記のとおりです。未使用変数を正しく検出できていることがわかります。

Inspecting 1 file
W

Offenses:

test.rb:1:1: W: Lint/UselessAssignment: Useless assignment to variable - hoge.
hoge = 10
^^^^

1 file inspected, 1 offense detected

Custom Cop の雛形

Rubocop でカスタムルール(Custom Cop)を作るには一定のインターフェースを満たすクラスを作る必要があります。

  • RuboCop::Cop::Base を継承したクラスを作る
  • エラーメッセージの定数 MSG を定義する
  • そのクラスに on_xxx フックを実装する(構文解析時に実行されるメソッド)
    • 違反しているときは add_offense メソッドを実行する

具体例は下のとおりです。

class RuboCop::Cop::Style::Dame < RuboCop::Cop::Base
  MSG = 'ダメです!'

  def check_custom_rule(node)
    false # あとで実装する
  end

  def on_send(node)
    return unless check_custom_rule(node)

    add_offense(node)
  end
end

このクラスはあるルールに違反すると、「ダメです!」というメッセージを返します。ルールを実装するには rubocop の構文解析とパターンマッチについて理解する必要があります。

なお、この雛形を作成するときには rubocop-extension-generator という gem に組み込まれている rake コマンドを使えば Custom Cop の雛形を作ることができます。しかし、これは汎用的な Rubocop プラグインを作る前提となっているため、ここでは利用しないことにします。

Rubocop の構文解析

RuboCop は parser という gem を使っています。これは ruby スクリプトを読み取りその抽象構文木(abstract syntax tree = AST) を作るライブラリです。parser には実行可能形式のコマンド ruby-parse が用意されていて下のようにして試すことができます。

ruby-parse -e "1" # => (int 1)

これは 1 だけからなる ruby スクリプトが AST ではただ一つのノード (int 1) で表現されることを表しています。parser は AST の一つのノードをカッコで表現します。そしてカッコの最初にそのノードの種類を出力します。その後は1つ以上の値が続きます。

 (ノードの種類 値1 ...)

いくつか例をみてみましょう。

ruby のコード parser の出力した AST
100 (int 100)
“john” (str “john”)
“john”.length (send (str “john”) :length)
[1,2,3] (array (int 1) (int 2) (int 3))
size = 10 (lvasgn :size (int 10))
size = 10; size (begin (lvasgn :size (int 10)) (lvar :size))
Math::PI (const (const nil :Math) :PI)
size * 2 (send (lvar :size) :* (int 2))
def nop; 1; end (def :nop (args) (int 1))

ノードの種類は整数、文字列、メソッド呼び出し、変数への代入、変数参照、定数、関数定義などがあります。ちなみに ruby-parse はワンライナーで書く必要はなく、ファイルを受け取る事もできます。手元にある適当な ruby のファイルを ruby-parser に与えてみてください。どんなに複雑なプログラムであっても正しく AST が構築されることを確認できます。

Rubocop のパターンマッチ

Rubocop では NodePattern という表現方法を使って AST にパターンマッチさせます。これは、AST に対する正規表現のようなものです。正規表現は文字列にマッチする文字列ですが、NodePattern は AST にマッチする文字列です。

たとえば “send” は最もかんたんな NodePattern の一つです。このパターンは、メソッド呼び出しのノードとマッチします。適当なプログラムを与えて “send” とマッチするかどうかを調べてみます。

require "rubocop"

# @param [String] patern 判定する NodePattern
# @param [String] source_code 判定するコード
# 与えられたパターンがコードのAST とマッチするかどうか判定する
def match?(pattern, source_code)
  # 実装は Custom Cop の利用とはさほど関係がないので読み飛ばしてください
  node_pattern = RuboCop::AST::NodePattern.new(pattern)
  node = RuboCop::ProcessedSource.new(source_code, RUBY_VERSION.to_f).ast
  node_pattern.match(node)
end

match?("send", "100")             #=> nil
match?("send", "Math::PI")        #=> nil
match?("send", "'john'.length")   #=> true
match?("send", "1 + 1")           #=> true

パターン “send” は、整数リテラルや定数とマッチしません。なぜなら、メソッド呼び出しではないからです。一方、”send” と文字列リテラルに対する length メソッドの呼び出しはマッチします。同じように 1 + 1 もマッチします。なぜなら、このプログラムは + というメソッドを呼び出すからです。

“send” と同じように “int” や “const” も最も短い NodePattern のひとつです。

match?("int", "100")              #=> true
match?("const", "Math::PI")       #=> true

より複雑なパターンを見ていきましょう。カッコで囲われたパターンは AST の文字列表現に一致するとき true を返します。

match?("(int 100)", "100")        #=> true
match?("(int 10)", "100")         #=> nil

ノードのうち、関心のない部分には … を使うことで任意要素とマッチすることができます。

match?("(int ...)", "100")        #=> true
match?("(int ...)", "10")         #=> true

match?("(send ... :length) ", "array.length")    #=> true
match?("(send ... :length) ", "'john'.length")   #=> true
match?("(send ... :length) ", "length * weight") #=> nil

1つ目のパターン “(int …)” はすべての整数リテラルとマッチします。2つ目のパターン “(send … :length)” はメソッド length の呼び出しとマッチします。いかなるレシーバであってもマッチします。最後の例は lenght メソッドを呼び出していないためマッチしません。

$… を使うことでマッチしたコードの一部を取り出す事ができます。

match?("(send $...)", "Array.new")       #=> [s(:const, nil, :Array), :new]
match?("(send (...) $...)", "Array.new") #=> [:new]
match?("(send $... :new)", "Array.new")  #=> [s(:const, nil, :Array)]

1つ目の例は、メソッド呼び出しのレシーバ、メソッド名を取得します。2つ目の例はメソッド名だけ取得します。最後の例はレシーバだけを取得します。なお、ここで出力された小文字の s は内部表現で AST ノードを表しています。

Custom Cop の実装

これまでに勉強したパターンマッチを使って試しに !array.empty? を禁止するというルールを作成してみます。禁止する理由は、より短いコード array.any? で表現できるからです。 !array.empty? にマッチする NodePattern はどうなるでしょうか。このコードが empty? メソッドと ! メソッドの呼び出しであること。そして、レシーバに関心がないことに着目すると (send (send (...) :empty?) :!) と表現できることがわかります。

このパターンを使って判定を行う Custom Cop は下記のようになります。

class RuboCop::Cop::Style::SimplifyNotEmptyWithAny < RuboCop::Cop::Base
  def_node_matcher :not_empty_call?, "(send (send (...) :empty?) :!)"

  MSG = 'ダメです!'
  RESTRICT_ON_SEND = [:!]

  # rubocop-ast で定義されたフック。ノードの種類が send であるノードに対して実行する。
  def on_send(node)
    return unless not_empty_call?(node)

    add_offense(node)
  end
end

def_node_matcher は第一引数をメソッド名、第二引数を NodePattern にとります。そして、そのパターンに一致するかどうか判定するメソッドを定義します。定義したメソッドを使って on_send の内部で判定しています。

定数 RESTRICT_ON_SEND は最適化のための特別な配列です。この中に含まれるメソッドが呼び出されたときだけ on_send を実行するように制限します。この制限がない場合、すべてのメソッドに対して on_send を呼び出し、パターンマッチの計算を行うために実行時間が増えてしまいます。今回のケースでは、最も外側にあるメソッド ! を発見したときだけ on_send を行うようにして実行時間を減らします。

定義した Custom Cop はとりあえず ./lib/rubocop/cop/style/simplify_not_empty_with_any.rb に保存しましょう。これで準備ができました。確認のため、わざと Custom Cop に違反しているテストファイル test.rb を作成します。内容は特に意味はありませんが、Custom Cop 以外の違反が出ないようにしてあります。

hoge = (1..10).to_a

if hoge.is_a?(Array) && !hoge.empty?
  puts hoge.length
  puts hoge
end

そして、下記のコマンドを実行します。

rubocop test.rb --require ./lib/rubocop/cop/style/simplify_not_empty_with_any.rb

下記の結果になりました。カスタムルール Style/SimplifyNotEmptyWithAny による検査が行われ、違反箇所が見つかっていることがわかります。

Inspecting 1 file
C

Offenses:

test.rb:3:12: C: Style/SimplifyNotEmptyWithAny: ダメです!
if hoge.is_a?(Array) && !hoge.empty?
                        ^^^^^^^^^^^^

1 file inspected, 1 offense detected

毎回 –require を書くのは面倒なので設定ファイル .rubocop.yml に下記の内容を追記します。

require:
  - ./lib/rubocop/cop/style/simplify_not_empty_with_any

Style/SimplifyNotEmptyWithAny:
  Enabled: true

オプションなしで rubocop コマンドを実行するだけで Custom Cop を毎回実行するようになります。

Custom Cop を auto-correct に対応させる

ビルドイン Cop のいくつかは auto-correct 機能を備えています。rubocop 実行時に引数を与えることで、違反箇所を自動的に修正します。

rubocop --auto-correct

先程定義した Style/SimplifyNotEmptyWithAny を auto-correct に対応させましょう。Custom Cop に2つの修正を加えます。

  • RuboCop::Cop::AutoCorrector モジュールを extend する
  • メソッド add_offence にブロックを与えて違反箇所のソースコードを修正する
class RuboCop::Cop::Style::SimplifyNotEmptyWithAny < RuboCop::Cop::Base
  extend RuboCop::Cop::AutoCorrector

  def_node_matcher :match?, '(send (send $(...) :empty?) :!)'

  MSG = 'ダメです!'
  RESTRICT_ON_SEND = [:!]

  def on_send(node)
    matched = match?(node)

    return unless matched

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

NodePattern で $(...) を利用してレシーバーを取り出し、変数 matched に代入しています。そうして得たレシーバーを使って add_offence のブロックの中で、ソースコードを !array.empty? から array.any? に置き換えています。ソースコードの置き換えは構文木の状態で行う必要があるため Parser::Source::TreeRewriter を使います。主に下記のメソッドを使用します。

メソッド 意味
#replace(node, content) node を content で置き換えます
#insert_after(node, content) node の末尾に content を付け足します
#insert_before(node, content) node の先頭に content を付け足します
#wrap(node, insert_before_content, insert_after_content) insert_after と intert_before を同時に行います

node は AST ノードで、content は ruby プログラムの文字列であることに注意してください。使用例は下記のとおりです。

前節と同様に rubocop コマンドを実行してみましょう。

Inspecting 1 file
C

Offenses:

test.rb:3:12: C: [Correctable] Style/SimplifyNotEmptyWithAny: ダメです!
if hoge.is_a?(Array) && !hoge.empty?
                        ^^^^^^^^^^^^

1 file inspected, 1 offense detected, 1 offense auto-correctable

エラーが変化し [Correctable] のラベルが追加され、メッセージの最後に auto-correctable と追記されました。続いて rubocop --auto-correct コマンドを実行します。

Inspecting 1 file
C

Offenses:

test.rb:3:25: C: [Corrected] Style/SimplifyNotEmptyWithAny: ダメです!
if hoge.is_a?(Array) && !hoge.empty?
                        ^^^^^^^^^^^^

1 file inspected, 1 offense detected, 1 offense corrected

自動修正が正しく機能しました。ファイルの中身もきちんと置き換えられています。

さいごに

Rubocop を使って、Custom Cop を作り、適用した上で、自動修正機能をつける方法までを紹介しました。 ここまでくれば、本家 Rubocop に Custom Cop を取り込んでもらうプルリクエストも作れるかもしれません。 Custom Cop のテストの書き方や、gem を使った開発など、より詳しい内容はRubocop ドキュメントの記事を読んでみてください。


マネーフォワードでは、エンジニアを募集しています。
ご応募お待ちしています。

【サイトのご案内】
マネーフォワード採用サイト
Wantedly
京都開発拠点

【プロダクトのご紹介】
お金の見える化サービス 『マネーフォワード ME』 iPhone,iPad Android

ビジネス向けバックオフィス向け業務効率化ソリューション 『マネーフォワード クラウド』

おつり貯金アプリ 『しらたま』

お金の悩みを無料で相談 『マネーフォワード お金の相談』

だれでも貯まって増える お金の体質改善サービス 『マネーフォワード おかねせんせい』

金融商品の比較・申し込みサイト 『Money Forward Mall』

くらしの経済メディア 『MONEY PLUS』

Pocket