Link and Motivation Developers' Blog

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

第3回・作って理解するpry(TracePointでステップ実行)

はじめに

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

pryっぽい対話型デバッグツールを作成している記事の3回目になります。

前回はシンタックスハイライトを実現して喜んでいました。

見た目が良くなったので、大分やる気があがりました^^;

という訳で、今回はちょっと大変かもと思っていたステップ実行機能を作っていきます。

(尚、pry自体にステップ実行機能は無いですが、普段はpry-byebugを入れているので挙動はそちらを参考にしていきます)

方針

今回の鍵となるTracePointについて、簡単に説明です。

class TracePoint

プログラム中のイベントをトリガーにして、任意の処理を実行させることができます。

プログラム中のイベントというのは例えば

  • 式の評価
  • メソッド呼び出し
  • メソッドからのリターン

等です。 一覧は こちら で確認できます。

これを使うとステップ実行、出来そうじゃないですか?具体的には

という処理をTarcePointでちょこちょこフックさせればいけそうという訳です。

もちろん、スタックトレースを確認する術もあります。

Kernel.#caller_locations (Ruby 3.1 リファレンスマニュアル)

現在のフレームを Thread::Backtrace::Location の配列で返します。

ステップ・オーバー

TracePointを有効にするとすぐ次の式の評価からフックが開始されてしまいます。
するとpryのloopを抜けて呼びだし元まで戻るまでにも何度かフック処理が実行されてしまいます。

その間の判定が面倒かなと思っていたのですが、ステップ・オーバーなら比較的簡単と思い、まずここから手を付けることにしました。

というのも、pryをcallした時よりもスタックが深い間は何もせず、同じレベルに戻ってきたらbinding.pryすれば良いからです。

その頃には、自然とpryのloopも抜けていることになります。

条件式だけTODOでコードにすると下記の様になります。

        TracePoint.trace(:line) do |tp|
          if # TODO: pryをcallした時よりもスタックが深くなければtrue
            tp.disable
            tp.binding.pry
          end
        end

:lineはフック対象のイベントを指定しています。「式の評価」すべてです。
もっと工夫すればフックするイベントの数を減らせそうな気もしますが、シンプルにいきます。

ブロックの引数tpはTracePointのオブジェクトになります。

  • #disable で 次回以降のフックを無効化
  • #binding でイベント発生時のbindingを取得

できます。ここで肝心のbindingが取得できなかったら企画倒れでした^^; 無事にpryの呼びだしが繋がります。

さて、TODOコメントでごまかした条件式の部分を実装します。

pryをcallした時のbinding(つまりpryのレシーバ)と、TracePointで設定したイベント発生時のbindingのそれぞれからスタックの深さを取得しなければいけません。

当初は

binding.eval('caller_locations')

で良いかと思ったのですが、これだとeval自体のフレームも残ってしまい取り回すのに不便だと判明。 後々メソッドが生えそうな予感もあり、ラップしたクラスを作成することにしました。

require 'delegate'

module Pry
  class Frames < DelegateClass(Array)
    def initialize(binding)
      delegate = binding.eval('caller_locations').drop_while do |frame|
        [frame.path, frame.lineno] != binding.source_location
      end

      super(delegate)
    end
  end
end

普段は余り使う機会ないのですが、DelegateClass使ってみました ^^;

これでBinding#source_locationの情報と一致するまで巻き戻した状態のスタックを表現できます。

条件式も含めてステップ・オーバー全体も次の様なコマンドの形で実装できました。

module Pry
  module Command
    class Next
      def initialize(binding)
        @binding = binding
      end

      def execute
        frames = Pry::Frames.new(@binding)
        TracePoint.trace(:line) do |tp|
          if Pry::Frames.new(tp.binding).count <= frames.count
            tp.disable
            tp.binding.pry
          end
        end
      end
    end
  end
end

