やわらかテック

興味のあること。業務を通して得られた発見。個人的に試してみたことをアウトプットしています🍵

RuboCopに勝手にオレオレCopを実装してみる

前回、RuboCopの処理を追いかけました。
長い旅になりましたが、なんとかRuboCopのコマンドが実行されてから画面に結果が出力されるまでの流れを完全に理解しました。せっかくコードを読んだわけなので何かしらの変更が出来ないか試してみたいと思います。

以前、知人からRuboCopにはカスタムCopを設定できるという話を聞いたので、カスタムCopならぬ本体へのCopの追加をやってみました。 今回、追加するCopは「変数名にpが使われている際に警告を出力する」という非常に簡単なものです。

Rubyでは標準出力を行うpという関数が用意されています。
そのためp = ...という変数宣言があると、出力をしたいのか変数宣言をしたいのか分からず、個人的には非常に気持ち悪く感じるのです。

qiita.com

実装手順

あれこれ試した結果、以下の手順をこなせば新しくCopを追加することが出来ました。
意外にもやることは非常に少ないです。前回の記事で触れたようにRuboCopは拡張性に富んだ設計がされています。 変更の全貌は僕のgithubにて公開していますので、合わせてご覧ください。

github.com

  • lib/rubocop/cop/style直下に新規ファイルを追加する
  • lib/rubocop.rbに追加したCopをrequiereする
  • 追加したファイルにコールバックとOffenseを追加する条件・処理を実装

ファイル名は「違法な変数名」というニュアンスからとってillegal_variable.rbとしました。

ファイルの追加とrequire

まずはモジュールの定義だけしておきます。
他のstyle直下のCopにならってモジュールを定義しました。

lib/rubocop/style/illegal_variable.rb

# frozen_string_literal: true

module RuboCop
  module Cop
    module Style
      class IllegalVariable < Base
      end
    end
  end
end

追加したファイルを読み込むようにします。

lib/rubocop.rb

require_relative 'rubocop/cop/style/illegal_variable'
:

これで準備は完了したので、あとはコールバックとOffenseを追加する条件をゴリゴリ書けばOKです。
他のCopでは条件判定を行う処理は別のモジュールにmixinとして定義されることがありますが、今回は簡単のため、このまま同じモジュールに処理を書いてしまいます。

コールバックのオーバーライド

前回の記事で紹介したようにRuboCopはCopに対していくつかのコールバックを提供しています。
Cop側で任意のコールバックをオーバーライドすることで、チェックを行うタイミングを選択することが可能です。

  • on_new_investigation
  • on_investigation_end
  • on_other_file

今回は特に考慮したいことがないため、全てのon_...が実行される前に実行してくれるon_new_investigationを実装するようにします。
on_...の一例としてはon_ifon_whileなどがあります。名前の通り、ファイル中にifが登場すると呼び出されるコールバックになります。

class IllegalVariable < Base
  def on_new_investigation
    nil
  end
end

main.rbの用意とbinding.irbでの確認

今回、追加するStyle/IllegalVariableの条件に合致するコードを用意しました。
他のCopの条件には合致しないようにしたので、上手くIllegalVariableが実装できればIllegalVariableの警告のみが表示される想定です。

# frozen_string_literal: true

p = 'hello world'
puts p

この時点でon_new_investigationにbinding.irbを仕込んでみると無事にirbが立ち上がることを確認しました。

From: /Users/.../ruby/rubocop-reading/rubocop/lib/rubocop/cop/style/illegal_variable.rb @ line 22 :

    21:         def on_new_investigation
 => 22:           binding.irb
    23:           nil
    24:         end
    25:

Offenseを追加する条件

次にCopのエラー記録を保持するOffenseを追加する条件を記述していきます。
p = ...というようにpを使って変数宣言がされていた場合にOffenseを追加するようにします。ファイル中の情報は抽象構文木(AST)として、processed_source.tokensに保持されています。

