はじめに
バックエンドエンジニアのやまぴーです。
pryっぽい対話型デバッグツールを作成している記事の3回目になります。
前回はシンタックスハイライトを実現して喜んでいました。
見た目が良くなったので、大分やる気があがりました^^;
という訳で、今回はちょっと大変かもと思っていたステップ実行機能を作っていきます。
(尚、pry自体にステップ実行機能は無いですが、普段はpry-byebugを入れているので挙動はそちらを参考にしていきます)
方針
今回の鍵となるTracePointについて、簡単に説明です。
プログラム中のイベントをトリガーにして、任意の処理を実行させることができます。
プログラム中のイベントというのは例えば
- 式の評価
- メソッド呼び出し
- メソッドからのリターン
等です。 一覧は こちら で確認できます。
これを使うとステップ実行、出来そうじゃないですか?具体的には
- 現在のスタックトレースを確認して、ちょうど良いところだったらbinding.pryを実行する
という処理を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のオブジェクトになります。
できます。ここで肝心の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