あとはREPLに組み込むだけです。nが入力された時にステップ・オーバーさせる様にします。

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
+     if input == 'n'
+       Pry::Command::Next.new(self).execute
+       break
+     end
      puts "\e[1A\e[2C#{Pry::SyntaxHighlighted.new(input)}"
      puts Pry::SyntaxHighlighted.new(Pry::Output.new(self, input))
    end
  end
end

実際に確認してみますとこんな感じです。

ステップ・オーバー

お、矢印が動きます!感動!(というか、動くまで頑張った ^^;)

リファクタリング

この調子でステップ・アウトやステップ・イン等も追加していきたいのですが
このまま行くと分岐の部分が無限に膨らみそうなので、少し改修したいと思います。

まずは、EvaluateしてPrintする本来の(?)処理パターンもコマンド化したいと思います。 こんな感じでしょうか。

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

      def execute
        puts "\e[1A\e[2C#{Pry::SyntaxHighlighted.new(@input)}"
        puts Pry::SyntaxHighlighted.new(Pry::Output.new(@binding, @input))
      end
    end
  end
end

ユーザ入力に応じて適切なコマンドを実行する分岐自体もコマンド化したいと思います。

(switch文にしたついでに、nだけじゃなくてnextでもステップ・オーバーできる様にしました)

module Pry
  module Command
    class FromInput
      def initialize(binding, input)
        @command =
          case input
          when 'n', 'next'
            Next.new(binding)
          else
            EvaluateAndPrint.new(binding, input)
          end
      end

      def execute
        @command.execute
      end
    end
  end
end

これで、REPLのループがすっきりした状態に戻せました。

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
-     if input == 'n'
-       Pry::Command::Next.new(self).execute
-       break
-     end
-     puts "\e[1A\e[2C#{Pry::SyntaxHighlighted.new(input)}"
-     puts Pry::SyntaxHighlighted.new(Pry::Output.new(self, input))
+     Pry::Command::FromInput.new(self, input).execute
    end
  end
end

ステップ・アウト

こちらはステップ・オーバーをちょっと変えるだけでできますね。

スタックの深さが最初の状態から減った時に再度pryさせれば良いはずです。

ステップ・オーバーのコードをコピーして演算子を変えるだけです。

        TracePoint.trace(:line) do |tp|
-         if Pry::Frames.new(tp.binding).count <= frames.count
+         if Pry::Frames.new(tp.binding).count < frames.count
            tp.disable
            tp.binding.pry
          end
        end

Finishという名前を付けて、finish または fという入力で実行させることにします。

    class FromInput
      def initialize(binding, input)
        @command =
          case input
          when 'n', 'next'
            Next.new(binding)
+         when 'f', 'finish'
+           Finish.new(binding)
          else
            EvaluateAndPrint.new(binding, input)
          end
      end

ステップ・アウト

ステップ・イン

ステップ・インは少し判定に工夫が必要になります。

pryのloopを抜けて呼びだし元まで戻るまでの間にフック処理が実行されてしまいます。

と上の方で懸念していた通りです。pryのloopを抜けた後かどうかを考慮する必要があります。

一旦こんな判定処理をFramesクラスの方に用意しました。

module Pry
  class Frames < DelegateClass(Array)
    def initialize(binding)
      delegate = binding.eval('caller_locations').drop_while do |frame|
        [frame.path, frame.lineno] != binding.source_location
      end

      super(delegate)
    end
+
+   def in_pry?
+     any? { |frame| frame.base_label == 'pry' }
+   end
  end
end

Thread::Backtrace::Location#base_label

これで

"foobar.rb:420:in `pry'"

の様なスタックトレースの端の方だけ抽出できるので、ここがpryじゃなければpryメソッドの呼び出しじゃないという判定です。

ちょっと怪しい判定方法ですが、これで作成してみます。

    class Step
      def initialize(binding)
        @binding = binding
      end

      def execute
        TracePoint.trace(:line) do |tp|
          next if Pry::Frames.new(tp.binding).in_pry?

          tp.disable
          tp.binding.pry
        end
      end

      def break_repl?
        true
      end
    end

