Ruby 2.7.7 のリリースにほんのちょっとだけ関わった話

11/24にRuby 2.7.7, 3.0.5, 3.1.3がリリースされました

今まで一度もリリース作業に関わったことがなかったのですが、今回はRuby 2.7.7でripperのtestがこける! という話を聞いて、嫌な予感がしたのでデバッグに参加しました。 やったことを順番に書いていきます。

手元で再現するか確認

なにはともあれ手元で再現させないと調査も難しいので、その時点の ruby_2_7 branch をチェックアウトしてテストを実行しました。

$ git co ee8dc8a2f3ee7983d18339ea31444a981e63a874
$ make main
$ make test-all TESTS="../test/ripper/*"

Run options: "--ruby=./miniruby -I../lib -I. -I.ext/common  ../tool/runruby.rb --extout=.ext  -- --disable-gems" --excludes-dir=../test/excludes --name=!/memory_leak/

# Running tests:

[313/434] TestRipper::Ripper#test_yydebug_ident = 0.00 s
  1) Failure:
TestRipper::Ripper#test_yydebug_ident [ruby/ruby/test/ripper/test_ripper.rb:75]:
Expected "Next token is token \"local variable or method\" (1.0-1.9: )" to include "test_xxxx".

[327/434] TestRipper::Ripper::TestInput#test_yydebug_ident = 0.00 s
  2) Failure:
TestRipper::Ripper::TestInput#test_yydebug_ident [ruby/ruby/test/ripper/test_ripper.rb:75]:
Expected "Next token is token \"local variable or method\" (1.0-1.9: )" to include "test_xxxx".

Finished tests in 7.129586s, 60.8731 tests/s, 472.3977 assertions/s.
434 tests, 3368 assertions, 2 failures, 0 errors, 0 skips

ruby -v: ruby 2.7.7p220 (2022-11-24 revision ee8dc8a2f3) [arm64-darwin21]
make: *** [yes-test-all] Error 2

見事に再現しますね。

テストケースを読む

該当するテストケースは以下のようになっていて、yydebug をオンにしたときに特定の文字列が表示されるかをチェックしています。

  def test_yydebug_ident
    out = StringIO.new
    ripper = Ripper.new 'test_xxxx'
    ripper.yydebug = true
    ripper.debug_output = out
    ripper.parse
    assert_include out.string[/.*"local variable or method".*/], 'test_xxxx'
  end

どんな出力かパッとみてわからないので、--dump=yをして3.1系のRubyの出力と比較をしてみます。ripperとparserはコードを結構な部分で共有しているので、とりあえずこのオプションをつけて実行してみて、それでもわからなければちゃんとripperを使って検証しようという判断です。

$ ruby -v --dump=y -e 'test_xxxx'
ruby 3.1.3p185 (2022-11-24 revision 1a6b16756e) [arm64-darwin21]
...
Next token is token "local variable or method" (1.0-1.9: test_xxxx)
Shifting token "local variable or method" (1.0-1.9: test_xxxx)
...

$ ./miniruby -v --dump=y -e 'test_xxxx'
ruby 2.7.7p220 (2022-11-24 revision ee8dc8a2f3) [arm64-darwin21]
...
Next token is token "local variable or method" (1.0-1.9: )
Shifting token "local variable or method" (1.0-1.9: )
...

なるほど。確かにtest_xxxxがないですね。

コードを調査する

ちょうど先日この出力周りをmaster branchでいじった記憶があったので知っているのですが、この出力はBisonの %printer Directive で制御しています。 そこでparse.y%printerを探すのですが、そのようなコードが見つかりません。なぜ? よくわからないのでmaster branchで%printerがいつ導入されたか確認します。

$ git co master
$ git log -S printer -p parse.y

すると https://github.com/ruby/ruby/commit/fa05697e4832fbd67a4f91b9bb362471902faab3 というコミットがみつかります。つまりYYPRINTを消して%printerに置き換えています。 もう一度 ruby_2_7 branchにもどってparse.yを確認すると、たしかにYYPRINTが残っています。念の為 ripper.c (.yでない)を眺めるとYYPRINT#defineされてますが、まったく呼び出されていないことがわかります。ripper.cの先頭行をチェックしてBisonのversionをみると /* A Bison parser, made by GNU Bison 3.8.2. */ となっており、BisonのversionがあがってYYPRINTが呼ばれなくなったのかもしれないと見当がつきました。一応他に再現できているコミッターのBisonを確認してもらったところ、みんな3.8.2を使って再現しており。限りなくこれが怪しいという雰囲気になりました。

最終的には fa05697e4832fbd67a4f91b9bb362471902faab3 がバックポートされ、無事テストが通るようになりました。

まとめ

一応Bison側のchange logを追いかけてみて3.8でYYPRINTのサポートが終了したことを確認しました。

みなさんお疲れ様でした!

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,という入力にたいして)を補うなどのようにもっと細かい単位での修復ができるようにしていきたい。