Link and Motivation Developers' Blog

リンクアンドモチベーションの開発者ブログです

作って理解するpry(対話型デバッグツール)

はじめに

バックエンドエンジニアのやまぴーです。

自分は割と道具にこだわる方で、シェルやエディタの設定を弄ることも多いです。
ところが最近、良く使う割に手付かずな領域に気付きました。

デバッガです!!

業務ではRubyでpry(pry-byebug)を使うのですが、設定項目を読んでいたところ1から作ってみたくなってきました。

自分で書いたデバッガを普段の開発で使ったら、楽しそうじゃないですか?

ということで、やってみることにしました。

作りたい機能

普段使っているpryは対話型のデバッグツールなのですが
それに倣うと、次の機能は欲しいなーと思っています。

REPL

まずは基本的なREPL(Read-Eval-Print Loop)を用意していきます。

Rubyではローカルスコープのコンテキスト情報がBindingというオブジェクトで表現されて、Kernel#bindingでアクセスすることができます。

これを利用してpryではbinding.pryというイディオムを記述するだけで
記述箇所の実行時にREPLが開始されるという仕組みになっています。これをマネます。

class Binding
  def pry
    loop { pp eval(Readline.readline('> ')) }
  end
end

a = 1
b = 2

binding.pry

本体が1行で書けて、なんとなく嬉しいですね。

loop { pp eval(Readline.readline('> ')) }

反対から読むとちゃんとreplになる感じが良いです。

Readlineは普段から色々なところでお世話になっていますが、Rubyでも標準ライブラリとして提供されていますね。

さっそく、使ってみます。

f:id:lm_ymap:20220415120924g:plain
REPL

テストの準備

(先に考えようよという声が聴こえてきそうですが ^^;)
なんとなく、プログラムの特性上テスト書くの難しそうだしブログネタだし、いいかなーと思っていたのですが
やっぱりテスト書いていこうかなと思います。

簡易的ですが、こんなアサーションを用意しました。

def assert(expected, actual)
  puts "expected #{expected}, but got #{actual}" if expected != actual
end

既にloopは書いてしまいましたが、テストする上で少し手を入れたいです。
標準出力のキャプチャは面倒なので、少し大袈裟ですが出力内容を表すOutputオブジェクトを導入、文字列表現を返します。

テストする上で必要になる、Bindingオブジェクトは次の様に取得します。

_binding = -> { foo = 1; binding }.call

一応、新しいスコープから返す様にしました。今後、複数個用意した時に互いに汚染しない為の配慮です。 とはいえ、外側のローカル変数も見えてしまうので完全に綺麗な環境という訳ではないですが、一旦は十分かなと思います。

ローカル変数の値表示と、再代入後の表示をテストしました。

assert("1\n", Pry::Output.new(_binding, 'foo').to_s)
assert("2\n", Pry::Output.new(_binding, 'foo=2; foo').to_s)

実装です。

module Pry
  class Output
    def initialize(binding, input)
      @binding = binding
      @input = input
    end

    def to_s
      @binding.eval(@input).pretty_inspect
    end
  end
end

module名は特に思いつかなかったので、とりあえずPryにしました。 本家と両方requireしたら壊れるかも?しれないので名前は後でちゃんと考えたいですね。

呼び出し元のループも書き変えます。

    loop do
      input = Readline.readline('> ')
      puts Pry::Output.new(self, input)
    end

入力補完

pryでは途中まで入力すると残りの部分を補完できる機能があります。
これを作ってみたいと思います。

といっても簡単で、Readlineライブラリの力を借りるだけです。

Readline.completion_proc=

ユーザ入力を引数に取って、候補の文字列の配列を返すProcを代入すればOKの様です。

ローカル変数とメソッドとクラス定数あたりが補完できればいいかな〜と思い、テストは最終的にこうなりました。

_binding = -> { foo = 1; binding }.call

assert(
  ['foo'],
  Pry::Completion::Candidates.new(_binding, 'fo').to_a
)

assert(
  ['__send__'],
  Pry::Completion::Candidates.new(_binding, '__send').to_a
)

