【Part1 / 2】RuboCopの結果が出力されるまでの実装を追ってみる

※この記事はRuboCopの内部実装を読んでみるシリーズの第1記事です。

ある日のこと、いつものようにRuboCopに怒られた際に「RuboCopってどうやって作られてるのかな...」と唐突に気になってしまったのでコードを読んでみることにしました。最初は数日あれば終わるかなと思って始めたのですが、甘かったです...。気づけば二週間も時間が過ぎていました。

全てを詳細に紹介することは情報量の都合上、厳しいのでコアの部分を中心に紹介します。
途中、処理が飛んだりしますが気になった方はぜひ自分でコードを読んでみてください。RuboCopのコードを読むに当たってローカル環境でbinding.irbを仕込むと非常に捗るので前回の記事も、合わせてご覧ください。

www.okb-shelf.work

バージョンなど

  • Ruby: 3.2.2 ( arm64-darwin22 )
  • RuboCop: 1.52.1( localにダウンロードして読み込み )

解析するファイル(main.rb)

puts "hello world!"

ターミナル経由でRuboCopを実行

$ bundle exec rubocop main.rb

Offenses:                                                                                     
                                                                                              
main.rb:1:1: C: [Correctable] Style/FrozenStringLiteralComment: Missing frozen string literal comment.
puts "hello world!"                                                                           
^                                                                                             
main.rb:1:6: C: [Correctable] Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.
puts "hello world!"                                                                           
     ^^^^^^^^^^^^^^                                                                           
                                                                                              
1 file inspected, 2 offenses detected, 2 offenses autocorrectable

ターミナルからRubocopが実行された時に呼ばれる処理

先ほど記載したようにrubocopコマンド経由でファイルの解析を行なってみます。
rubocopコマンドを実行した際に、最初に呼び出されるのは一体、どこのファイルの処理なのでしょうか。lib/rubocop.rbにbinding.irbを仕込んで処理を追っていくとrubocop/exe/rubocopが呼び出されていることが判明しました。

そして、RuboCop::CLIクラスのrunが呼び出されることでコアの処理の実行が始まります。

cli = RuboCop::CLI.new
time = Benchmark.realtime { exit_status = cli.run }

rubocop/exe/rubocop at v1.52.1 · rubocop/rubocop · GitHub

runの連続(cli.runからrunner.runまで)

cli.runが実行されてからrunner.runが呼び出されるまでを見ていきます。
道中、さまざまなメソッドを経由していますが、1つ1つ紹介すると、とんでもない情報量になってしまうので省略しています。正確な流れを把握したい方は自分で コードを追ってみてください。

rubocop/lib/rubocop/cli.rb at v1.52.1 · rubocop/rubocop · GitHub

なぜここまで多くのクラスに分割して処理を経由しているのか自分なりに考えてみたのですが、おそらく責務を分割し拡張性を持たせるためだと思われます。新しくコマンドを追加したい場合を考えてみると、runが実装されたクラスを用意すれば事足りるのでしょう。

処理の流れ

file class method
lib/rubocop/cli.rb RuboCop::CLI run
lib/rubocop/cli/enviroment.rb RuboCop::CLI::Environment run
lib/rubocop/cli/command.rb RuboCop::CLI::Command run
lib/rubocop/cli/command/execute_runnner.rb RuboCop::CLI::Command::ExecuteRunner run
lib/rubocop/runnner.rb RuboCop::CLI::Runner run

Runnerクラス

Runnerクラスのrunから複数のメソッドを経由していきます。
1つ1つのメソッドについて紹介しきれないので、先ほどと同じように一気に紹介します。
最終的にはRuboCop::Cop::Teamクラスのinvestigateが呼び出されます。

method next method supplement
run inspect_files(files) 引数にはファイルパスの一覧が渡される
inspect_files each_inspected_file(files)
each_inspected_file process_file(file) filesをreduceして1ファイルずつ呼び出す
process_file file_offenses(file)
file_offenses do_inspection_loop(file) ファイルが解析され抽象構文木(AST)が作成される
do_inspection_loop inspect_file team.investigateを呼び出して解析結果(report)を取得する
def inspect_file(processed_source, team = mobilize_team(processed_source))
  extracted_ruby_sources = extract_ruby_sources(processed_source)
  offenses = extracted_ruby_sources.flat_map do |extracted_ruby_source|
    report = team.investigate(
      extracted_ruby_source[:processed_source],
      offset: extracted_ruby_source[:offset],
      original: processed_source
    )
    @errors.concat(team.errors)
    @warnings.concat(team.warnings)
    report.offenses
  end
  [offenses, team.updated_source_file?]