ステップ・イン

おわりに

今回はここまでになります。

Rubyだけでは無理かもと思っていたのですが、TracePointのおかげで意外と簡単にそれっぽく動いています。

コマンドを追加しやすい形になったので、次回はコマンドをどんどん追加していこうと思っています。

最後に、ここまでのソースコードを載せておきます。(252行でした)

require 'pp'
require 'readline'
require 'ripper'
require 'delegate'

module Pry
  class SyntaxHighlighted
    def initialize(source)
      @source = source.to_s
    end

    def to_s
      Class.new(Ripper::Filter) do
        def on_default(event, tok, f)
          escape_seq =
            case event
            when :on_int
              "\e[1;34m"
            when :on_float
              "\e[1;35m"
            when :on_tstring_beg, :on_tstring_content, :on_tstring_end
              "\e[31m"
            when :on_kw
              "\e[32m"
            when :on_const
              "\e[1;34;4m"
            else
              "\e[m"
            end

          f << escape_seq << tok
        end
      end.
      new(@source).
      parse('')
    end
  end
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

module Pry
  class SourceNavigation
    DISTANCE = 5

    def initialize(binding)
      @binding = binding
    end

    def to_s
      filepath, lineno = @binding.source_location
      lines = Pry::SyntaxHighlighted.new(File.read(filepath)).to_s.lines

      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 = format("%#{to.digits.size}d", n)

        "#{arrow} #{Pry::SyntaxHighlighted.new("#{no}:")} "
      end

      prependings.zip(lines).slice(from..to).join
    end
  end
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

module Pry
  class Frames < DelegateClass(Array)
    def initialize(binding)
      delegate = binding.eval('caller_locations').drop_while do |frame|
        [frame.path, frame.lineno] != binding.source_location
      end

      super(delegate)
    end

    def in_pry?
      any? { |frame| frame.base_label == 'pry' }
    end
  end
end

module Pry
  module Command
    class Step
      def initialize(binding)
        @binding = binding
      end

      def execute
        TracePoint.trace(:line) do |tp|
          next if Pry::Frames.new(tp.binding).in_pry?

          tp.disable
          tp.binding.pry
        end
      end

      def break_repl?
        true
      end
    end

    class Next
      def initialize(binding)
        @binding = binding
      end

      def execute
        frames = Pry::Frames.new(@binding)
        TracePoint.trace(:line) do |tp|
          if Pry::Frames.new(tp.binding).count <= frames.count
            tp.disable
            tp.binding.pry
          end
        end
      end

      def break_repl?
        true
      end
    end

    class Finish
      def initialize(binding)
        @binding = binding
      end

      def execute
        frames = Pry::Frames.new(@binding)
        TracePoint.trace(:line) do |tp|
          if Pry::Frames.new(tp.binding).count < frames.count
            tp.disable
            tp.binding.pry
          end
        end
      end

      def break_repl?
        true
      end
    end

    class EvaluateAndPrint
      def initialize(binding, input)
        @binding = binding
        @input = input
      end

      def execute
        puts "\e[1A\e[2C#{Pry::SyntaxHighlighted.new(@input)}"
        puts Pry::SyntaxHighlighted.new(Pry::Output.new(@binding, @input))
      end

      def break_repl?
        false
      end
    end

    class FromInput
      def initialize(binding, input)
        @command =
          case input
          when 's', 'step'
            Step.new(binding)
          when 'n', 'next'
            Next.new(binding)
          when 'f', 'finish'
            Finish.new(binding)
          else
            EvaluateAndPrint.new(binding, input)
          end
      end

      def execute
        @command.execute
      end

      def break_repl?
        @command.break_repl?
      end
    end
  end
end

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
      command = Pry::Command::FromInput.new(self, input)
      command.execute
      break if command.break_repl?
    end
  end
end

def foo
  a = 1
end

binding.pry
foo