【Ruby Advent Calendar 2020】Ruby の AST から Ruby のソースコードを復元しよう【1日目】

Ruby Advent Calendar 2020 1日目の記事になります。
もう今年も Advent Calendar の時期ですね。
今年は 12/25日に Ruby 3.0 がリリースされるのでとても楽しみです。
並行して Ruby 3.0 Advent Calendar 2020 も開催しているので興味があるひとはぜひ参加してみてください!
Ruby Advent Calendar 2020 もまだ空きはあるのでこっちも参加してね!
さて、今年の Advent Calendar は Ruby の AST 周りについて書いてみようかと思います。

注意

本記事では Ruby 2.7.2で動作確認を行っています。
AST は Ruby のバージョンに深く依存するので AST 情報を扱う場合は使用する Ruby のバージョンに注意しましょう。

AST とは

AST とは『Abstract Syntax Tree』の略で日本語にすると『抽象構文木』と呼ばれるデータ構造になります。
Ruby では『Ruby のコードをバイトコードに変換する過程』で構文解析を行い、AST への変換を経て Ruby のコードを実行しています。
本記事ではその AST データを『Ruby のコードに復元すること』について書いていきます。
今年はこの『AST から Ruby のコードへ変換する実装』を主に趣味開発していました。

Ruby のコードを AST に変換する

さて、Ruby のコードを AST 情報へと変換する手段はいくつかあるんですが今回は標準ライブラリの RubyVM::AbstractSyntaxTree を利用して AST 情報を取得していきます。
RubyVM::AbstractSyntaxTree の使い方自体は簡単で RubyVM::AbstractSyntaxTree.parse構文解析したい Ruby のコードを文字列で渡すだけです。
渡した Ruby の AST 情報が RubyVM::AbstractSyntaxTree::Node として返ってきます。

code = "1 + 2"
ast = RubyVM::AbstractSyntaxTree.parse(code)
pp ast.class
# => RubyVM::AbstractSyntaxTree::Node
pp ast
# => (SCOPE@1:0-1:5
#     tbl: []
#     args: nil
#     body: (OPCALL@1:0-1:5 (LIT@1:0-1:1 1) :+ (LIST@1:4-1:5 (LIT@1:4-1:5 2) nil)))

RubyVM::AbstractSyntaxTree::Node は『ノードの命令』と『そのノードの子ノード』の2つの情報を保持しており AST という名前の通り『ツリー構造』になっています。
ノードの命令は #type で取得でき、子ノードは #children で取得する事ができます。

code = "1 + 2"
ast = RubyVM::AbstractSyntaxTree.parse(code)
# type がノードの命令の Symbol を返す
pp ast.type
# => :SCOPE

# children がそのノードの子ツリーを返す
pp ast.children
# => [[],
#     nil,
#     (OPCALL@1:0-1:5 (LIT@1:0-1:1 1) :+ (LIST@1:4-1:5 (LIT@1:4-1:5 2) nil))]

他にもそのノードの定義位置の情報を保持していますが、本記事では扱わないので省略します。
Ruby のコードが複雑になればツリー構造もどんどん複雑になっていきます。

code = "1 + 2 * 3 / 4"
ast = RubyVM::AbstractSyntaxTree.parse(code)
# Ruby の構造が複雑になるとどんどんネストしてく
pp ast.children
# => [[],
#     nil,
#     (OPCALL@1:0-1:13 (LIT@1:0-1:1 1) :+
#        (LIST@1:4-1:13
#           (OPCALL@1:4-1:13
#              (OPCALL@1:4-1:9 (LIT@1:4-1:5 2) :*
#                 (LIST@1:8-1:9 (LIT@1:8-1:9 3) nil)) :/
#              (LIST@1:12-1:13 (LIT@1:12-1:13 4) nil)) nil))]

また RubyVM::AbstractSyntaxTree.orProc オブジェクトを渡すとその Proc が持っているブロックの中身の AST 情報を返します。

# proc のブロックの中身をパースする
ast = RubyVM::AbstractSyntaxTree.of(proc{ 1 + 2 })
pp ast
# => (SCOPE@2:40-2:49
#     tbl: []
#     args: nil
#     body:
#       (OPCALL@2:42-2:47 (LIT@2:42-2:43 1) :+
#          (LIST@2:46-2:47 (LIT@2:46-2:47 2) nil)))

余談:Ripper.sexp を使う

