RDoc 内のコードをオートフォーマットする gem を作ってみた

TL;DR

  • Ruby 公式コーディングスタイルほしい
  • 公式コーディングスタイル作成はトライしたが断念
  • RDoc 内の Ruby コードをオートフォーマットする rdoc_rubocop gem を作った

RDoc 内のコードをオートフォーマットしよう

きっかけは mattn さんの以下のツイート。

自分も前々から公式コーディングスタイルが欲しい (アプリや 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/SpaceBeforeBlockBracesStyle/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#subString#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トークンの型だ。 トークンがコメントのとき typeon_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_sourcesample.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 あたりが該当します。