今年の後半は久しぶりに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
で取得できるコードをつなげあわせたものとそのファイルの内容が一致することを確認している。
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/ などによせていただきたい。
- Nodeのlocation情報をarrayにして
[<<~EOSの範囲、abc ~ EOSの範囲]
をもつようにする #tokens
のなかでtHEREDOC_BEG
を目印にして、tSTRING_CONTENT
とtHEREDOC_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と挙動が変わる。
SyntaxError
が出なくなる- invalidなscriptでもASTが返るようになる
- 一部のケースで入力を補って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の位置を調整して、復旧可能なコードを増やした。
- https://github.com/ruby/ruby/commit/4bfdf6d06ddbcf21345461038f2a9e3012f77268
- https://github.com/ruby/ruby/commit/3531086095aed9d2898de686bc67ab3a6c2192de
そのほかの例は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は以下のとおり
- https://bugs.ruby-lang.org/issues/19068
- https://github.com/ruby/ruby/pull/6579
https://github.com/ruby/ruby/commit/230267d1a8f2b8245e911513926c06299ddeebc8 Bisonのminimum versionを宣言するコミット
Ruby 3.3に向けて
Error tolerantの機能をもっと充実させていきたい。現状では限定的かつ大局的な修復しかできていないので、例えば obj.m(arg1,
という入力にたいして)
を補うなどのようにもっと細かい単位での修復ができるようにしていきたい。