Ruby では RubyVM::AbstractSyntaxTree の他に Ripper というライブラリで構文解析を行うことができます。
Ripper では Ripper.sexp を使用して構文解析の結果を S式で取得する事ができます。

require "ripper"

code = "1 + 2"
ast = Ripper.sexp(code)
pp ast
# => [:program, [[:binary, [:@int, "1", [1, 0]], :+, [:@int, "2", [1, 4]]]]]

見て分かるとおり RubyVM::AbstractSyntaxTree とはかなり情報が異なります。
また RubyVM::AbstractSyntaxTree.of 相当の機能は Ripper にはありません(多分 今回はこの RubyVM::AbstractSyntaxTree.of の機能を使いたかったので RubyVM::AbstractSyntaxTree を使用しています。

1 + 2 はどういう AST になるのか

先程は 1 + 2 という Ruby のコードを RubyVM::AbstractSyntaxTree というライブラリを使って構文解析しました。

code = "1 + 2"
ast = RubyVM::AbstractSyntaxTree.parse(code)
pp ast.class
# => RubyVM::AbstractSyntaxTree::Node
pp ast
# => (SCOPE@1:0-1:5
#     tbl: []
#     args: nil
#     body: (OPCALL@1:0-1:5 (LIT@1:0-1:1 1) :+ (LIST@1:4-1:5 (LIT@1:4-1:5 2) nil)))

この時に SCOPE という AST 情報が最初に来るのですが、この SCOPE というノードは『Ruby が暗黙的に追加する』ノードになります。
なので実際の 1 + 2 というコードに対する AST は次のようになります。

code = "1 + 2"
ast = RubyVM::AbstractSyntaxTree.parse(code)

# SCOPE ノードの子を参照する
# これが子ノードの最後が実際にパースしたい Ruby の AST 情報になる
node = ast.children.last

# 1 + 2 のノード情報
pp node

# ノードの種類
pp node.type
# => :OPCALL

# 1 + 2 の子ノード
pp node.children
# => [(LIT@1:0-1:1 1), :+, (LIST@1:4-1:5 (LIT@1:4-1:5 2) nil)]

上記の node の子ノードは、

  • (LIT@1:0-1:1 1)
  • :+
  • (LIST@1:4-1:5 (LIT@1:4-1:5 2) nil)

という 3つの情報を持っています。
これはそれぞれ、

  • (LIT@1:0-1:1 1) -> + の左辺情報
  • :+ -> 演算する演算子情報
  • (LIST@1:4-1:5 (LIT@1:4-1:5 2) nil) -> + の右辺情報

という情報になります。
また、 左辺(LIT@1:0-1:1 1)右辺(LIST@1:4-1:5 (LIT@1:4-1:5 2) nil) なども更にネストした AST 情報となります。
試しに左辺の AST を見てみると以下のようになっていることがわかります。

code = "1 + 2"
ast = RubyVM::AbstractSyntaxTree.parse(code)
node = ast.children.last

# 左辺の AST を取得
left = node.children.first

# 左辺のノードの種類
pp left.type
# => :LIT

# 左辺の子ノード
pp left.children
# => [1]

やっと 1 という値が出てきましたね。
こんな感じで AST がツリー構造となっているのが分かると思います。

余談: 1 + 21.+(2) の違い

Ruby では 1 + 2 のような演算式は 1#+ メソッドを呼び出すことになるので 1.+(2) と同じ意味になります。
しかし RubyVM::AbstractSyntaxTree ではこの2つのコードの構文解析の結果が異なります。

# こっちは OPCALL 命令
pp RubyVM::AbstractSyntaxTree.parse("1 + 2").children.last
# => (OPCALL@1:0-1:5 (LIT@1:0-1:1 1) :+ (LIST@1:4-1:5 (LIT@1:4-1:5 2) nil))

# こっちは CALL 命令
pp RubyVM::AbstractSyntaxTree.parse("1.+(2)").children.last
# => (CALL@1:0-1:6 (LIT@1:0-1:1 1) :+ (LIST@1:4-1:5 (LIT@1:4-1:5 2) nil))

これは 1 + 2 のような演算式と .+ のようなメソッド呼び出しでは AST の意味が異なる為です。

AST から Ruby のコードに復元しよう

さて、ここからが本題になります。
実際に AST から Ruby のコードに変換してみましょう。
まずは簡単に 1 という値の AST を Ruby のコードに変換してみます。
1 の AST は以下のようになります。

code = "1"
ast = RubyVM::AbstractSyntaxTree.parse(code)
pp ast
# => (SCOPE@1:0-1:1 tbl: [] args: nil body: (LIT@1:0-1:1 1))

ここでは SCOPELIT の 2種類のノードが出てきます。
まずはこの 2つのノードに対応する『アンパーサ』をつくってみます。

# AST のノード情報から Ruby のコードに変換する
def unparse(node)

  # ノードの種類から Ruby のコードに変換するための分岐
  case node.type
  when :SCOPE
    # SCOPE はかなり複雑な構造ので今回は body の値を参照するだけに留める
    tbl, args, body = node.children

    # body をそのまま再度 unparse する
    unparse(body)
  when :LIT
    # LIT はリテラル値を保持しているだけなのでその値をそのまま返す
    lit = node.children.first
    lit.to_s
  else
    ""
  end
end


code = "1"
ast = RubyVM::AbstractSyntaxTree.parse(code)
pp unparse(ast)
# => "1"

このようにノードの種類を判別して、その子ノードから更にアンパースしていくような実装になります。
これを踏まえて次は 1 + 2 に対応してみましょう。
1 + 2 の AST は次のようになります。

code = "1 + 2"
ast = RubyVM::AbstractSyntaxTree.parse(code)
pp ast
# => (SCOPE@1:0-1:5
#     tbl: []
#     args: nil
#     body: (OPCALL@1:0-1:5 (LIT@1:0-1:1 1) :+ (LIST@1:4-1:5 (LIT@1:4-1:5 2) nil)))

ここで OPCALLLIST というノードが増えましたね。
OPCALL+ 演算子を表現するノードで LIST はその + メソッドの引数を表現しています。
これを考慮して unparse を実装すると以下のようになります。

# AST のノード情報から Ruby のコードに変換する
def unparse(node)
  case node&.type
  when :SCOPE
    tbl, args, body = node.children
    unparse(body)
  when :LIT
    lit = node.children.first
    lit.to_s
  when :OPCALL
    # 『左辺』、『演算子』、『右辺』の情報を分割して取得する
    left, op, right = node.children

    # 左辺と右辺は再度アンパースして、演算子は Symbol なのでそのままにする
    "#{unparse(left)} #{op} #{unparse(right)}"
  when :LIST
    # 各子ノードをアンパースし、それを , で結合する
    node.children.compact.map { |node| unparse(node) }.join(", ")
  else
    ""
  end
end


code = "1 + 2"
ast = RubyVM::AbstractSyntaxTree.parse(code)
pp unparse(ast)
# => "1 + 2"

# 複数の式も変換できてる!  
code = "1 + 2 + 3 + 4"
ast = RubyVM::AbstractSyntaxTree.parse(code)
pp unparse(ast)
# => "1 + 2 + 3 + 4"

この調子で試しに制御構文も実装してみましょう。

# AST のノード情報から Ruby のコードに変換する
def unparse(node)
  case node&.type
  when :SCOPE
    tbl, args, body = node.children
    unparse(body)
  when :LIT
    lit = node.children.first
    lit.to_s
  when :OPCALL
    left, op, right = node.children
    "#{unparse(left)} #{op} #{unparse(right)}"
  when :LIST
    node.children.compact.map { |node| unparse(node) }.join(", ")
  when :IF
    # 子ノードは以下の3つになる
    # * 条件式
    # * then 部
    # * else 部
    cond, body, else_ = node.children
    <<~EOS
    if #{unparse(cond)}
      #{unparse(body)}
    #{"else\n  #{unparse(else_)}" if else_}
    end
    EOS
  when :VCALL
    # 引数がないメソッド呼び出し
    mid = node.children.first
    mid.to_s
  else
    ""
  end
end


code = <<~EOS
if cond
  foo
else
  bar
end
EOS

ast = RubyVM::AbstractSyntaxTree.parse(code)
puts unparse(ast)
# => if cond
#      foo
#    else
#      bar
#    end


# 後置 if も構文解析すると if 文と同じ構造になる
ast = RubyVM::AbstractSyntaxTree.parse("foo if cond")
puts unparse(ast)
# => if cond
#      foo
#    
#    end

このように各ノードに対して Ruby のコードになるように実装していくことになります。
後置 if が普通の if 文になるのが面白いですね。

余談:どんなノードがあるの

Ruby ではノードの種類がめちゃくちゃ多くて 100個以上のノードがあります。
制御構文を表すノードから変数の定義参照、メソッド呼び出しなどなど様々な種類のノードがあります。
実際にどういうノードがあるのかは Ruby の node.c を参照するといいと思います。
また、以下の記事にも詳しくまとめられているのでこちらを見てみるのもいいと思います。

ちなみにノードの種類は細かく分けられているんですが先程の後置 if のように書き方が違うだけで意味が同じような構文は共通のノードで表される事があります。
例えば Ruby では &&and は優先順位が異なるのですが、優先順位が異なるだけで意味は同じなので共通のノードが使用されています。

# 両方とも同じ AST になる
pp RubyVM::AbstractSyntaxTree.parse("a && b").children.last
# => (AND@1:0-1:6 (VCALL@1:0-1:1 :a) (VCALL@1:5-1:6 :b))
pp RubyVM::AbstractSyntaxTree.parse("a and b").children.last
# => (AND@1:0-1:7 (VCALL@1:0-1:1 :a) (VCALL@1:6-1:7 :b))

では何が違うのかというと構文解析後のツリーの構造が異なるようになります。

# && と and は優先順位が異なるのでノードは同じだがツリーの行動が異なる
# ((a && b) || c) というような構造になる
pp RubyVM::AbstractSyntaxTree.parse("a && b || c").children.last
# => (OR@1:0-1:11 (AND@1:0-1:6 (VCALL@1:0-1:1 :a) (VCALL@1:5-1:6 :b))
#       (VCALL@1:10-1:11 :c))
# (a and (b || c)) というような構造になる
pp RubyVM::AbstractSyntaxTree.parse("a and b || c").children.last
# => (AND@1:0-1:12 (VCALL@1:0-1:1 :a)
#       (OR@1:6-1:12 (VCALL@1:6-1:7 :b) (VCALL@1:11-1:12 :c)))

AST から Ruby のコードに変換する rensei-gem をつくった

はい、では 1個1個のノードを解析していきましょうってやると日が暮れてしまいます。
ということで先程行ったような『ノードから Ruby のコードに変換する』ということを全ノードに対して実装した gem をつくりました。

$ gem install rensei

リポジトリはこちら

使い方も簡単で Rensei.unparseRubyVM::AbstractSyntaxTree::Node を渡すだけです。

require "rensei"

code = "1 + 2"
ast = RubyVM::AbstractSyntaxTree.parse(code)
pp Rensei.unparse(ast)
# => "(1 + 2)"

ここで注目したいのが元のコードは 1 + 2 なんですが Rensei.unparse の結果は (1 + 2) となっており元々の Ruby のコードとはちょっと違っている点です。
これはなぜかというと Ruby の AST 情報は実際のコードから最低限の情報のみで AST を構築しているので元のコードを完全に復元するのは不可能だからです。
なので rensei-gem で復元した Ruby のコードはあくまでも『元となった AST と復元後の Ruby のコードの ASTが同じであること』のみを保証するような実装になっております。
実際にもっと複雑な Ruby のコードの AST を渡すと全く異なる Ruby のコードになります。

require "rensei"

code = "1 + 2"
code = <<~'RUBY'
# AST のノード情報から Ruby のコードに変換する
def unparse(node)
  case node&.type
  when :SCOPE
    tbl, args, body = node.children
    unparse(body)
  when :LIT
    lit = node.children.first
    lit.to_s
  when :OPCALL
    left, op, right = node.children
    "#{unparse(left)} #{op} #{unparse(right)}"
  when :LIST
    node.children.compact.map { |node| unparse(node) }.join(", ")
  when :IF
    # 子ノードは以下の3つになる
    # * 条件式
    # * then 部
    # * else 部
    cond, body, else_ = node.children
    <<~EOS
    if #{unparse(cond)}
      #{unparse(body)}
    #{"else\n  #{unparse(else_)}" if else_}
    end
    EOS
  when :VCALL
    # 引数がないメソッド呼び出し
    mid = node.children.first
    mid.to_s
  else
    ""
  end
end
RUBY

ast = RubyVM::AbstractSyntaxTree.parse(code)
puts Rensei.unparse(ast)
__END__
output:
def unparse(node)
  case node&.type()
when :SCOPE
  (tbl, args, body, ) = node.children(); unparse(body)
when :LIT
  (lit = node.children().first()); lit.to_s()
when :OPCALL
  (left, op, right, ) = node.children(); "#{unparse(left)} #{op} #{unparse(right)}"
when :LIST
  node.children().compact().map() { |node| unparse(node) }.join(", ")
when :IF
  (cond, body, else_, ) = node.children(); "if #{unparse(cond)}\n  #{unparse(body)}\n#{if else_
  "else\n  #{unparse(else_)}"
end}\nend\n"
when :VCALL
  (mid = node.children().first()); mid.to_s()