assert(
  ['Array', 'ArgumentError'],
  Pry::Completion::Candidates.new(_binding, 'Ar').to_a
)

実装

module Pry
  module Completion
    class Candidates
      def initialize(binding, input)
        @binding = binding
        @input = input
      end

      def to_a
        @binding.local_variables
          .concat(@binding.receiver.methods)
          .concat(@binding.receiver.class.constants)
          .map(&:to_s)
          .select { |s| s.start_with?(@input) }
      end
    end
  end
end

そうしたらREPLのloopに組み込む為のヘルパー的なものを用意して

module Pry
  module Completion
    def self.enable(binding)
      @@old_completion_proc = Readline.completion_proc
      Readline.completion_proc = -> i { Candidates.new(binding, i).to_a }
    end

    def self.disable
      Readline.completion_proc = @@old_completion_proc
    end
  end
end

組み込みました。

    loop do
      Pry::Completion.enable(self)
      input = Readline.readline('> ')
      Pry::Completion.disable
      puts Pry::Output.new(self, input)
    end

f:id:lm_ymap:20220415120105g:plain
入力補完

each_objectも補完してほしかったですが、それは無理でしたね。(考えてみれば当然ですが)
やるとしたら入力されたコードの構文解析しないと駄目ですかね^^; 一旦Todo行きです。

ソース表示

入力補完できて嬉しいのですが、なんとなく物足りないです。
何かなーと思うと、ブレークポイントを貼った周辺のソースが見たいんですね。これがないと雰囲気が出ません。

いつも見ているのが大体こんな表示です。
(ちょっと準備が増えたのでテーブル駆動風にテストを書いてみました。)

{
  1 => <<~EOF,
  => 1: def fizzbuzz(n)
     2:   if n % 15 == 0
     3:     'FizzBuzz'
     4:   elsif n % 3 == 0
     5:     'Fizz'
     6:   elsif n % 5 == 0
  EOF
  6 => <<~EOF,
      1: def fizzbuzz(n)
      2:   if n % 15 == 0
      3:     'FizzBuzz'
      4:   elsif n % 3 == 0
      5:     'Fizz'
  =>  6:   elsif n % 5 == 0
      7:     'Buzz'
      8:   else
      9:     n.to_s
     10:   end
     11: end
  EOF
  11 => <<~EOF,
      6:   elsif n % 5 == 0
      7:     'Buzz'
      8:   else
      9:     n.to_s
     10:   end
  => 11: end
  EOF
}.each do |lineno, expected|
  binding_stub = OpenStruct.new(source_location: ["fixture.rb", lineno])
  assert(expected, Pry::SourceNavigation.new(binding_stub).to_s)
end

前後5行を表示して、止まっている場所を指し示してくれます。

行番号がずれるとテストが壊れるので、面倒ですが別途ファイル(fixture.rb)を用意しました。FizzBuzzが書いてあります。

OpenStructはBinding#source_locationのstubとして使っています。
このあたりは実装を見ていただくのが早いと思います。

module Pry
  class SourceNavigation
    DISTANCE = 5

    def initialize(binding)
      @binding = binding
    end

    def to_s
      filepath, lineno = @binding.source_location
      lines = File.readlines(filepath)

      offset = lineno - 1
      from = [offset - DISTANCE, 0].max
      to = [offset + DISTANCE, lines.size].min

      prependings = (1..lines.count).map do |n|
        arrow = n == lineno ? '=>' : '  '
        no = sprintf("%#{to.digits.size}d", n)

        "#{arrow} #{no}: " 
      end

      prependings.zip(lines).slice(from..to).join
    end
  end
end
  • 表示する範囲のfrom, toを有効な値に調整
  • 表示用の行番号の幅を揃える

あたりで少し膨らんでしまいました。

あと、Array#join再帰的に連結してくれることを初めて知りました。
ちょっと分かりずらいかもですが、最後のあたりを途中

-      prependings.zip(lines).slice(from..to).map(&:join).join
+      prependings.zip(lines).slice(from..to).join

と書き変えました。地味に嬉しい。(業務で使うかは微妙なところですが^^;)

