やわらかテック

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

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

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

前回の記事でターミナルでrubocopコマンドを実行するとRuboCop::CLIクラスのrunが呼び出されて、紆余曲折あって各Copのチェックがコールバックとして呼び出されていることが判明しました。そのまま処理を追いたかったのですが、記事が長くなり過ぎたので2つに分割しました。本稿を読んで頂くにあたって前回の記事の内容が深く関わってくるので、ぜひ前回の記事もご覧ください。

www.okb-shelf.work

今回はCopのコールバックが実行されてから結果が画面(ターミナル)に出力されるまでを追っていきます。

Copのコールバック

前回に引き続きCopからFrozenStringLiteralCommentを抜粋して見ていきます。
FrozenStringLiteralCommentではon_new_investigationをオーバーライドしています。processed_source.tokensにはコードを解析して作成した抽象構文木(AST)が保持されています。

def on_new_investigation
  return if processed_source.tokens.empty?

  case style
  when :never
    ensure_no_comment(processed_source)
  when :always_true
    ensure_enabled_comment(processed_source)
  else
    ensure_comment(processed_source)
  end
end

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

styleの値によって処理が分岐します。
この変数にはymlファイルに定義されたスタイルの値を読み込んだものが保持されており、今回はconfig/default.ymlに定義されているalwaysに該当するためensure_commentが呼び出されます。

Style/FrozenStringLiteralComment:
  EnforcedStyle: always
def ensure_comment(processed_source)
  return if frozen_string_literal_comment_exists?

  missing_offense(processed_source)
end

Copのチェック

ensure_commentの1行目にはガード説が記述されています。 メソッド名から分かるようにfrozen_string_literalがファイルに記述されていればtrueが返ってきます。つまり、ここまで来てようやくCopのルールに違反していないかどうかの確認がされるわけです。

このメソッドはlib/rubocop/cop/mixin/frozen_string_literal.rbに定義されています。

def frozen_string_literal_comment_exists?
  leading_comment_lines.any? { |line| MagicComment.parse(line).valid_literal_value? }
end

rubocop/lib/rubocop/cop/mixin/frozen_string_literal.rb at v1.52.1 · rubocop/rubocop · GitHub

leading_comment_linesでは条件に合致した場合、抽象構文木(AST)のトークンをスライスしています。
最初のコメントではない記述(eg: puts )を見つけた場合、その箇所までのトークンが返しますが、コメントではない記述が存在しない場合は全てのトークンを返します。

def leading_comment_lines
  # eg: <RuboCop::AST::Token:0x0000000108905cf0
  #          @pos=#<Parser::Source::Range /Users/.../ruby/rubocop-reading/main.rb 10...14>,
  #          @text="puts",
  #          @type=:tIDENTIFIER>
  first_non_comment_token = processed_source.tokens.find { |token| !token.comment? }

  if first_non_comment_token
    processed_source.lines[0...first_non_comment_token.line - 1]
  else
    processed_source.lines
  end
end

そして、このメソッドからの戻り値に1行でもvalid_literal_value?となるものが含まれていればtrueとなります。 valid_literal_value?の判定を簡単に紹介すると、正規表現を用いて文字列が'frozen[_-]string[_-]literal'にマッチしているかを判定しています。この判定のためにコメントの種類を判定したり、指定値がtruefalseかを確認したり...と様々な処理がされていることが分かります。

何よりも驚いたのがCopのチェックが全てRubyで実装されているという点です。
つまりRubyで書かれたコードをRubyで書いたコードでチェックしているわけですが、凄すぎますね...。 少なくともRuby力が53万ぐらいは必要になりそうです。RuboCopのCopの実装には感服しました。

Offenseクラスのインスタンスの作成

ガード説でtrueにならなかった場合、missing_offenseが呼び出されます。
このメソッドさらに継承しているRuboCop::Cop::Baseクラスのadd_offenseを呼び出します。

