Ruby 3.2のParser目玉機能

今年の後半は久しぶりにRubyに機能を追加したりしており、Ruby 3.2に3つの機能(もしくは変更)をいれたので紹介したい。 Ruby 3.2リリースまであと一ヶ月くらいあるので、現時点でBugなどをみつけたら教えてほしい。

1. RubyVM::AbstractSyntaxTreeにkeep_tokensオプションを追加した

名前の通りtokenの情報を保持するようにし、あとでNodeからtokenを取得できるようにするためのオプション。 RubyVM::AbstractSyntaxTree.parse, .parse_file, .ofの3メソッドで使うことができる。 とりだすときはRubyVM::AbstractSyntaxTree::Node#tokensもしくは#all_tokensでtokenを取得できる。

root = RubyVM::AbstractSyntaxTree.parse("x = 1 + 2", keep_tokens: true)
root.tokens # => [[0, :tIDENTIFIER, "x", [1, 0, 1, 1]], [1, :tSP, " ", [1, 1, 1, 2]], ...]
root.tokens.map{_1[2]}.join # => "x = 1 + 2"

#tokensの戻り値は要素が4つのArrayになっており、それぞれ順番に

  • id: 連番。tokensのArrayに追加した順番と等しい
  • token type: tokenの種類を表すsymbol
  • source code text: tokenに対応する文字列
  • location: tokenの位置情報: first_lineno, first_column, last_lineno, last_columnの順番で並んでいる

keep_tokensといいつつ、commentなどの終端記号でないものも保持しているので便利に活用できると思う。 rubyのtestとしてtestディレクトリ以下のすべての.rbファイルに対してall_tokensで取得できるコードをつなげあわせたものとそのファイルの内容が一致することを確認している。

https://github.com/ruby/ruby/blob/aedf682bfad425149053f58c9115bc830da4efd1/test/ruby/test_ast.rb#L139-L142