else
  ""
end
end

こんな感じでコメントが消えていたり、インデントがおかしかったり、メソッド呼び出しでは必ず () が付いたり、ヒアドキュメントがただの文字列になっていたり、やたらめったら () が付いてたりと元のコードとは似ても似つかないコードになっています。
しかし、この異なるコードでも AST で見ると全く同じ情報になります。

rensei-gem の注意点

この rensei-gem ですが今回の Advent Calendar のために gem を公開したのですが実際のところまだ未完成です。
現時点ではこれぐらい複雑なコードの復元には成功しているんですが、まだまだエッジケースのバグは多いです。
例えば次のような Ruby のコードにはまだ対応していません。

require "rensei"

# 元のコード
code = <<~EOS
a = begin
  foo
  bar
end
EOS
ast = RubyVM::AbstractSyntaxTree.parse(code)

# 意図しない Ruby のコードになっている
puts Rensei.unparse(ast)
# => (a = foo; bar)

また、 RubyVM::AbstractSyntaxTree 自体に不具合があることもあり、例えば次のようなコードは Ruby 2.7.1 時点で『演算子の情報』が欠落していました。

# 本来であれば += の情報が欲しいが AST でそれが欠落してしまっている
code = "struct.field += foo"
ast = RubyVM::AbstractSyntaxTree.parse(code)
pp ast
# => (SCOPE@1:0-1:19
#     tbl: []
#     args: nil
#     body:
#       (OP_ASGN2@1:0-1:19 (VCALL@1:0-1:6 :struct) false :field
#          (VCALL@1:16-1:19 :foo)))