def missing_offense(processed_source)
  # #<Parser::Source::Range /Users/takamizawa46/workspace/ruby/rubocop-reading/main.rb 0...1>
  range = source_range(processed_source.buffer, 0, 0)

  # MSG_MISSING: "Missing frozen string literal comment."
  add_offense(range, message: MSG_MISSING) { |corrector| insert_comment(corrector) }
end
def add_offense(node_or_range, message: nil, severity: nil, &block)
  :
  current_offenses << Offense.new(severity, range, message, name, status, corrector)
end

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

途中の処理は省略しますが、add_offenseによって最終的にはOffenseクラスのインスタンスが作成されます。
作成されたインスタンスはcurrent_offensesに格納され後に結果の一覧を返す構造体 InvestigationReportのインスタンスとして保持されるようになります。

前回の記事で見た、Offenseクラスのインスタンスはここで作られていたんですね。

#<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>,

結果の出力まで

長い旅もあと少しで終わりを迎えようとしています。
作成されたOffenseクラスのインスタンスの一覧がRuboCop::Cop::Commissionerの構造体 InvestigationReportのインスタンスとして返ります。ここまでさまざまなメソッドを経由してきましたがRuboCop::Cop::Runnerクラスのinspect_filesまで戻ります。

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_files(files)
  inspected_files = []
  :
ensure
  :
  formatter_set.finished(inspected_files.freeze)
  formatter_set.close_output_files
end

inspect_filesからはFormatterSetクラスのfinishedを呼び出しています。
またこの時、すでにFormatterSetクラスにはoffenseの一覧がインスタンス変数(@offenses_for_files)に保持されています。
前回の記事で少し紹介しましたがFormatterSetクラスはFormatterの一覧を管理するためにArrayを継承しているクラスです。
eachで処理しているのはselfに保持されたFormatterの一覧です。今回はProgressFormatterのみが保持されています。

FORMATTER_APIS.each do |method_name|
  define_method(method_name) do |*args|
    each { |f| f.public_send(method_name, *args) }
  end
end

結果的にProgressFormatterのfinishedが呼び出しています。

def finished(inspected_files)
  output.puts

  unless @offenses_for_files.empty?
    output.puts
    output.puts 'Offenses:'
    output.puts

    @offenses_for_files.each { |file, offenses| report_file(file, offenses) }
  end

  report_summary(inspected_files.size,
                  @total_offense_count,
                  @total_correction_count,
                  @total_correctable_count)
end

ここでお馴染みの形式に整形されて出力がされます。

# output.puts
Offenses:       # output.puts 'Offenses:'                                                                              
# output.puts

# @offenses_for_files.each { |file, offenses| report_file(file, 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!"                                                                           
     ^^^^^^^^^^^^^^                                                                           

# report_summary                                                                                              
1 file inspected, 2 offenses detected, 2 offenses autocorrectable

ようやく結果が出力される所まで確認することが出来ました...。 ここまで非常に長かったですが、これがRuboCopがターミナルからコマンドとして実行されて結果を出力するまでの概要になります。
実際は設定やフラグ値など、さまざまな要素を考慮しており処理はもっと複雑です。今回は簡単のために僕がコアっぽいと感じた部分を抜粋して紹介しました。

最後に

元々は数日あれば終わると思って始めたのですが、とんでもないぐらい時間がかかりました。
正直、きつかった部分もありますが読み進めていくと少しずつ謎が解けていくような感じがして楽しかったです。何よりも驚いたのは先ほども書いたようにCopによるチェックがRubyで実装されていた点です。

勝手に別言語のパーサーのようなものを使っていると思い込んでいたのですが、そうではありませんでした。
今回は紹介しませんでしたが抽象構文木(AST)を生成する過程ではRuboCopとは別のrubocop-astというgemが作成されています。

github.com

いや、もうコンパイラ独自で作っとるやないかい。感服です。
RuboCopのおかげで日々、とてもスムーズにRubyが書けます。これだけの機能を持ったgemの実装・管理をしてくださっている方々には頭が上がりません。本当にありがとうございます。

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