やわらかテック

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

gemにbinding.irbを仕込んでデバッグする方法

OSSのコードを読むときは基本的にgithub.devを使っていますが、最近、限界を感じています。
というのも巨大なコードベースを持つOSSの場合は処理を追うだけで大変です。その上、この変数には〇〇クラスのインスタンスが...。この関数の引数には△△が指定されて...と自分の脳(揮発性メモリ)では非常に辛い作業です。

最近はrubocopのコードを読んでいるのですが、何度も「ローカル環境で実行できればなぁ...」と感じていました。
そんな中、技術書典で「Rails のコードを読む」という書籍を購入して読み進めていると、ローカルでRailsにbinding.irbを仕込んでデバッグする方法が紹介されていました。

techbookfest.org

まさに自分が求めていたものです...。
今回は、書籍を参考にRailsではなくrubocopにbinding.irbを仕込んでデバッグしてみます。

実行環境の準備

まずはrubocopの実行環境を用意します。
他のgemの場合でも何らかの形でgem内のコードが実行されることが確認できれば問題ありません。
今回はrubocopの実行結果がターミナルに表示された所で確認完了とします。

$ mkdir rubocop-debug
$ cd rubocop-debug
$ bundle init
Writing new Gemfile to ../rubocop-debug/Gemfile

生成されたGemfileに以下を追加。

gem 'rubocop', require: false

gemをインストール。

$ bundle i
Fetching gem metadata from https://rubygems.org/.........
:
Using rubocop 1.52.1
Bundle complete! 1 Gemfile dependency, 13 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.

rubocopにパースしてもらうために適当なファイルを用意します。コードは適当です。

main.rb

puts "hello world!"

bundle経由でインストールしたrubocopを実行します。
この時点で、rubocopの実行結果がターミナルに出力されていれば確認完了です。

bundle exec rubocop main.rb
Inspecting 1 file
C
:

1 file inspected, 2 offenses detected, 2 offenses autocorrectable

gemのクローンと切り替え

次はbundle installのインストール先をローカル環境のファイルに変更します。
といってもパスを指定するだけなので、gemのソースコードをローカル環境に用意すればOKです。
githubからcloneしてrubocopのソースコードを用意します。gemによってはファイルサイズが大きく少し時間がかかるかもしれません。

$ pwd
../rubocop-debug

$ git clone https://github.com/rubocop/rubocop.git
Cloning into 'rubocop'...
:
Resolving deltas: 100% (103266/103266), done.

$ ls
Gemfile     Gemfile.lock    main.rb     rubocop

クローンしたrubocopのバージョンを切り替えます。
今回は特にバージョンのこだわりはないので、最新版のrubocopに切り替えます。masterブランチのままでも良いですが、リリース後に何かしらの変更が入っていたりするので、タグを使ってリリースされたバージョンのコードに切り替えることをオススメします。

$ cd rubocop/
$ git checkout refs/tags/v1.52.1
Note: switching to 'refs/tags/v1.52.1'.
:

HEAD is now at 276a53801 Cut 1.52.1

あとはGemfileを変更して、クローンしたrubocopを参照するようにします。

# gem 'rubocop', require: false
gem 'rubocop', path: './rubocop'

インストールを再実行します。
rubocopのインストール元を見てみるとローカル環境のrubocopを指していることが分かります。

$ bundle i
 bundle i
Resolving dependencies...
Fetching gem metadata from https://rubygems.org/.........
:
Using rubocop 1.52.1 from source at `rubocop` and installing its executables # ローカルからインストールしている
Bundle complete! 1 Gemfile dependency, 13 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.

ようやく準備が完了しました。再度、rubocopを実行してみます。

$ bundle exec rubocop main.rb
Inspecting 1 file
C
:

1 file inspected, 2 offenses detected, 2 offenses autocorrectable

binding.irbを仕込む

あとはデバッグしたい箇所にbinding.irbを仕込むだけです。
gemの場合、lib直下に配置されたgem名に対応するファイル(例: rubocop.rb)が最初に実行されるため、とりあえず最初にbinding.irbを仕込むにはもってこいの場所です。

rubocop/lib/rubocop.rb

unless File.exist?("#{__dir__}/../rubocop.gemspec") # Check if we are a gem
  RuboCop::ResultCache.rubocop_required_features = $LOADED_FEATURES - before_us
end

binding.irb

RuboCop::AST.rubocop_loaded if RuboCop::AST.respond_to?(:rubocop_loaded)

rubocopを実行してみると、先ほど仕込んだbinding.irbによって処理が停止します。

$ bundle exec rubocop main.rb

From: ../rubocop/lib/rubocop.rb @ line 758 :

    753: 
    754: unless File.exist?("#{__dir__}/../rubocop.gemspec") # Check if we are a gem
    755:   RuboCop::ResultCache.rubocop_required_features = $LOADED_FEATURES - before_us
    756: end
    757: 
 => 758: binding.irb
    759: 
    760: RuboCop::AST.rubocop_loaded if RuboCop::AST.respond_to?(:rubocop_loaded)

あとはやりたい放題です。
試しに、関数の実行結果を見てみましょう。

irb(main):001:0> File.exist?("#{__dir__}/../rubocop.gemspec")
=> true
irb(main):002:0> RuboCop::AST.rubocop_loaded
=> nil

より詳細にデバッグがしたければdebugコマンドを使用します。
以前の記事にも書いたのですが、irbのバージョンが古いとdebugコマンドが使えないのでご注意ください。

www.okb-shelf.work

debugモードに移行してnextを使って、次にどの処理が呼び出されるのか追ってみます。
rubocop.rbが実行された後、rubocop/exe/rubocopが実行されているようです。

irb(main):003:0> debug
Loaded debug-1.8.0
(rdbg) next    # command
[12, 21] in ~/workspace/ruby/rubocop-reading/rubocop/exe/rubocop
    12|   exit_status = RuboCop::Server::ClientCommand::Exec.new.run
    13| else
    14|   require 'benchmark'
    15|   require 'rubocop'
    16| 
=>  17|   cli = RuboCop::CLI.new
    18| 
    19|   time = Benchmark.realtime { exit_status = cli.run }
    20| 
    21|   puts "Finished in #{time} seconds" if cli.options[:debug] || cli.options[:display_time]

デバッグ画面上でcli.runを実行してみた所、ターミナルに出力された実行結果が表示されました。

(ruby) cli.run
Inspecting 1 file
C              
               
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!"
     ^^^^^^^^^^^^^^
main.rb:1:20: C: [Correctable] Layout/TrailingEmptyLines: Final newline missing.
puts "hello world!"
                   

1 file inspected, 3 offenses detected, 3 offenses autocorrectable
1

どうやら次はRuboCop::CLIクラスの実装を追っていけば良さそうですね。
続きが非常に気になりますが、今回はコードリーディングがメインではないのでここで止めておきます。

最後に

無事にローカル環境でgemにbinding.irbを仕込んでデバッグすることができました。
別のgemでも同様の手順で再現ができると思います。ただ、ローカルにクローンしたgemが内部で使用しているgemは別途、クローンする必要があります。場合によっては大量の依存ファイルを手元に用意する必要があります。

これでOSSのコードリーディングがはかどりそうです。
きっかけを頂いた「Rails のコードを読む」の著者 hachiさんには感謝しかありません。書籍の内容もとても素晴らしく勉強になりました。

techbookfest.org

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