※この記事はRuboCopの内部実装を読んでみるシリーズの第1記事です。
ある日のこと、いつものようにRuboCopに怒られた際に「RuboCopってどうやって作られてるのかな...」と唐突に気になってしまったのでコードを読んでみることにしました。最初は数日あれば終わるかなと思って始めたのですが、甘かったです...。気づけば二週間も時間が過ぎていました。
全てを詳細に紹介することは情報量の都合上、厳しいのでコアの部分を中心に紹介します。
途中、処理が飛んだりしますが気になった方はぜひ自分でコードを読んでみてください。RuboCopのコードを読むに当たってローカル環境でbinding.irb
を仕込むと非常に捗るので前回の記事も、合わせてご覧ください。
バージョンなど
- 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={}, :> ]
また後に登場するでの、お楽しみに...。
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クラスの実装を見ていきたい所ですが、記事が長くなりすぎたので、今回はここまでとして次回の記事にて続きを見ていこうと思います。
次回の投稿をお楽しみに!