RubyVM::AbstractSyntaxTree.ofをevalやirbで動かすために調べたこと

調査結果を書いただけで、あまり良い解決方法は見つけられていない・・・。

調べたきっかけ

RubyKaigi 2019でRubyVM::AbstractSyntaxTree.ofメソッドを 悪用 活用している方々の発表を聞いて、自分でも試してみようとirbでメソッドを使ったら、無慈悲にもエラーメッセージが表示された。

irb(main):001:0> pp RubyVM::AbstractSyntaxTree.of(proc { 1 + 2 })
Traceback (most recent call last):
        5: from /Users/sinsoku/.rbenv/versions/2.6.2/bin/irb:23:in `<main>'
        4: from /Users/sinsoku/.rbenv/versions/2.6.2/bin/irb:23:in `load'
        3: from /Users/sinsoku/.rbenv/versions/2.6.2/lib/ruby/gems/2.6.0/gems/irb-1.0.0/exe/irb:11:in `<top (required)>'
        2: from (irb):1
        1: from (irb):1:in `of'
Errno::ENOENT (No such file or directory @ rb_sysopen - (irb))

「自分の使い方が悪い・・・?」「いや、空気を読んで動いてくれないRubyが悪い」

という気持ちになったので、ソースコードを読んで直せないか調べてみた。

RubyVM::AbstractSyntaxTree.of のコードの概要

どうやらast.cの中のrb_ast_s_ofの関数が呼ばれているっぽい。

VALUE path, node, lines;
int node_id;
const rb_iseq_t *iseq = NULL;

if (rb_obj_is_proc(body)) {
    iseq = vm_proc_iseq(body);

    if (!rb_obj_is_iseq((VALUE)iseq)) {
        iseq = NULL;
    }
}
else {
    iseq = rb_method_iseq(body);
}

if (!iseq) return Qnil;

path = rb_iseq_path(iseq);
node_id = iseq->body->location.node_id;

コード片(ブロックで渡されたコード)を受け取って、iseqを作って、node_idを取り出している。

if (!NIL_P(lines = script_lines(path))) {
    node = rb_ast_parse_array(lines);
}
else if (RSTRING_LEN(path) == 2 && memcmp(RSTRING_PTR(path), "-e", 2) == 0) {
    node = rb_ast_parse_str(rb_e_script);
}
else {
    node = rb_ast_parse_file(path);
}

return node_find(node, node_id);

これらの分岐は「ソースコード全体をASTにしたnodeを取得する」処理です。 つまり、 ソースコード全体のASTからコード片と同じnode_idのASTを探して返す ような実装になっています。

コード片だけではファイル内の行列数が分からないため、ソースコード全体のASTを一度作る必要があるのかな。

エラーの原因

エラーメッセージの「Errno::ENOENT (No such file or directory @ rb_sysopen - (irb))」で分かるように、 rb_ast_parse_file が例外を出しています。 これを何かしらの方法で避ける必要がありそうです。

SCRIPT_LINES__ を使う処理

ソースコード全体のASTを取得する処理は以下の3つの分岐になっています。

  1. SCRIPT_LINES__ で取得した配列を使う処理
  2. ruby -e に対応するための処理
  3. ソースコードのファイルを読み込む処理

script_lines関数のコードを読むと分かるように、 SCRIPT_LINES__ の定数からソースコードの配列を取得しています。 つまり、evalやirbで渡した文字列をSCRIPT_LINES__に配列として突っ込めば、良い感じに動きそうです。

eval を上書きしてみる

module AbstractSyntaxTreeOfPatch
  REPL_FNAME = ["(eval)", "(irb)"].freeze

  def eval(expr, bind = nil, fname = "(eval)", lineno = 1)
    return super unless REPL_FNAME.include?(fname)

    defined = Object.const_defined?(:SCRIPT_LINES__)
    Object.const_set(:SCRIPT_LINES__, {}) unless defined
    Object::SCRIPT_LINES__[fname] = [expr]

    super.tap do
      if defined
        Object::SCRIPT_LINES__.delete(fname)
      else
        Object.send(:remove_const, :SCRIPT_LINES__)
      end
    end
  end
end
Object.prepend(AbstractSyntaxTreeOfPatch)

# コード内
pp RubyVM::AbstractSyntaxTree.of(proc { 1 + 2 })
# eval
pp eval('RubyVM::AbstractSyntaxTree.of(proc { 1 + 2 })')
binding.irb
# irbで `pp RubyVM::AbstractSyntaxTree.of(proc { 1 + 2 })` を試す
puts

こんな感じでevalを上書きすることで、それっぽいASTが返ってくるようになった。

いろんな問題

byebugやpryもevalを使っているだろうし、evalを上書きすれば他も簡単に対応できる....と思っていた。 世の中はそんなに甘くなかった。

良い感じに直せたら、ruby/rubyにプルリクを作れるかも・・・?とか思ったけど、色々な問題が起きるので厳しい感じ。

まとめ

あまり良い成果は出ていないけど、とりあえず最近調べていた事をブログにまとめておいた。
irbやevalでASTを作れると、色々と捗りそうなんだけど難しい。

別解: irbでRubyVM::AbstractSyntaxTree.ofを動かす方法

@hanachin_さんがTracePointを使って動かす方法を書かれているので、興味ある方はそちらを読んでみると良いかと思います。

qiita.com