REPLのloopに加えておきます。

    loop do
      Pry::Completion.enable(self)
      puts Pry::SourceNavigation.new(self).then { |s| "\n%s\n" % s }
      input = Readline.readline('> ')

見やすさを考えて前後に改行を加えておきました。確認してみます。

f:id:lm_ymap:20220415114320g:plain
ソース表示

おわりに

だいぶ書いたので、一旦区切りをつけたいと思います。
色々と発見があって面白かったですが、常用するにはきついところが多いのでまた続きをやりたいです。

最後に、ここまでのコードを貼っておきます。

require 'pp'
require 'readline'
require 'ostruct'

def assert(expected, actual)
  puts "expected #{expected.inspect}, but got #{actual.inspect}" if expected != actual
end

module Pry
  module Completion
    class Candidates
      def initialize(binding, input)
        @binding = binding
        @input = input
      end

      def to_a
        @binding.local_variables
          .concat(@binding.receiver.methods)
          .concat(@binding.receiver.class.constants)
          .map(&:to_s)
          .select { |s| s.start_with?(@input) }
      end
    end
  end
end

module Pry
  module Completion
    def self.enable(binding)
      @@old_completion_proc = Readline.completion_proc
      Readline.completion_proc = -> i { Candidates.new(binding, i).to_a }
    end

    def self.disable
      Readline.completion_proc = @@old_completion_proc
    end
  end
end

_binding = -> { foo = 1; binding }.call

assert(
  ['foo'],
  Pry::Completion::Candidates.new(_binding, 'fo').to_a
)

assert(
  ['__send__'],
  Pry::Completion::Candidates.new(_binding, '__send').to_a
)

assert(
  ['Array', 'ArgumentError'],
  Pry::Completion::Candidates.new(_binding, 'Ar').to_a
)

module Pry
  class SourceNavigation
    DISTANCE = 5

    def initialize(binding)
      @binding = binding
    end

    def to_s
      filepath, lineno = @binding.source_location
      lines = File.readlines(filepath)

      offset = lineno - 1
      from = [offset - DISTANCE, 0].max
      to = [offset + DISTANCE, lines.size].min

      prependings = (1..lines.count).map do |n|
        arrow = n == lineno ? '=>' : '  '
        no = sprintf("%#{to.digits.size}d", n)

        "#{arrow} #{no}: " 
      end

      prependings.zip(lines).slice(from..to).join
    end
  end
end

{
  1 => <<~EOF,
  => 1: def fizzbuzz(n)
     2:   if n % 15 == 0
     3:     'FizzBuzz'
     4:   elsif n % 3 == 0
     5:     'Fizz'
     6:   elsif n % 5 == 0
  EOF
  6 => <<~EOF,
      1: def fizzbuzz(n)
      2:   if n % 15 == 0
      3:     'FizzBuzz'
      4:   elsif n % 3 == 0
      5:     'Fizz'
  =>  6:   elsif n % 5 == 0
      7:     'Buzz'
      8:   else
      9:     n.to_s
     10:   end
     11: end
  EOF
  11 => <<~EOF,
      6:   elsif n % 5 == 0
      7:     'Buzz'
      8:   else
      9:     n.to_s
     10:   end
  => 11: end
  EOF
}.each do |lineno, expected|
  binding_stub = OpenStruct.new(source_location: ["fixture.rb", lineno])
  assert(expected, Pry::SourceNavigation.new(binding_stub).to_s)
end

module Pry
  class Output
    def initialize(binding, input)
      @binding = binding
      @input = input
    end

    def to_s
      @binding.eval(@input).pretty_inspect
    end
  end
end

_binding = -> { foo = 1; binding }.call

assert("1\n", Pry::Output.new(_binding, 'foo').to_s)
assert("2\n", Pry::Output.new(_binding, 'foo=2; foo').to_s)

class Binding
  def pry
    loop do
      Pry::Completion.enable(self)
      puts Pry::SourceNavigation.new(self).then { |s| "\n%s\n" % s }
      input = Readline.readline('> ')
      Pry::Completion.disable
      puts Pry::Output.new(self, input)
    end
  end
end

a = 1
b = 2

binding.pry