Link and Motivation Developers' Blog

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

続・作って理解するpry(Ripperでシンタックスハイライト)

はじめに

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

pryというツールをマネしながら対話型デバッグツールを作成していた 前回の記事の続きになります。

前回までの様子

シンタックスハイライト

どうも味気ない印象なので、見栄えを良くしていきたいです。
エスケープシーケンスで色やスタイルを付与したいと思います。

試行錯誤の末、テストが最終的にこんな感じになりました。

{
  '42'       => "\e[1;34m42",
  '6.7'      => "\e[1;35m6.7",
  '"string"' => "\e[31m\"\e[31mstring\e[31m\"",
  'class'    => "\e[32mclass",
  'Object'   => "\e[1;34;4mObject",
}.each do |input, expected|
  assert(expected, Pry::SyntaxHighlighted.new(input).to_s)
end

前回と同様、左が入力値、右が期待値になります。

"string"に対するエスケープシーケンスが冗長なことに気付かれるかもしれません。 これは今回、字句解析器を利用して抽出されたトークンひとつひとつに対して付与した為です。

具体的にはRipper::Filterを使用しました。実装です。

class Ripper::Filter (Ruby 3.1 リファレンスマニュアル)

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

Class.new(Ripper::Filter)Ripper::Filterを親とするクラスを作成できます。

そして、on_defaultメソッドを定義します。 字句解析でトークンが抽出される度にデフォルトで呼ばれるメソッドです。

メソッドの引数には

  • 抽出したトークンに応じたイベントの識別子
  • トークンの値
  • 毎回の処理の結果

が渡されます。

インスタンス化した後に空文字とともにparseを呼びますが、これが「毎回の処理の結果」の初期値になります。

どんな感じになるか出力して確認していたのがこちらです。

シンタックスハイライト(実装中)

これを既に実装した分に反映していきます。

まずは入力の評価結果の文字列を装飾してみます。

  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)
+     puts Pry::SyntaxHighlighted.new(Pry::Output.new(self, input))
    end
  end

ついでにユーザが入力した文字も装飾してみます。 入力中にリアルタイムで反映させるのは難しそうですが 読み込んだ後なら簡単な方法があります。

  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 "\e[1A\e[2C#{Pry::SyntaxHighlighted.new(input)}"
      puts Pry::SyntaxHighlighted.new(Pry::Output.new(self, input))
    end
  end

REPLのReadした直後にエスケープシーケンス \e[1A\e[2C(1つ上、2つ右へ) でカーソルをプロンプトの直後まで移動させます。 そして装飾した文字列で元々ユーザが入力していた文字列を上書くことにしています。

シンタックスハイライト(評価結果・ユーザ入力)

pryを使う時と同じ様にメソッド定義の途中で改行したらsyntax errorになってしまい、何度か撮り直しました ^^;
何らかの条件で続きの入力を促す様にするべきですが、どうするべきか。。興味深いですね。

さておき、同じ要領でブレークポイント周辺のソース表示も装飾してみました。

シンタックスハイライト(ソース表示)

かなり、いいんじゃないでしょうか?!
このキャプチャだけなら本家と遜色ない様な気がします!!

おわりに

今回は以上になります。

少しのコードで見た目が大分華やかになったのが嬉しかったです!
(もちろん、Ripperのおかげではあるのですが)

機能的にはまだまだなので、また弄るかもしれません。

最後に、ここまでのコードを貼っておきます。※テストコードは抜きました

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

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

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 "\e[1A\e[2C#{Pry::SyntaxHighlighted.new(input)}"
      puts Pry::SyntaxHighlighted.new(Pry::Output.new(self, input))
    end
  end
end

a = 1
b = 2

binding.pry