はじめに
バックエンドエンジニアのやまぴーです。
自分は割と道具にこだわる方で、シェルやエディタの設定を弄ることも多いです。
ところが最近、良く使う割に手付かずな領域に気付きました。
デバッガです!!
業務ではRubyでpry(pry-byebug)を使うのですが、設定項目を読んでいたところ1から作ってみたくなってきました。
自分で書いたデバッガを普段の開発で使ったら、楽しそうじゃないですか?
ということで、やってみることにしました。
作りたい機能
普段使っているpryは対話型のデバッグツールなのですが
それに倣うと、次の機能は欲しいなーと思っています。
- REPL
- 入力補完
- ソース表示
- シンタックスハイライト
- ステップ実行
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でも標準ライブラリとして提供されていますね。
さっそく、使ってみます。
テストの準備
(先に考えようよという声が聴こえてきそうですが ^^;)
なんとなく、プログラムの特性上テスト書くの難しそうだしブログネタだし、いいかなーと思っていたのですが
やっぱりテスト書いていこうかなと思います。
簡易的ですが、こんなアサーションを用意しました。
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ライブラリの力を借りるだけです。
ユーザ入力を引数に取って、候補の文字列の配列を返す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
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('> ')
見やすさを考えて前後に改行を加えておきました。確認してみます。
おわりに
だいぶ書いたので、一旦区切りをつけたいと思います。
色々と発見があって面白かったですが、常用するにはきついところが多いのでまた続きをやりたいです。
最後に、ここまでのコードを貼っておきます。
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