root = RubyVM::AbstractSyntaxTree.parse("
# comment 1
class C
  def m # comment for a method
  end
end
", keep_tokens: true)

puts root.tokens.map{_1[2]}.join
# =>
# comment 1
class C
  def m # comment for a method
  end
end

#tokensは自身とchildrenのNodeがもつtoken全てを返すので、自身のtokenだけが欲しい場合はchildren#tokensを除外する必要がある。

root = RubyVM::AbstractSyntaxTree.parse("1 + 2", keep_tokens: true)
opcall = root.children[-1]
opcall.tokens.map{_1[2]}.join # => "1 + 2"
(opcall.tokens - opcall.children.grep(RubyVM::AbstractSyntaxTree::Node).flat_map(&:tokens)).map{_1[2]}.join # => " + "

ヒアドキュメントに関しては注意が必要でヒアドキュメントを復元するにはtHEREDOC_BEGを目印に#all_tokensを探索する必要がある。これは現状ヒアドキュメントに対応するNODE_STRの位置情報が<<~EOSとして実装されており、#tokensがnodeのlocation情報に依存しているため。以下のコードのようにヒアドキュメントは2つの異なるRangeにまたがることがあるので、location情報側で調整する場合にはこれをどう表すかを決める必要がある。ぱっと思いつくアイデアとしては以下のふたつがあるので、他にアイデアやご意見があれば https://bugs.ruby-lang.org/ などによせていただきたい。

  1. Nodeのlocation情報をarrayにして [<<~EOSの範囲、abc ~ EOSの範囲]をもつようにする
  2. #tokensのなかでtHEREDOC_BEGを目印にして、tSTRING_CONTENTtHEREDOC_ENDを探して#tokensの戻り値に含める
root = RubyVM::AbstractSyntaxTree.parse(<<~RUBY, keep_tokens: true)
str = <<~EOS + "ABC"
  abc
  123
EOS
RUBY

root.tokens
# =>
[[0, :tIDENTIFIER, "str", [1, 0, 1, 3]],
 [1, :tSP, " ", [1, 3, 1, 4]],
 [2, :"=", "=", [1, 4, 1, 5]],
 [3, :tSP, " ", [1, 5, 1, 6]],
 [4, :tHEREDOC_BEG, "<<~EOS", [1, 6, 1, 12]],
 [8, :tSP, " ", [1, 12, 1, 13]],
 [9, :+, "+", [1, 13, 1, 14]],
 [10, :tSP, " ", [1, 14, 1, 15]],
 [11, :tSTRING_BEG, "\"", [1, 15, 1, 16]],
 [12, :tSTRING_CONTENT, "ABC", [1, 16, 1, 19]],
 [13, :tSTRING_END, "\"", [1, 19, 1, 20]]]

root.all_tokens
# =>
[[0, :tIDENTIFIER, "str", [1, 0, 1, 3]],
 [1, :tSP, " ", [1, 3, 1, 4]],
 [2, :"=", "=", [1, 4, 1, 5]],
 [3, :tSP, " ", [1, 5, 1, 6]],
 [4, :tHEREDOC_BEG, "<<~EOS", [1, 6, 1, 12]],
 [5, :tSTRING_CONTENT, "  abc\n", [2, 0, 2, 6]],
 [6, :tSTRING_CONTENT, "  123\n", [3, 0, 3, 6]],
 [7, :tHEREDOC_END, "EOS\n", [4, 0, 4, 4]],
 [8, :tSP, " ", [1, 12, 1, 13]],
 [9, :+, "+", [1, 13, 1, 14]],
 [10, :tSP, " ", [1, 14, 1, 15]],
 [11, :tSTRING_BEG, "\"", [1, 15, 1, 16]],
 [12, :tSTRING_CONTENT, "ABC", [1, 16, 1, 19]],
 [13, :tSTRING_END, "\"", [1, 19, 1, 20]],
 [14, :nl, "\n", [1, 20, 1, 21]]]

root.tokens.map{_1[2]}.join # => "str = <<~EOS + \"ABC\""

関連するbugsのチケットやPRは以下のとおり

2. RubyVM::AbstractSyntaxTreeにerror_tolerantオプションを追加した

SyntaxErrorかどうかという情報だけでは困る、もっと多くの情報がほしいということが稀によくある。そのようなときに使えるオプションとしてerror_tolerantを実装した。 このオプションもkeep_tokensと同様にRubyVM::AbstractSyntaxTree.parse, .parse_file, .ofの3メソッドで使うことができる。 通常ではSyntaxErrorが出てしまうようなケースでも、error_tolerant: trueを渡すことでASTを取得することができる。

root = RubyVM::AbstractSyntaxTree.parse(<<~RUBY)
def m
  a = 10
  if
end
RUBY

# =>
<internal:ast>:33:in `parse': syntax error, unexpected `end' (SyntaxError)
  from ../test.rb:1:in `<main>'
root = RubyVM::AbstractSyntaxTree.parse(<<~RUBY, error_tolerant: true)
def m
  a = 10
  if
end
RUBY

pp root
# =>
(SCOPE@1:0-4:3
 tbl: []
 args: nil
 body:
   (DEFN@1:0-4:3
    mid: :m
    body:
      (SCOPE@1:0-4:3
       tbl: [:a]
       args:
         (ARGS@1:5-1:5 pre_num: 0 pre_init: nil opt: nil first_post: nil post_num: 0 post_init: nil rest: nil kw: nil kwrest: nil block: nil)
       body: (BLOCK@2:2-4:3 (LASGN@2:2-2:8 :a (LIT@2:6-2:8 10)) (IF@3:2-4:3 (ERROR@4:0-4:3) nil nil)))))

error_tolerantを有効にすると以下の3つの点で通常のparserと挙動が変わる。

  1. SyntaxErrorが出なくなる
  2. invalidなscriptでもASTが返るようになる
  3. 一部のケースで入力を補ってparseを行う

"入力を補ってparseを行う"というのは大きくわけて2つのパターンがあって

1). scriptを最後まで読み終わったときにendが不足している場合にendを補う

例えば以下のコードのようにendが一つ足りないコードがあったときに、一番最後に自動でendを挿入してASTをつくる。

describe "1" do
  describe "2" do
    describe "3" do
      it "here" do
    end
  end
end

2). endをキーワードとして扱う

最後にendを挿入するだけでは解決できない問題として、以下の例がある。以下では3~4行目は foo.end というようにメソッド呼び出しとして解釈されてしまう。そしてこのままだと後続のbarメソッドがmodule Zではなくclass Fooに対するメソッド定義になってしまう。このような事態を回避するために、error_tolerantが有効な場合はendをキーワードとして切り出すようにlexerの挙動を変えている。なおコードのindentを確認してキーワードとして切り出すか決めるようにしている。

module Z
  class Foo
    foo.
  end

  def bar
  end
end

その他にBisonのerror tokenの位置を調整して、復旧可能なコードを増やした。

そのほかの例はtest_error_tolerant_からはじまるテストケースをみてほしい。

関連するbugsのチケットやPRは以下のとおり

3. 開発に必要なBisonのrequired versionを3.0にあげた

Bisonの便利機能を使いたかったのでversionをあげていいですかとというチケットをきり、required versionを3.0にあげた。 macOSだと標準ではいっているBisonが2.3だったりするので、もしRubyをコードからビルドしていてこのようなエラーにあたったらbrewなどでBisonのversionをあげてほしい。

parse.tmp.y:12.10-14: require bison 3.0, but have 2.3

これにより --dump=y したときにNodeのタイプが表示されるようになったので、parse.yをいじる人にとってよりわかりやすくなったのではないかと思う。

# Before
Reducing stack by rule 639 (line 5062):
   $1 = token "integer literal" (1.0-1.1: 1)
-> $$ = nterm simple_numeric (1.0-1.1: )

# After
Reducing stack by rule 641 (line 5078):
   $1 = token "integer literal" (1.0-1.1: 1)
-> $$ = nterm simple_numeric (1.0-1.1: NODE_LIT)

関連するbugsのチケットやPRは以下のとおり

Ruby 3.3に向けて

Error tolerantの機能をもっと充実させていきたい。現状では限定的かつ大局的な修復しかできていないので、例えば obj.m(arg1,という入力にたいして)を補うなどのようにもっと細かい単位での修復ができるようにしていきたい。