TL;DR
- Ruby 公式コーディングスタイルほしい
- 公式コーディングスタイル作成はトライしたが断念
- RDoc 内の Ruby コードをオートフォーマットする rdoc_rubocop gem を作った
RDoc 内のコードをオートフォーマットしよう
きっかけは mattn さんの以下のツイート。
Ruby もオートフォーマッタの流れに乗っておくと良いと思うけどな。 / “Rubyコーディングスタイルの現状とStandard gemのご紹介 - Qiita” https://t.co/rCSDGLoUlu
— mattn (@mattn_jp) 2018年12月5日
自分も前々から公式コーディングスタイルが欲しい (アプリや gem の新規作成の度にコーディングスタイルどうしようとほんのちょっとでも悩むのが面倒) と思っていたので、激しく同意。 ないのなら作ってみよう。
Ruby 公式コーディングスタイルを作ってみる
Ruby には RuboCop という静的コード解析&オートフォーマッタがある。
これにはコーディングスタイル違反を検出し、一旦保留にするための設定ファイルを生成する機能がある。
生成された設定ファイル (.rubocop_todo.yml
) を手直しし RuboCop を適用することでオートフォーマットとして使えるので、試してみよう。
Ruby の公式コードは Ruby コアリファレンス のサンプルコードを利用することにする。
Ruby コアリファレンスに RuboCop
Ruby コアリファレンスは CRuby のコード中のコメントから RDoc コマンドで生成されている(はず)。 ここからサンプルコードを抽出しファイルに保存、このファイルに対して RuboCop すれば良さそうだ。
RDoc コマンドで生成されるのは HTML (デフォルトの場合)。 サンプルコードは ruby クラスが付与された pre タグ内にあるようなので、この部分を抜き出すスクリプトを作成する。
#!/usr/bin/env ruby require 'pathname' require 'fileutils' require 'nokogiri' class HtmlFile def initialize(path) @path = path end def load_and_parse @doc = File.open(@path) { |f| Nokogiri::HTML(f) } end def rubies @doc.xpath("//pre[@class='ruby']").map(&:inner_text) end end class Extractor SRC_DIR = Pathname("doc") DST_DIR = Pathname("sample") def initialize end def extract SRC_DIR.glob("**/*.html").each do |path| puts path html_file = HtmlFile.new(path) html_file.load_and_parse dst = DST_DIR + (path.sub("doc/", "").sub(/html$/, "rb")) FileUtils.mkdir_p(dst.dirname) dst.open("w") do |f| f.puts html_file.rubies.join("#---\n") end end end end Extractor.new.extract
#!/bin/sh [ -d ruby ] || git clone git://github.com/ruby/ruby.git (cd ruby && git checkout v2_5_3) bundle exec rdoc --root=ruby [ -d sample ] || mkdir sample bundle exec ext_sample.rb echo 2.5.3 > .ruby-version bundle exec rubocop --auto-gen-config sample/
これを実行した結果、45KB、1,822 行の .rubocop_todo.yml
が生成された。
事前になんとなく想像していたよりも多い。
表記ゆれも結構あり、Layout/SpaceBeforeBlockBraces
と Style/NumericLiterals
が多い様子。
思っていたよりも統一感がなく、ここから生成された設定ファイルを公式コーディングスタイルとするのは厳しそうだ。 ということで公式コーディングスタイルは一旦諦めることにした。
それよりも表記ゆれもあることだしサンプルコードの方を修正したくなってきた。 抽出したサンプルコードに対して RuboCop でオートフォーマットし、結果を元のソースコードに反映すればいいのではないか。
RDoc 内のサンプルコードをオートフォーマット
RDoc 自体はコンピュータ言語に依存しないが、通常は Ruby のコードか Ruby の C 拡張に用いられることが多いだろう。 また Ruby の C 言語による実装 CRuby にも多数使用されている。 ここでは C 言語および Ruby のメントを加工することを考える。
C 言語のコメントを加工する
CRuby のコードは以下のようになっている。
/* array.c から Array#at を抜粋 */ /* * call-seq: * ary.at(index) -> obj or nil * * Returns the element at +index+. A negative index counts from the end of * +self+. Returns +nil+ if the index is out of range. See also * Array#[]. * * a = [ "a", "b", "c", "d", "e" ] * a.at(0) #=> "a" * a.at(-1) #=> "e" */ VALUE rb_ary_at(VALUE ary, VALUE pos) { return rb_ary_entry(ary, NUM2LONG(pos)); }
ここからコメント部の /* ... */
を抜き出し、Ruby のサンプルコードをオートフォーマットしてから元の場所を置換できればいい。
コメント部を抜き出して置換する場合、String#sub
、String#gsub
が使える。単純な文字列置換の場合は "hello".sub(/e/, "*")
の形式で利用するが、もう少し複雑な処理の場合はブロックを渡し、その中でマッチした部分に複雑な加工を施すこともできる。
code = File.open("array.c").read code.gsub(/\/\*.*?\*\//) { |s| s.auto_correct_with_rubocop }
しかし今回はこの方法は使わない。なぜなら、RuboCop の処理中のプログレス表示が見づらいものになるからだ。RuboCop で複数のファイルを処理する場合を考えてみてほしい。通常はコマンドに複数のファイルをまとめて指定するはずだ。ファイルを一つだけ指定し複数回実行することは稀だろう。
# 複数のファイルをまとめて指定する場合 $ rubocop file1.c file2.c file3.c Inspecting 3 files ... 3 files inspected, no # ファイルを一つだけ指定して複数回実行 $ for f in file1.c file2.c file3.c; do rubocop $f; done Inspecting 1 file . 1 file inspected, no offenses detected Inspecting 1 file . 1 file inspected, no offenses detected Inspecting 1 file . 1 file inspected, no offenses detected
ではコメントを複数個まとめて抜き出し、加工をしてから元のコメントを置換することは可能か。MatchData
を利用すれば可能だ。
MatchData
は文字列と正規表現をマッチさせた結果を保持するクラスだが、これにはマッチした文字列の位置情報が含まれている。
m = /\d+/.match("THX1138.") m[0] #=> "1138" m.begin(0) #=> 3 m.end(0) #=> 7 m = /[A-Z](\d{2})/.match("THX1138.") m[0] #=> "X11" m[1] #=> "11" m.begin(1) #=> 3 m.end(1) #=> 5
これと String#[]=
を合わせて使うことでコメント置換ができる。
code = File.open("array.c").read m = /\/\*.*?\*\//.match(code) code[m.begin(0) .. m.end(0)] = m[0].auto_correct_with_rubocop
上記のコードには問題がある。コード中の一番最初のコメントしか抜き出せないのだ。Regexp#match
は一度に複数のコメントとマッチしない。しかし幸いなことに、第二引数を指定すれば検索開始位置を変更できる。
m = /\d/.match("THX1138.") m[0] #=> "1" m = /\d/.match("THX1138.", 5) m[0] #=> "3"
あとはこれをコメントがマッチしなくなるまでループすればいい。
code = File.open("array.c").read pos = 0 comments = [] while m = /\/\*.*?\*\//.match(code, pos) comments << m pos = m.end(0) end comments #=> [#<MatchData "/* comment1 */">, #<MatchData "/* comment2 */">, ...]
これでコメントが複数であろうと抜き出せるようになった。
Ruby のコメントを加工する
Rails のコードを以下に抜粋する。
class Numeric # Returns the number of milliseconds equivalent to the seconds provided. # Used with the standard time durations. # # 2.in_milliseconds # => 2000 # 1.hour.in_milliseconds # => 3600000 def in_milliseconds self * 1000 end end
※activesupport/lib/active_support/core_ext/numeric/time.rb
から #in_milliseconds
のみ抜粋
Ruby のコメントは #
から行末までなので、これを抜き出せばいい(=begin
, =end
による埋め込みドキュメントは今回は保留)。
C 言語と異なり Ruby のコメントは 1 行なので、抜き出したあとで結合をする必要がある。
# extract_comments_ruby.rb Line = Struct.new(:text, :lineno) class Comment def initialize(lines) @lines = lines end def text @lines.map(&:text).join end def lineno @lines.map(&:lineno).minmax end end code = File.open("time.rb").readlines comment_lines = code. map.with_index(1) { |line, lineno| Line.new(line, lineno) }. select { |line| /^\s*#/ =~ line.text } comments = comment_lines. slice_when { |i, j| j.lineno - i.lineno > 1 }. map { |tokens| Comment.new(tokens) } puts "length: #{comments.length}" comments.each do |comment| puts "" puts "lineno: #{comment.lineno}" puts "text:\n" + comment.text end
Enumerable#slice_when
はブロックの評価結果が true のとき、そこを区切りとして Array をグループ化するメソッドだ。
[1, 2, 3, 5, 6, 9, 10].slice_when { |i, j| j - i > 1 }.to_a #=> [[1, 2, 3], [5, 6], [9, 10]]
今回は行番号が連続しているコメントを、一まとまりとして扱うために使用した。 以下のような状況を想定すると行番号が連続していても必ずしも一まとまりのコメントとはならないので、あくまで例として認識していただきたい。
# comment def foo # another comment "foo" end
実行してみると
$ ruby extract_comments_ruby.rb length: 1 lineno: [2, 6] text: # Returns the number of milliseconds equivalent to the seconds provided. # Used with the standard time durations. # # 2.in_milliseconds # => 2000 # 1.hour.in_milliseconds # => 3600000
問題ないようだ。
ところでこの例ではコメントを正規表現 /\s*#/
にマッチさせて判定している。
が Ruby の標準添付ライブラリには Ripper という Ruby パーサがある。
これを使うと Ruby のコードを簡単かつ確実にパースすることができる。
require "ripper" Ripper.lex(File.open("time.rb").read)
# 実行結果 [[[1, 0], :on_kw, "class", EXPR_CLASS], [[1, 5], :on_sp, " ", EXPR_CLASS], [[1, 6], :on_const, "Numeric", EXPR_ARG], [[1, 13], :on_nl, "\n", EXPR_BEG], [[2, 0], :on_sp, " ", EXPR_BEG], [[2, 2], :on_comment, "# Returns the number of milliseconds equivalent to the seconds provided.\n", EXPR_BEG], [[3, 0], :on_sp, " ", EXPR_BEG], [[3, 2], :on_comment, "# Used with the standard time durations.\n", EXPR_BEG], [[4, 0], :on_sp, " ", EXPR_BEG], [[4, 2], :on_comment, "#\n", EXPR_BEG], [[5, 0], :on_sp, " ", EXPR_BEG], [[5, 2], :on_comment, "# 2.in_milliseconds # => 2000\n", EXPR_BEG], [[6, 0], :on_sp, " ", EXPR_BEG], [[6, 2], :on_comment, "# 1.hour.in_milliseconds # => 3600000\n", EXPR_BEG], [[7, 0], :on_sp, " ", EXPR_BEG], [[7, 2], :on_kw, "def", EXPR_FNAME], [[7, 5], :on_sp, " ", EXPR_FNAME], [[7, 6], :on_ident, "in_milliseconds", EXPR_ENDFN], [[7, 21], :on_nl, "\n", EXPR_BEG], [[8, 0], :on_sp, " ", EXPR_BEG], [[8, 4], :on_kw, "self", EXPR_END], [[8, 8], :on_sp, " ", EXPR_END], [[8, 9], :on_op, "*", EXPR_BEG], [[8, 10], :on_sp, " ", EXPR_BEG], [[8, 11], :on_int, "1000", EXPR_END], [[8, 15], :on_nl, "\n", EXPR_BEG], [[9, 0], :on_sp, " ", EXPR_BEG], [[9, 2], :on_kw, "end", EXPR_END], [[9, 5], :on_nl, "\n", EXPR_BEG], [[10, 0], :on_kw, "end", EXPR_END], [[10, 3], :on_nl, "\n", EXPR_BEG]]
パースするとコードはトークンに分解され、トークンは [[lineno, column], type, token, state]
という Array として返される。
lineno
は行番号、column
はカラム位置、type
はトークンの型だ。
トークンがコメントのとき type
は on_comment
になるので、これを集めればいい。
require "ripper" Token = Struct.new(:lineno, :column, :type, :token, :state) class Comment def initialize(tokens) @tokens = tokens end def text @tokens.map(&:token).join end def lineno @tokens.map(&:lineno).minmax end end code = File.open("time.rb").read comment_tokens = Ripper.lex(code). map(&:flatten). map { |token| Token.new(*token) }. select{ |token| token.type == :on_comment } comments = comment_tokens. slice_when { |i, j| j.lineno - i.lineno > 1 }. map { |tokens| Comment.new(tokens) } puts "length: #{comments.length}" comments.each do |comment| puts "" puts "lineno: #{comment.lineno}" puts "text:\n" + comment.text end
実行すると
$ ruby extract_comments_ruby.rb length: 1 lineno: [2, 6] text: # Returns the number of milliseconds equivalent to the seconds provided. # Used with the standard time durations. # # 2.in_milliseconds # => 2000 # 1.hour.in_milliseconds # => 3600000
こちらも問題ない。
RuboCop を使う
stdin オプションを使ってみる
RuboCop に文字列を直接指定して使用することはできるか。rubocop コマンドには --stdin
オプションが用意されており、ファイルの代わりに標準入力・標準出力を利用できる。
$ echo "[1 , 2]" | rubocop --stdin sample.rb Inspecting 1 file C Offenses: sample.rb:1:3: C: Layout/SpaceBeforeComma: Space found before comma. [1 , 2] ^ 1 file inspected, 1 offense detected
オートコレクトもできる。
$ echo "[1 , 2]" | rubocop --stdin sample.rb --auto-correct Inspecting 1 file C Offenses: sample.rb:1:3: C: [Corrected] Layout/SpaceBeforeComma: Space found before comma. [1 , 2] ^ 1 file inspected, 1 offense detected, 1 offense corrected ==================== [1, 2]
コマンド実行結果は省略するが、--auto-gen-config
で .rubocop_todo.yml
を生成することもできた。おそらく他のオプションも利用可能だ。
しかしこの方法では一塊のコードしか指定できず、先のプログレス表示問題に再度突き当たってしまう。
rubocop コマンドには複数のファイルを指定できることは先に記載したとおり。 ではどのように実装されているかを調べ、そこをうまく利用できないかを検討しよう。
ファイルを読み込む処理を変更してみる
まず RuboCop が指定されたファイルまたは標準入力を読み出す処理を探してみる。すると以下のコードが見つかる。
module RuboCop class Runner # : def get_processed_source(file) ruby_version = @config_store.for(file).target_ruby_version if @options[:stdin] ProcessedSource.new(@options[:stdin], ruby_version, file) else ProcessedSource.from_file(file, ruby_version) end end end end module RuboCop class ProcessedSource def self.from_file(path, ruby_version) file = File.read(path, mode: 'rb') new(file, ruby_version, path) end def initialize(source, ruby_version, path = nil) @raw_source = source @path = path @ruby_version = ruby_version end # : end
※いずれのクラスも要所のみ抜粋。以後、引用するコードも同様
ProcessedSource
は対象となるソースコードを保持するためのクラスのようだ。
RuboCop::Runner#get_processed_source
にソースコードを渡した場合、それを元に RuboCop::ProcessedSource
のインスタンスを返すようにすればどうだろう。
既存のコードの動作を変更するには prepend
が便利だ。
# test.rb require "rubocop" SourceCode = Struct.new(:filepath, :source) module RunnerModifier private def get_processed_source(file) if file.is_a?(SourceCode) ruby_version = @config_store.for(file.filepath).target_ruby_version RuboCop::ProcessedSource.new(file.source, ruby_version, file.filepath) else super end end end RuboCop::Runner.prepend RunnerModifier cli = RuboCop::CLI.new source_code = SourceCode.new("sample.rb", "[1 , 2]") cli.run([source_code]) puts source_code.source
ruby_version
を得るためにほぼ同じコードを 2 箇所に書くことになり少々嫌な気分になるが、とにかく実行してみよう。
rubocop コマンドは内部で RuboCop::CLI
クラスのインスタンスを生成し、これにコマンドの引数を配列として渡している。
コードの最後の部分はこれを簡略化したものだ。
$ bundle exec ruby test.rb no implicit conversion of SourceCode into String /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/target_finder.rb:36:in `directory?' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/target_finder.rb:36:in `block in find' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/target_finder.rb:35:in `each' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/target_finder.rb:35:in `find' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:67:in `find_target_files' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:43:in `run' # snip
RuboCop::TargetFinder#find
は引数がファイルなら AllCops/Exclude
に含まれるかをチェックし、ディレクトリなら再帰的にファイルを検索するメソッドだ。
上記のコードでは SourceCode
という構造体クラスを定義したが、これが引数に String を期待する既存のコードにとって良くなかったようだ。
代わりに String
クラスを拡張したものを使用する。
# test.rb require "rubocop" class FilePath < String attr_accessor :source def initialize(str, source) super(str) @source = source end end module RunnerModifier private def get_processed_source(file) if file.is_a?(FilePath) ruby_version = @config_store.for(file).target_ruby_version RuboCop::ProcessedSource.new(file.source, ruby_version, file) else super end end end RuboCop::Runner.prepend RunnerModifier cli = RuboCop::CLI.new source_code = FilePath.new("sample.rb", "[1 , 2]") cli.run([source_code]) puts source_code.source
$ bundle exec ruby test.rb Inspecting 1 file 0 files inspected, no offenses detected Error: No such file or directory: /path/to/dir/sample.rb
"sample.rb" がないというエラーが出てしまった。
よく見ると source_code
を指定したにもかかわらず 0 files inspected
となってしまっている。
調べてみると、先の RuboCop::Runner#get_processed_source
で sample.rb
を開こうとして見つからないという状態のようだ。
おかしい。そこは FilePath
クラスのインスタンスを受け取るとファイルを読み込む代わりにソースコードを利用するよう改修したはずだ。
原因を調べるために、デバッガを使って詳しく調べてみよう。
ruby コマンドに -rdebug
を指定することで利用できる。
$ bundle exec ruby -rdebug test2.rb /home/user/.rbenv/versions/2.6.0/lib/ruby/2.6.0/x86_64-linux/continuation.so: warning: callcc is obsolete; use Fiber instead Debug.rb Emacs support available. /home/user/.rbenv/versions/2.6.0/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:35: RUBYGEMS_ACTIVATION_MONITOR.enter # ブレイクポイントをセットする対象の RuboCop::Runner を事前にロードする (rdb:1) require "rubocop" true # RuboCop::Runner:get_processed_source にブレイクポイントをセット (rdb:1) break RuboCop::Runner:get_processed_source Set breakpoint 1 at RuboCop::Runner:get_processed_source # Ruby スクリプトを実行 (rdb:1) cont Inspecting 1 file Breakpoint 1, get_processed_source at /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:get_processed_source /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:350: def get_processed_source(file) (rdb:1) file.class String
引数 file
のクラスが FilePath
ではなくて String
になってしまっていた。
これでは存在しないファイル "sample.rb" を開こうとして失敗するのは当然だ。
file
は一体どこで String
になってしまったのか。
RuboCop::CLI
は内部で RuboCop::Runner
のインスタンスを生成し、run
メソッドを呼び出すようになっている。
ひとまずここから見てみよう。
module RuboCop class Runner def run(paths) target_files = find_target_files(paths) if @options[:list_target_files] list_files(target_files) else warm_cache(target_files) if @options[:parallel] inspect_files(target_files) end end def find_target_files(paths) target_finder = TargetFinder.new(@config_store, @options) target_files = target_finder.find(paths) target_files.each(&:freeze).freeze end end end
先ほどと同様にデバッガで詳しく見ていく。
# `RuboCop::Runner#run` にブレイクポイントをセット (rdb:1) break RuboCop::Runner:run Set breakpoint 2 at RuboCop::Runner:run # スクリプトを実行 (rdb:1) c Breakpoint 1, run at /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:run /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:42: def run(paths) # 引数 paths 内のクラスを確認する (rdb:1) paths ["sample.rb"] (rdb:1) paths[0].class FilePath # この時点では FilePath (rdb:1) next /home/masaoo/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:43: target_files = find_target_files(paths) (rdb:1) next /home/masaoo/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:44: if @options[:list_target_files] # ローカル変数 target_files 内のクラスを確認する (rdb:1) target_files ["/path/to/dir/sample.rb"] (rdb:1) target_files[0].class String # ここで FilePath でなくなっている
先に出てきた RuboCop::TargetFinder#find
が犯人だった。
コードを見てみると、ファイル名を絶対パスに変換する処理 (File.expand_path(f)
) があるのがわかる。
module RuboCop class TargetFinder def find(args) return target_files_in_dir if args.empty? files = [] args.uniq.each do |arg| files += if File.directory?(arg) target_files_in_dir(arg.chomp(File::SEPARATOR)) else process_explicit_path(arg) end end files.map { |f| File.expand_path(f) }.uniq end end end
ではここも prepend
で動作を変えよう。
# RunnerModifier 以外の部分は同じ module RunnerModifier private def find_target_files(paths) file_paths, strs = paths.partition { |path| path.is_a?(FilePath) } if file_paths.empty? super(strs) elsif strs.empty? file_paths else file_paths + super(strs) end end def get_processed_source(file) if file.is_a?(FilePath) ruby_version = @config_store.for(file).target_ruby_version RuboCop::ProcessedSource.new(file.source, ruby_version, file) else super end end end # snip
bundle exec ruby test.rb Inspecting 1 file C Offenses: sample.rb:1:3: C: Layout/SpaceBeforeComma: Space found before comma. [1 , 2] ^ sample.rb:1:8: C: Layout/TrailingBlankLines: Final newline missing. [1 , 2] 1 file inspected, 2 offenses detected
うまくいった!
オートコレクトも試してみよう。cli.run
の引数に --auto-correct
を追加する。
# 上のコードは同じ cli = RuboCop::CLI.new source_code = FilePath.new("sample.rb", "[1 , 2]") cli.run(["--auto-correct", source_code])
$ bundle exec ruby test3.rb Inspecting 1 file 0 files inspected, no offenses detected No such file or directory @ rb_sysopen - sample.rb /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cached_data.rb:47:in `read' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cached_data.rb:47:in `deserialize_offenses' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cached_data.rb:13:in `from_json' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/result_cache.rb:95:in `load' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:134:in `file_offense_cache' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:124:in `file_offenses' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:112:in `process_file' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:89:in `block in each_inspected_file' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:86:in `each' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:86:in `reduce' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:86:in `each_inspected_file' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:76:in `inspect_files' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:48:in `run' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cli.rb:174:in `execute_runner' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cli.rb:75:in `execute_runners' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cli.rb:47:in `run' test.rb:43:in `<main>'
またエラーだ。今度の原因はなんだろうか。 RuboCop は解析の結果をキャッシュファイルに保存しておき、それを再利用する仕組みがある。 これをロードする際に解析対象のファイルの内容も同時に読み込むのだが、ソースコードは標準入力なので失敗するというわけだ。
キャッシュを利用しないようにするには rubocop コマンドに -C
--cache
を指定すればいいのだが、ここの実装はどうなっているかというと
module RuboCop class Runner def cached_run? @cached_run ||= (@options[:cache] == 'true' || @options[:cache] != 'false' && @config_store.for(Dir.pwd).for_all_cops['UseCache']) && !@options[:auto_gen_config] && !@options[:stdin] end end end
--cache
オプション、AllCops/UseCache
など参照していることがわかる。
注目は最終行だ。なるほど、--stdin
オプションが指定されたときもキャッシュが無効になっていたのか。
ということは #cached_run?
が常に false
を返すようにすればいい。
RunnerModifier
モジュールに以下のメソッドを追加してみよう。
def cached_run? false end
実行すると
$ bundle exec ruby test.rb Inspecting 1 file C Offenses: sample.rb:1:3: C: [Corrected] Layout/SpaceBeforeComma: Space found before comma. [1 , 2] ^ sample.rb:1:8: C: [Corrected] Layout/TrailingBlankLines: Final newline missing. [1 , 2] 0 files inspected, 2 offenses detected, 2 offenses corrected Infinite loop detected in sample.rb. /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:270:in `check_for_infinite_loop' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:253:in `block in iterate_until_no_changes' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:252:in `loop' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:252:in `iterate_until_no_changes' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:223:in `do_inspection_loop' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:126:in `block in file_offenses' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:144:in `file_offense_cache' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:124:in `file_offenses' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:112:in `process_file' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:89:in `block in each_inspected_file' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:86:in `each' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:86:in `reduce' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:86:in `each_inspected_file' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:76:in `inspect_files' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/runner.rb:48:in `run' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cli.rb:174:in `execute_runner' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cli.rb:75:in `execute_runners' /home/user/.rbenv/versions/2.6.0/lib/ruby/gems/2.6.0/gems/rubocop-0.63.1/lib/rubocop/cli.rb:47:in `run' test.rb:47:in `<main>'
エラーの場所が変わった。
メソッド名は #check_for_infinite_loop
だ。コードはどうなっているか。
def check_for_infinite_loop(processed_source, offenses) checksum = processed_source.checksum if @processed_sources.include?(checksum) raise InfiniteCorrectionLoop.new(processed_source.path, offenses) end @processed_sources << checksum end
ProcessedSource
のチェックサムが @processed_sources
に登録済みなら InfiniteCorrectionLoop
例外を上げるようにしている。
違反があるもののオートコレクト後のコードに変更がなければ無限ループに陥る。
これを検知し防止する機能には納得がいく。
ここで例外が上がるということは stdin で渡されたコードにオートコレクト結果がうまく反映されていないのかもしれない。 呼び出し元のコードを見てみよう。
def do_inspection_loop(file, processed_source) offenses = [] iterate_until_no_changes(processed_source, offenses) do offenses.select!(&:corrected?) new_offenses, updated_source_file = inspect_file(processed_source) offenses.concat(new_offenses).uniq! break unless updated_source_file processed_source = get_processed_source(file) end [processed_source, offenses] end def iterate_until_no_changes(source, offenses) @processed_sources = [] iterations = 0 loop do check_for_infinite_loop(source, offenses) if (iterations += 1) > MAX_ITERATIONS raise InfiniteCorrectionLoop.new(source.path, offenses) end source = yield break unless source end end
元のコードではコメントに興味深いメモがいくつか書いてあるが、ここでは割愛している。 RuboCop をより深く理解したい方はぜひ元のコードを参照していただきたい。
#iterate_until_no_changes
では先の #check_for_infinite_loop
で無限ループをチェックしているほか、ループ回数にも上限を設けている。
#do_inspection_loop
にはループ内の実処理が書かれている。
…おや、ここにも #get_processed_source
がある。
ソースコードを再取得しているということは、ここにオートコレクト結果が反映されていないのかもしれない。
結果として無限ループに陥っている恐れがある。
#get_processed_source
があるブロック内でオートコレクトをしていそうなのはどこだろうか。
#inspect_file
がそれっぽいようだ。
def inspect_file(processed_source) config = @config_store.for(processed_source.path) team = Cop::Team.new(mobilized_cop_classes(config), config, @options) offenses = team.inspect_file(processed_source) [offenses, team.updated_source_file?] end
まだオートコレクトの実装は見えない。
RuboCop::Cop::Team#inspect_file
を追ってみる。
とその名もずばり #autocorrect
メソッドがあった。
module RuboCop module Cop class Team def autocorrect(buffer, cops) new_source = autocorrect_all_cops(buffer, cops) if @options[:stdin] @options[:stdin] = new_source else filename = buffer.name File.open(filename, 'w') { |f| f.write(new_source) } end end end end end
オートコレクトした結果をファイルに書き出している!
RuboCop::Runner#do_inspection_loop
内で #get_processed_source
によってコードを再取得しているのはこの仕様によるものだろう。
そしてオートコレクトされた new_source
はそれ以外には使われない。これでは無限ループが検出されるのも当然だ。
オートコレクト結果を利用するにはどうしたらいいだろう。
もう一度コードを見てみると、new_source
を使用している部分はまだある。
--stdin
オプションが指定されたときは @options[:stdin]
に記録されるようだ。
これは利用できるのではないだろうか。
stdin 再び
RuboCop::Cop::Team#autocorrect
で stdin 側の処理を利用するのなら、メソッドを呼び出す直前で @options[:stdin]
に FilePath#source
セットしてみよう。
念のため、そのあとで @options[:stdin]
を元の状態に戻しておこう。
FilePath
は 引数 cops
から取得できる。
module TeamModifier def autocorrect(buffer, cops) code = cops[0].processed_source.path if code.is_a?(FilePath) stdin_backup = @options[:stdin] @options[:stdin] = code.source result = super code.source = @options[:stdin] @options[:stdin] = stdin_backup result else super end end end RuboCop::Cop::Team.prepend TeamModifier
Inspecting 1 file C Offenses: sample.rb:1:3: C: [Corrected] Layout/SpaceBeforeComma: Space found before comma. [1 , 2] ^ sample.rb:1:8: C: [Corrected] Layout/TrailingBlankLines: Final newline missing. [1 , 2] 1 file inspected, 2 offenses detected, 2 offenses corrected [1, 2]
オートコレクトされた結果が得られた!stdin
バンザイ!
とここで改めて RuboCop::Runner#get_processed_source
を見てみると
module RuboCop class Runner # : def get_processed_source(file) ruby_version = @config_store.for(file).target_ruby_version if @options[:stdin] ProcessedSource.new(@options[:stdin], ruby_version, file) else ProcessedSource.from_file(file, ruby_version) end end end end
@options[:stdin]
で分岐しているという点が共通している。
同じ手法が使えそうだ。
抽象化してモジュールとして抽出し、これをつかうようにしてみよう。
require "rubocop" class FilePath < String attr_accessor :source def initialize(str, source) super(str) @source = source end end module HijackSTDIN def hijack_stdin_opt(filepath) if filepath.is_a?(FilePath) stdin_backup = @options[:stdin] @options[:stdin] = filepath.source result = yield filepath.source = @options[:stdin] @options[:stdin] = stdin_backup result else yield end end end module RunnerModifier include HijackSTDIN private def find_target_files(paths) file_paths, strs = paths.partition { |path| path.is_a?(FilePath) } if file_paths.empty? super(strs) elsif strs.empty? file_paths else file_paths + super(strs) end end def get_processed_source(file) hijack_stdin_opt(file) do super end end def cached_run? false end end RuboCop::Runner.prepend RunnerModifier module TeamModifier include HijackSTDIN def autocorrect(buffer, cops) code = cops[0].processed_source.path hijack_stdin_opt(code) do super end end end RuboCop::Cop::Team.prepend TeamModifier cli = RuboCop::CLI.new source_code = FilePath.new("sample.rb", "[1 , 2]") cli.run(["--auto-correct", source_code]) puts source_code.source
Inspecting 1 file C Offenses: sample.rb:1:3: C: [Corrected] Layout/SpaceBeforeComma: Space found before comma. [1 , 2] ^ sample.rb:1:8: C: [Corrected] Layout/TrailingBlankLines: Final newline missing. [1 , 2] 1 file inspected, 2 offenses detected, 2 offenses corrected [1, 2]
問題なさそうだ。
おまけに ruby_version
を得るための重複したコードもなくなった。
これでオートコレクトの準備はできた。
RDoc 内のコードをオートコレクト
C のコードからコメントを抽出する方法は先に書いたとおり。 ここから更に Ruby のコードを抜き出し、修正後に元に戻さなければならないのだが、泥臭くテキスト処理をするだけなのでばっさりカット。
気になる方は冒頭に記載した rdoc_rubocop gem の実装を見てください。
RDocRuboCop::RDoc
あたりが該当します。