[
  #<RuboCop::AST::Token:0x0000000105ecc970
    @pos=#<Parser::Source::Range /Users/takamizawa46/workspace/ruby/rubocop-reading/main.rb 0...29>,
    @text="# frozen_string_literal: true",
    @type=:tCOMMENT>,
  #<RuboCop::AST::Token:0x0000000105ecc858
    @pos=#<Parser::Source::Range /Users/takamizawa46/workspace/ruby/rubocop-reading/main.rb 31...32>,
    @text="p",
    @type=:tIDENTIFIER>,
  #<RuboCop::AST::Token:0x0000000105ecc830
    @pos=#<Parser::Source::Range /Users/takamizawa46/workspace/ruby/rubocop-reading/main.rb 33...34>,
    @text="=",
    @type=:tEQL>,
  #<RuboCop::AST::Token:0x0000000105ecc7b8
    @pos=#<Parser::Source::Range /Users/takamizawa46/workspace/ruby/rubocop-reading/main.rb 35...48>,
    @text="hello world",
    @type=:tSTRING>,
]

main.rbにてp = 'hello world'と宣言した箇所は以下、3つのtokenに分割されています。

  • p(tIDENTIFIER)
  • =(tEQL)
  • hello world(tSTRING)

tokenを利用してpの後に=が続いていれば、Copの条件に合致したと判定するようにします。

start_p_lines = processed_source.tokens.filter_map.with_index do |token, i|
  if token.type == :tIDENTIFIER && token.text == 'p'
    next_token = processed_source.tokens[i + 1]
    next_token.type == :tEQL && next_token.text == '=' && token.line
  end
end

ファイル中にp = ...のような記述がある場合、後の処理のために定義された箇所の行数が返るようにしました。
当然、複数の宣言がされる可能性があるため、戻り値は配列です。この戻り値の要素数が0より大きければCopの条件に合致したことになります。

def on_new_investigation
  start_p_lines = processed_source.tokens.filter_map.with_index do |token, i|
    if token.type == :tIDENTIFIER && token.text == 'p'
      next_token = processed_source.tokens[i + 1]
      next_token.type == :tEQL && next_token.text == '=' && token.line
    end
  end

  return if start_p_lines.size.zero?
end
irb> start_p_lines
=> [3]

Offenseの追加

あとはOffenseを追加するのみです。
他のCopにならってOffenseを追加するようにしました。結果的に、ほとんど既存のCopと同じ処理になりました。

class IllegalVariable < Base
  include RangeHelp

  MSG_DETECTED = "Not recommend using 'p' as a variable because it can be confused with the standard output function 'p'."

  def on_new_investigation
    :
    missing_offense(processed_source, start_p_lines)
  end

  private

  def missing_offense(processed_source, start_p_lines)
    start_p_lines.each do |line|
      range = source_range(processed_source.buffer, line, line)
      add_offense(range, message: MSG_DETECTED)
    end
  end
end

これで全ての実装が完了しました。

いざ動作確認

さっそくrubocopコマンドを実行してみます。どうなるでしょうか...。

$ bundle exec rubocop main.rb

Inspecting 1 file                                                                                                           
C                                                                                                                           
                                                                                                                            
Offenses:                                                                                                                   

main.rb:3:4: C: Style/IllegalVariable: Not recommend using 'p' as a variable because it can be confused with the standard output function 'p'.
p = 'hello world'
   ^

1 file inspected, 1 offense detected

無事に新しく追加したCopであるIllegalVariableの警告が出力されました。やりました。
続いてmain.rbを更新してp = ...の宣言を複数、行ってみます。無事に複数の警告が出力されるでしょうか...。

# frozen_string_literal: true

p = 'hello world'
puts p

p = 'hello world'
puts p

p = 'hello world'
puts p

$ bash run-rubocop.sh 
Inspecting 1 file
C

Offenses:

main.rb:3:4: C: Style/IllegalVariable: Not recommend using 'p' as a variable because it can be confused with the standard output function 'p'.
p = 'hello world'
   ^
main.rb:6:7: C: Style/IllegalVariable: Not recommend using 'p' as a variable because it can be confused with the standard output function 'p'.
p = 'hello world'
      ^
main.rb:9:10: C: Style/IllegalVariable: Not recommend using 'p' as a variable because it can be confused with the standard output function 'p'.
p = 'hello world'
         ^

1 file inspected, 3 offenses detected

複数、宣言しても問題なく警告が出力されました。問題なさそうですね。
無事に前回、RuboCopのコードを読んだ得た知識を元に、Copの追加ができました。非常に嬉しいです。
これがRuboCop本体への機能追加であれば、specの追加も行うべきですが、今回は省略しています。

少しでも「ええな〜」と思ったらイイネ!・シェア!・はてなブックマークを頂けると励みになります。