end

report.offensesにはRubocopによって解析された解析結果が保持されています。
report自体はRuboCop::Cop::Commissionerに定義された構造体 InvestigationReportのインスタンスなのですが、保持しているデータ量がとんでもないので迂闊に中身を確認しようとすると、ターミナルが5秒ほどスクロールされ続けます...。

main.rbの解析結果

# 配列で保持されている
# 一例としてFrozenStringLiteralCommentに引っかかった際のoffenses
[
  #<RuboCop::Cop::Offense:0x0000000106983c10
  @cop_name="Style/FrozenStringLiteralComment",
  @corrector=#<RuboCop::Cop::Corrector /Users/.../ruby/rubocop-reading/main.rb: +"# frozen_string_literal: true\n"@0>,
  @location=#<Parser::Source::Range /Users/.../ruby/rubocop-reading/main.rb 0...1>,
  @message="Style/FrozenStringLiteralComment: Missing frozen string literal comment.",
  @severity=#<RuboCop::Cop::Severity:0x0000000106ec9e68 @name=:convention>,
  @status=:uncorrected>,
  :
]

次はoffensesの値を作っている処理を追いかけていきます。

補足: FormatterSetクラスについて

Copの解析結果を画面にどのように表示するのかを一元管理しているのがFormatterSetクラスです。
このクラスは非常に面白くてArrayクラスを継承しており、複数のFormatterを配列のように管理することが可能となっています。
自分が見た限りだとFormatterSetクラスにはデフォルトとしてProgressFormatterが保持されていました。

[
  #<RuboCop::Formatter::ProgressFormatter:0x0000000106f42890
  @offenses_for_files={},
  :>
]

www.okb-shelf.work

また後に登場するでの、お楽しみに...。

team.investigateからコールバックの実行まで

RuboCopではコードに対して解析を行うチェックのことをそれぞれCopと呼びます。
例えばファイルの先頭に#frozen_string_literal: trueが記載されているかどうかをチェックするCopはStyle/FrozenStringLiteralCommentです。対応するファイルがlib/rubocop/cop/styles 配下に frozen_string_literal_comment.rbとして定義されています。

各Copによるチェックはコールバックとして実行されます。コールバック実行されるまでの処理を追っていきます。

class method supplement
RuboCop::Cop::Team investigate Copを一元管理するためのクラス
RuboCop::Cop::Team investigate_partial
RuboCop::Cop::Commissioner investigate それぞれのCopにチェックを依頼する
RuboCop::Cop::Commissioner invoke Copの呼び出し

invokeとコールバック

invokeは以下のように呼び出されます。
第1引数には各Copに定義されている呼び出したいメソッド名、第2引数にはCopの一覧が指定されます。

invoke(:on_new_investigation, @cops)

ブロック引数を指定することで各Copのコールバックを呼び出しています。

def invoke(callback, cops)
  cops.each { |cop| with_cop_error_handling(cop) { cop.send(callback) } }
end

しかしながらCopによっては実行するタイミングが異なっており、on_new_investigationが実装されているCopクラスとそうではないクラスがあります。同じことが他のコールバック(eg: investigate)についても言えますが、どのように処理を制御しているのでしょうか。

それぞれのCopは基底クラスであるRuboCop::Cop::Baseを必ず継承しています。

class FrozenStringLiteralComment < Base
  :
end

rubocop/lib/rubocop/cop/base.rb at v1.52.1 · rubocop/rubocop · GitHub

Baseクラスはinterfaceのような役割を担っていて全てのコールバックのデフォルトの振る舞いが定義されています。
とはいっても、どれもnilを返すようになっているだけで何も処理を実行しません。継承したCop側で、実行したいタイミングに合わせて適当なコールバックをオーバーライドするという設計になっていました。

非常に美しいですね。

# invoke(:on_new_investigation, @cops)に対応
def on_new_investigation

# invoke(:on_investigation_end, @cops)に対応
def on_investigation_end

# invoke(:on_other_file, @cops)に対応
def on_other_file

一例として、FrozenStringLiteralCommentクラスはon_new_investigationをオーバーライドしていました。

def on_new_investigation
  return if processed_source.tokens.empty?
  :
end

rubocop/lib/rubocop/cop/style/frozen_string_literal_comment.rb at v1.52.1 · rubocop/rubocop · GitHub

これで各Copがどのように実行されるのか分かりました。素晴らしい成果です。
引き続き FrozenStringLiteralCommentクラスの実装を見ていきたい所ですが、記事が長くなりすぎたので、今回はここまでとして次回の記事にて続きを見ていこうと思います。

次回の投稿をお楽しみに!