※この記事はRuboCopの内部実装を読んでみるシリーズの第2記事です。
前回の記事でターミナルでrubocop
コマンドを実行するとRuboCop::CLIクラスのrunが呼び出されて、紆余曲折あって各Copのチェックがコールバックとして呼び出されていることが判明しました。そのまま処理を追いたかったのですが、記事が長くなり過ぎたので2つに分割しました。本稿を読んで頂くにあたって前回の記事の内容が深く関わってくるので、ぜひ前回の記事もご覧ください。
今回は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'
にマッチしているかを判定しています。この判定のためにコメントの種類を判定したり、指定値がtrue
かfalse
かを確認したり...と様々な処理がされていることが分かります。
何よりも驚いたのが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が作成されています。
いや、もうコンパイラ独自で作っとるやないかい。感服です。
RuboCopのおかげで日々、とてもスムーズにRubyが書けます。これだけの機能を持ったgemの実装・管理をしてくださっている方々には頭が上がりません。本当にありがとうございます。
少しでも「ええな〜」と思ったらイイネ!・シェア!・はてなブックマークを頂けると励みになります。