この不具合はわたしが bugs.ruby に報告しパッチを投げて取り込んでもらいました。

このパッチは Ruby 2.7.2 にバックポートされているので Ruby 2.7.2 では『演算子の情報』がある AST になっています。

# Ruby 2.7.2 だと += 情報が取得できる
code = "struct.field += foo"
ast = RubyVM::AbstractSyntaxTree.parse(code)
pp ast
# => (SCOPE@1:0-1:19
#     tbl: []
#     args: nil
#     body:
#       (OP_ASGN2@1:0-1:19 (VCALL@1:0-1:6 :struct) false :field :+
#          (VCALL@1:16-1:19 :foo)))

他にも後置 if の場合、通常の if 文として復元されます。

require "rensei"

code = "foo if cond"
ast = RubyVM::AbstractSyntaxTree.parse(code)

# 後置 if ではなくて if ~ end で復元する
puts Rensei.unparse(ast)
# => if cond
#      foo
#    end

これの影響により a = 42 if a みたいな Ruby コードを復元しようとすると意味が変わってしまいます。

require "rensei"

code = "a = 42 if a"
ast = RubyVM::AbstractSyntaxTree.parse(code)

puts Rensei.unparse(ast)
# => if a
#      (a = 42)
#    end

Ruby の場合は a = 42 if aif a; a = 42; end で変数が定義されるタイミングが異なり、前者は問題がないんですが後者の場合はエラーになってしまいます(実際に動かしてみるとわかります。
これはまだどう対応するべきか考え中です。
このように rensei-gem はまだまだ不安定となっております。
あとテストはガッツリ用意しているんですが実装コードはかなり適当なのであんまり読まないでね!

rensei-gem で遊んで見る

さてさて、まだまだ未完成ではあるんですが rensei-gem を使って AST から Ruby のコードに復元する事ができるようになりました。
では、これを利用して遊んでみたいと思います。
rensei-gem は任意の AST 情報から Ruby のコードに変換します。
つまり『AST の中身を書き換えれば Ruby のコードを変更すること』ができるようになります。
例えば、 obj.foo みたいなメソッド呼び出しは CALL で表現されます。

code = "obj.foo"
ast = RubyVM::AbstractSyntaxTree.parse(code)
pp ast.children.last
# => (CALL@1:0-1:7 (VCALL@1:0-1:3 :obj) :foo nil)

一方で obj&.foo のようにぼっち演算子を使用した場合は QCALL というような命令になります。

code = "obj&.foo"
ast = RubyVM::AbstractSyntaxTree.parse(code)
pp ast.children.last
# => (QCALL@1:0-1:7 (VCALL@1:0-1:3 :obj) :foo nil)

つまりこの CALL という情報を QCALL に置き換えることで obj.foo というコードをぼっち演算子呼び出しの obj&.foo という Ruby のコードに変換する事ができます。

require "rensei"

class Hash
  # ノードの Hash をネストして操作する拡張する
  def each_node(&block)
    return enum_for(:each_node) unless block
    self[:children].each { |node|
      block.call self
      if Hash === node
        node.each_node(&block)
      end
    }
  end
end

# RubyVM::AbstractSyntaxTree::Node から Hash に変換する拡張
# RubyVM::AbstractSyntaxTree::Node#to_h で変換できる
using Rensei::NodeToHash


code = "obj.foo"
ast = RubyVM::AbstractSyntaxTree.parse(code)

# AST から必要な node だけ抽出
node = ast.children.last

# AST の node から Hash に変換
node_h = node.to_h

# AST の type の意味を変更
# hoge.foo から hoge&.foo に変換する
node_h.each_node { |node|
  node[:type] = :QCALL if node[:type] == :CALL
}

# 変換した AST の Hash を Ruby のコードに戻す
code = Rensei.unparse(node_h)

# . から &. に変換されている
pp code
# => "obj&.foo()"

このように比較的簡単な実装で . から &. 呼び出しに変換することができました。
更にこれを『特定のブロックのコード』で反映されるようにしてみましょう。

require "rensei"

class Hash
  # ノードの Hash をネストして操作する拡張する
  def each_node(&block)
    return enum_for(:each_node) unless block
    self[:children].each { |node|
      block.call self
      if Hash === node
        node.each_node(&block)
      end
    }
  end
end


# RubyVM::AbstractSyntaxTree::Node から Hash に変換する拡張
# RubyVM::AbstractSyntaxTree::Node#to_h で変換できる
using Rensei::NodeToHash


def bocchi &block
  # ブロックの中身を AST に変換
  ast = RubyVM::AbstractSyntaxTree.of(block)

  # AST から必要な node だけ抽出
  node = ast.children.last

  # AST の node から Hash に変換
  node_h = node.to_h

  # AST の type の意味を変更
  # hoge.foo から hoge&.foo に変換する
  node_h.each_node { |node|
    node[:type] = :QCALL if node[:type] == :CALL
  }

  # 変換した AST の Hash を Ruby のコードに戻す
  code = Rensei.unparse(node_h)

  # メソッドの呼び出し元のコンテキストで評価する
  block.binding.eval(code)
end

obj = { foo: { hoge: {} } }

# . を &. に変換して実行する
bocchi {
  pp obj[:foo]               # => {:hoge=>{}}
  pp obj[:foo][:bar]         # => nil
  pp obj[:foo][:bar].to_s    # => nil
  pp obj[:foo][:bar][:piyo]  # => nil
}

RubyVM::AbstractSyntaxTree では『ブロック内のコードを AST 情報として取得する事ができる』のでこのように『特定のブロック内のコードのみに対して AST 情報を書き換えて実行する事』ができます。
こういう使い方であれば副作用は最小限に留める事ができるのでかなりいろんな事ができるのでは?と思っています。

所感

と、言うことで RubyVM::AbstractSyntaxTree とその情報から Ruby のコードに復元するようなことを考えてみました。
元々はブロック内のコードを文字列として取得したくて何か便利なライブラリがないか探していたんですが、その時に RubyVM::AbstractSyntaxTree がブロックから AST を生成できることを見つけて勢いで実装してみました。
実装するのはかなり大変だったんですが AST を見ていると Ruby の気持ちがわかってきてとても勉強になりました。
元のコードと復元したコードはかなり異なる形の文字列になるので元々やりたかった事には利用できなさそうなんですが、これはこれで便利なのでもっと色々と利用できないか今後も考えていきたいと思います。
将来的には rensei-gem を利用してマクロ的なものを実装したいと考えているんですがまだまだ先は長そうです…。
それでは今年の Ruby Advent Calendar 2020 の記事でした。
まだ空きはあるのでみんな書いてみてね!