Ruby Parser開発日誌 (18) - Rearchitect Ripper

Rubyのparserを語る上で忘れてはいけない存在としてRipperというライブラリがあります。

RipperはRubyのコードを構文解析するためのライブラリとしてirbやrdocなどで長く使われてきました。 一方でBug #10436のように長い間未解決のバグがあったり、Bug #18988のようにパーサーと挙動が異なる箇所があったりと完璧でない部分があります。

今回Ripperのアーキテクチャを見直すことでRipperの抱えている問題を解決することができたので、この記事で紹介したいと思います。

関連するPRとticketはそれぞれ以下のとおりです。

github.com bugs.ruby-lang.org

Ripperとは

RipperはRubyのパーサーライブラリです。入力されたコードに対応するS式を返すRipper.sexpを使ったことがある人もいるのではないでしょうか。 Ripperの最もプリミティブなAPIon_XXXというメソッド群で、on_ifon_opといったparserやscanner(lexer)のイベントに対応したコールバックを設定することができます。 Ripper.sexpもこれらon_XXXメソッドをオーバーライドすることで実装されています。

簡単な例をもとにRipperのAPIの挙動を確認しておきましょう。 各種on_XXXメソッドが呼ばれるたびに以下の3つを行うParserclassを定義します。

  1. 内部のcounter(@count)を1つ増やす
  2. イベント名(n)、引数(args)、その時点でのcounterの値(c)を表示する
  3. メソッドからはcounterの値(c)を返す
require "ripper"

class Parser < Ripper
  def count
    @count ||= 0
    @count += 1
    "count #{@count}"
  end

  PARSER_EVENTS.each  {|n| eval "def on_#{n}(*args) c = count; p [[:parser,  :#{n}, *args], '=>', c]; c end" }
  SCANNER_EVENTS.each {|n| eval "def on_#{n}(*args) c = count; p [[:scanner, :#{n}, *args], '=>', c]; c end" }
end

p Parser.new("1 + 2").parse
# =>
# [[:scanner, :int, "1"], "=>", "count 1"]
# [[:scanner, :sp, " "], "=>", "count 2"]
# [[:scanner, :op, "+"], "=>", "count 3"]
# [[:scanner, :sp, " "], "=>", "count 4"]
# [[:scanner, :int, "2"], "=>", "count 5"]
# [[:parser, :binary, "count 1", :+, "count 5"], "=>", "count 6"]
# [[:parser, :stmts_new], "=>", "count 7"]
# [[:parser, :stmts_add, "count 7", "count 6"], "=>", "count 8"]
# [[:parser, :program, "count 8"], "=>", "count 9"]
# "count 9"

1, , +, , 2とそれぞれのtokenに対応するイベントが発生します1。そして1 + 2に対応する:binaryというイベントが発生します。先ほどのtokenのイベントのときの戻り値がここでの引数になるので、"count 1""count 5"が渡ってきます。:binaryイベントの戻り値である"count 6":stmts_addイベントに渡ります。そして:stmts_addイベントの戻り値である"count 8":programイベントに渡ります。このように以前のイベントで呼び出したメソッドの戻り値が、その後のイベントのメソッド呼び出しの引数になります。

現在の実装の問題点

Ripperとparserは構文解析のロジックを共有しているものの、それぞれが独自のコードを持っているので全体像はなかなかに複雑です。 どのように複雑であるかを2つの視点から見ていきましょう。

1. コールバックの戻り値をparserが使用しているスタックで管理している

通常のparserは構文解析をしながらその結果に対応したAST(抽象構文木)を構築していきます。 下の図のように12それぞれにNODE_INTEGERというNodeを紐づけておき、1 + 2全体をargだと認識するときにNODE_OPCALLというNodeをつくり、その子要素としてNODE_INTEGERを紐付けます2。これを繰り返すことで入力されたコードを1つの大きな木構造に変換していきます。

一方でRipperの場合は12それぞれにコールバックメソッドの戻り値を紐づけています。このようにしておくことで、前のコールバックメソッドの戻り値を次のコールバックメソッドの引数として渡すことができます。

ここで一つ問題があります。Nodeやコールバックメソッドの戻り値はparser generatorが提供するスタックで管理しています。しかしparser generatorは一個しかスタックを提供していないので、RipperではNodeを構築・管理することができません。

一方でRuby構文解析中に行われる意味解析の一部は構築中のNodeをチェックして行われます。 Bug #10436をみてみましょう。このチケットはm(&nil) {}というコードが通常のparserではエラーになるのに、Ripperではエラーにならないというバグに関するものです。

both block arg and actual block givenというエラーはblock_dup_checkという関数でチェックしています。この関数はメソッドの引数とその後ろに続くブロックの2つのNodeをチェックして両方でブロックを渡していないかを確認します。

つまりこのチェックにはNodeが必要であり、Nodeを構築・管理できないRipperではチェックすることができません。

2. parserとRipperで関数が分離している

parserとRipperでスタックで扱っている値が違うということは、その値を扱うためのロジックもそれぞれ存在します。 例えばpattern matchingで配列を扱うnew_array_pattern関数の場合、parser用の関数Ripper用の関数が存在します。

このように同じ名前で異なる振る舞いをする関数がparse.yには定義されており、複雑さを増しています。 複雑さが増すというのはバグが入り込みやすくなるということでもあります。例えば次のコードの2つ目のbarをパースするときに、parserはlex stateをEXPR_END|EXPR_LABELに更新しますが、現在のRipperはEXPR_ARGに更新します。

def foo(bar: bar())
end

1つ目のbarをパースしたときにbarローカル変数のテーブルに入れておくことで、2つ目のbarのパース時に特定の分岐に入るようになりlex stateが変化します。 しかし1つ目のbarをローカル変数のテーブルに入れるところで使っているformal_argumentにバグがあり、Ripperの場合はbarがローカル変数のテーブルに登録されなくなっています3

新しいアーキテクチャ

これらの問題を解決するためにはどうしたらよいでしょうか。 スタックが一つしかないのが問題なので、スタックをもう一つ足すことでこの問題は解決できます。

Bisonにはそのような機能はありませんでしたが、Lramaであれば新しく機能を足せば実現できます。 今回はLramaに5つのcallbackと1つの新しい記法を追加することで、Ripper用に新しくスタックを追加できるようにします。

Lramaの5つのcallback

Lramaから生成したparserに対して以下の5つのcallbackを設定できるようにします。

  • %after-shift: shiftをしたあとに呼び出す関数を指定できる
  • %before-reduce: reduceをするまえに呼び出す関数を指定できる
  • %after-reduce: reduceをしたあとに呼び出す関数を指定できる
  • %after-shift-error-token: エラー発生時にerrorというトークンが追加されるので、そのタイミングで呼び出す関数を指定できる
  • %after-pop-stack: エラー発生時にスタックを複数回popするので、そのタイミングで呼び出す関数を指定できる

それぞれのcallbackについて使い方をみていきます。

%after-shiftの使い方

本体のparserがshiftをしたときには、Ripper用のスタックにもオブジェクトをpushします。 具体的なコードはこちら

いま1 +までparserが終わっていて次のトークンが2だとします。2をlexerがスキャンしたときにRipperのon_intメソッドが呼び出されます。ここでは"count 5"が返ってきたとしましょう。%after-shiftでは"count 5"をRipper用のスタックにpushします。

%before-reduceの使い方

本体のparserがreduceする直前に$:$ = $:1のように初期化をしておきます。 こうしておくと、expr : command_callなどのように右辺が1つのときにアクションを省略することができます。 具体的なコードはこちら

%after-reduceの使い方

本体がreduceをしたらRipperもreduceをします。ルールの右辺の要素数に対応する数だけスタックからオブジェクトをpopし、左辺に対応するオブジェクトをpushします。 具体的なコードはこちら

1 + 2のケースでは"count 1""count 5"を引数にしてon_binaryを呼び出します。ここでは"count 6"が返ってきたとしましょう。%after-reduceでは右辺に対応する3つの要素をスタックからpopし、"count 6"をpushします。

%after-shift-error-token%after-pop-stackの使い方

この2つは構文エラーのときのparserの挙動を真似するために使います。現在のparse.yでは昔からあるpanic modeによるエラーリカバリーを行っているところがあります。panic modeによるエラーリカバリーでは構文エラーに遭遇したときに構文エラーの周辺(前後)をなかったことにして、無視した部分をerrorという特殊なトークンで置き換えるという挙動をします。スタックの操作としてはいまスタックにのっている要素のいくつかをpopし、代わりにerrorトークンをpushするという挙動になります。これらを真似するために%after-pop-stack%after-shift-error-tokenでそれぞれ適切な処理を行います。 具体的なコードはこちら

たとえばif 1 + ; then :t endが入力されたとします。;まで処理が進んだときに構文エラーがみつかります。このケースでは1 +errorに置き換えif error then :t endとすることでリカバリーを行い、処理を継続できるようにします。

$:1 という記法をLramaに追加する

普段Nodeなどにアクセスするときは$1$2というようにLramaの提供する特殊な変数を使ってアクセスしています。

| arg '+' arg
    {
        $$ = call_bin_op(p, $1, '+', $3, &@2, &@$);
    }

Ripper用に新しく追加したスタックにアクセスするために、新しい記法を導入する必要があります。 今回は$:1という記法を導入することにしました。これは最終的にはスタックのトップからみたときのマイナスのインデックスに展開されます。 そうすることでstack[-3]stack[-1]というようにRubyArray#[]と同じ感覚でスタックにアクセスできるようになります。

parserのデータ構造

最後にparserのデータ構造であるstruct parser_paramsについてもみておきましょう。今回新しくRipper用に3つのフィールドを追加しました。

    VALUE s_value; /* Token VALUE */
    VALUE s_lvalue; /* VALUE generated by rule action (reduce) */
    VALUE s_value_stack;

VALUE s_valueはlexerとparserの間でRubyのObjectを受け渡すのに使います。たとえば"2"を引数としてon_intを呼び出し、そのときの戻り値が"count 5"だったとします。on_intはlexerのイベントなので、"count 5"をparserに渡す必要があります。このとき使われるのがVALUE s_valueです。

VALUE s_lvalueはparserでreduceが発生したときに左辺に対応する値をいれておくためのフィールドです。通常のparserの$$に相当します。

VALUE s_value_stackはRipperのスタックのことで、具体的にはRubyのArrayです。RipperはRubyのObjectを扱うので、スタックにある間は適切にmarkをするなどGCのことを考慮する必要があります。RubyのArrayに格納しておけばVALUE s_value_stackだけをmarkすればよくなるので実装が簡単になります。

解決した問題

今回のアーキテクチャの見直しでparserとRipperが分離していることに起因するバグチケットを全て解決することができました。 具体的には以下の3つのチケットをcloseしました。

また今までレポートはされていませんでしたが他にもさまざまなバグが修正されています。 例えばネストしたnumbered parameterに関するエラーや、リテラルをifの条件に書いた時の警告がRipperで取れるようになるなど、意味解析に関するparserとRipperの差異がなくなりました。 parserとRipperが共通の意味解析を行うので、itのように新しく追加された構文についても基本的にはparser側の実装を更新するだけでRipper側でもサポートされます。

10.times do
  _1

  10.times do
    _1
  end
end

# => [:compile_error, ["numbered parameter is already used in\n(ripper):2: outer block here"]]

10.times do |i|
  it
end
# => [:compile_error, ["ordinary parameter is defined"]]
if 1
else
end

# => [:warning, ["literal in condition"]]

ほかにはパターンマッチングで変数が定義されたときに正しくvar_refイベントが発火するようになりました。

case i
in **a
  a == {} # aに関するイベントが修正されてvcallからvar_refになった
end

まとめ

長い間未解決であったBug #10436などを取り上げ、いままでのRipperの実装とその限界点について分析をしました。RipperではNodeを管理するスタックとRubyのObjectを管理するスタックの2つが必要なのに、過去に使っていたBisonの制約からそれらを1つのスタックで管理するアーキテクチャになっていました。現在はBisonのかわりにLramaを使っているので、Lramaにちょっとした機能を追加することでRipper用にスタックを追加できるようにしました。その結果、既知のバグを全て直すことができました。また今後parserに意味解析が追加された場合にも、Ripperは自動的に追従できるようになりました。

スタックが足らないならスタックを追加すればいいのです。そう、Lramaならね。


  1. この例ではscannerのイベントが全て発生したのちにparserのイベントが発生していますが、基本的にはscannerとparserのイベントが入り乱れて発生します。
  2. 正確には右の子要素はNODE_LISTでその下にNODE_INTEGERが紐づくのですが、ここでは説明を簡単にするために一部を省略しています。
  3. 9168行目lhsから取り出しているidを返すのが正しいです。ここではRipperはRubyのオブジェクトとidの両方を管理しないといけないので、NODE_RIPPERという特殊なNODEをつくって、そこにRubyのオブジェクトとidの両方を保存しています。なのでformal_argumentの引数の型がparserとRipperで異なります。

Ruby Parser開発日誌 (17) - 演算子の優先度はいつ使われるのか

AWKの式でa + b構文解析a (+b)ではなくて、(a) + (b)と解釈されるのはなぜでしょうかという質問をいただきました。 私はAWKに詳しいわけではないですが、LR parser なかでもBison用に書かれた文法定義ファイルであれば解説できると思うので、少し踏み込んで解説をしてみたいと思います。

質問者の方は https://pubs.opengroup.org/onlinepubs/9699919799/utilities/awk.html の"Expressions in awk"を読んで、+ expr (Unary plus)の方がexpr + expr (Addition)よりも優先度が高いのであるからa + ba (+b)と解釈されるのではないかと考えていました。とても良い質問だと思います。

結論を先にいうと、ここで問題になるのは加算(Addition)と文字列結合(String concatenation)の優先度であり、加算(Addition)のほうが優先度が高いのでa + b(a) + (b)と解釈されます。

awk構文解析の様子

下準備

まずはgawkソースコードレポジトリからcloneしてきます。 今回はtag gawk-5.3.0 (5efd1487e16e68a1bb52fa5ca997d2d51182bff2)を使って解説をしていきます。

Bisonを使っている場合yydebugという変数に0以外を設定することで構文解析の詳細なlogが出力されます。 "main.c"を眺めるとYYDEBUGというマクロが有効であればYオプションでyydebugを有効化できることがわかります。 また"configure.ac"をDYYDEBUGで検索すると$srcdir/.developingというファイルがあるときにdebugモードでビルドしてくれそうです。

$ touch .developing
$ ./configure
$ make
$ ./gawk -Y '{ a + b }'
Starting parse
Entering state 0
Stack now 0
Reducing stack by rule 1 (line 235):
-> $$ = nterm program ()
Entering state 1
...

無事にパーサーのlogがでるようになったので、このlogを調べていきましょう。

a + b の解析

ShiftについてはShifting token NAME ()のようにShifting token ...と表示されている行をみればわかります。 ReduceについてはReducing stack by rule 194 (line 2156):のようにReducing stack by rule ...と表示されている行とその後に続くメッセージをみればわかります。 たとえば以下のような出力であれば、simp_exp: simp_exp '+' simp_expのルールによってReduceしていることがわかります。

Reducing stack by rule 153 (line 1807):
   $1 = nterm simp_exp ()
   $2 = token '+' ()
   $3 = nterm simp_exp ()
-> $$ = nterm simp_exp ()
Entering state 36
Stack now 0 1 29 81 155 36

'{ a + b }'の中でもとくにa + bの部分について、構文解析が進んでいく様子をまとめたものが以下の図です。

awkgram.yという文法ファイルをみてみましょう。 String concatenationはcommon_expで、Additionはsimp_expでそれぞれ定義されています。Unary plus(単項演算のプラス)はnon_post_simp_expで定義されています。

また式に関する文法は

  • simple_stmt: exp
    • exp: common_exp

という構造になっているので、ここではcommon_exp以下の構造を調べていきます。

common_exp: simp_exp
          | common_exp simp_exp %prec CONCAT_OP /* String concatenation */

simp_exp: simp_exp '+' simp_exp /* Addition */
        | non_post_simp_exp

non_post_simp_exp: variable
                 | '+' simp_exp /* Unary plus */

今回の質問は、下図1のような動きになるのではないかという質問です。 つまりacommon_expとし、+bnon_post_simp_exp -> simp_expとして、全体をString concatenationのcommon_expとして解釈しないのだろうかということになります。

パーサーの気持ちになって考える

以前 Ruby Parser開発日誌 (14) - LR parser完全に理解した - かねこにっき で解説したとおり、LR parserというのはBNFのルールに1:1対応するオートマトンを用意し、それを1つのオートマトンに合成してつくります。

ここではa + bをパースしているときのパーサーの状態について詳しくみていきましょう。

いま問題になっているのはaをパースして、次のtokenが+という状況です。 asimp_expであることと次のトークンが+であることを考慮すると、この時点では2つの可能性があります。ひとつはcommon_exp: simp_expの解析中であり、かつsimp_exp: simp_exp '+' simp_expの解析中であるというケースです。

もうひとつはcommon_exp: common_exp simp_expの解析中であり、かつcommon_exp: simp_expの解析がちょうどいま終わろうとしているケースです。

ケース1では+をShiftして処理を進めることになりますし、ケース2では+をShiftせずにcommon_exp: simp_expのルールでReduceをすることになります。これはShift/Reduce Conflictと呼ばれるもので、1つの状態のときにShiftとReduceの両方が成立しうる状態です。Conflictというのはパーサーからするとどうしていいのか決められないので、パーサーの開発者が決めてあげる必要があります。このケースではAddition (ケース1)とString concatenation (ケース2)のどちらを優先するかという話であり、仕様ではAdditionを優先することになっています。なのでケース1が選択されます。

ケース1を選択したので、+をShiftして処理を続けてみましょう。 つぎのトークンがNAMEであり、NAMEは何回かのReduceによってvariableになることを加味すると、non_post_simp_exp: variableをパースする状態にいると考えることができます。non_post_simp_exp: '+' simp_expという単項演算のルールもありますが、+はすでに上位のオートマトンで考慮されているため、ここではこのルールは該当しません。

gawkのパーサーの状態を調べてみる

ここまでの説明と実際のgawkのパーサーの状態が一致しているのか確認してみましょう。 ここではBisonの出力するレポートファイルを使うので、まずはレポートファイルを生成します。

$ make clean
$ make YFLAGS="--report=states,itemsets,lookaheads,solved"

を実行するとawkgram.outputというファイルが生成されます。 これにはパーサーの状態に関する様々な情報が記載されています。 今回問題になっているのは+をShiftするかどうかという点なので、./gawk -Y '{ a + b }'のlogのうち+をShiftしている前後をみます。

Stack now 0 1 29 81 155 36
Next token is token '+' ()
Shifting token '+' ()
Entering state 100
Stack now 0 1 29 81 155 36 100
Reading a token

State 36のときに+をShiftしてState 100に遷移していることがわかります。 レポートファイルを見てみましょう。

State 36

  145 common_exp: simp_exp •  [error, FUNC_CALL, NAME, YNUMBER, YSTRING, RELOP, IO_OUT, IO_IN, MATCHOP, LEX_GETLINE, LEX_IN, LEX_AND, LEX_OR, INCREMENT, DECREMENT, LEX_BUILTIN, LEX_LENGTH, NEWLINE, SLASH_BEFORE_EQUAL, '?', ':', ',', '<', '>', '+', '-', '/', '!', '$', '(', ')', '@', ']', '{', ';']
  149 simp_exp: simp_exp • '^' simp_exp
  150         | simp_exp • '*' simp_exp
  151         | simp_exp • '/' simp_exp
  152         | simp_exp • '%' simp_exp
  153         | simp_exp • '+' simp_exp
  154         | simp_exp • '-' simp_exp

    '+'  shift, and go to state 100
    '-'  shift, and go to state 101
    '*'  shift, and go to state 102
    '/'  shift, and go to state 103
    '%'  shift, and go to state 104
    '^'  shift, and go to state 105

    '+'       [reduce using rule 145 (common_exp)]
    '-'       [reduce using rule 145 (common_exp)]
    '/'       [reduce using rule 145 (common_exp)]
    $default  reduce using rule 145 (common_exp)

145 common_exp: simp_exp •というのがさきほどのケース2のことです。 下のほうに'+' [reduce using rule 145 (common_exp)]と書いてあるとおり、Reduceをすることになります。

153 | simp_exp • '+' simp_expというのがケース1に該当します。 '+' shift, and go to state 100と書いてあるように、こちらは+をShiftします。

[reduce using rule 145 (common_exp)][]で囲ってあるのはReduceではなくShiftを選択したという意味です。

BisonはS/R Conflictがあり、演算子の優先度を考慮しても決定できないときにShiftを選択するという挙動をします。 なのでState 36では仕様の通り優先度の高いAdditionが選択されています。

Bison is designed to resolve these conflicts by choosing to shift, unless otherwise directed by operator precedence declarations.

Shift/Reduce (Bison 3.8.1)

余談ですが、BisonのS/R Conflict解消の挙動に依存したくない場合、以下のようにcommon_exp: simp_expのルールに優先度を明示することで解決できます。

diff --git a/awkgram.y b/awkgram.y
index 5f0697b0..cbce0f6a 100644
--- a/awkgram.y
+++ b/awkgram.y
@@ -1733,7 +1733,7 @@ a_relop
        ;

 common_exp
-       : simp_exp
+       : simp_exp %prec CONCAT_OP
          { $$ = $1; }
        | simp_exp_nc
          { $$ = $1; }
State 36

  145 common_exp: simp_exp •  [error, FUNC_CALL, NAME, YNUMBER, YSTRING, RELOP, IO_OUT, IO_IN, MATCHOP, LEX_GETLINE, LEX_IN, LEX_AND, LEX_OR, INCREMENT, DECREMENT, LEX_BUILTIN, LEX_LENGTH, NEWLINE, SLASH_BEFORE_EQUAL, '?', ':', ',', '<', '>', '!', '$', '(', ')', '@', ']', '{', ';']
  149 simp_exp: simp_exp • '^' simp_exp
  150         | simp_exp • '*' simp_exp
  151         | simp_exp • '/' simp_exp
  152         | simp_exp • '%' simp_exp
  153         | simp_exp • '+' simp_exp
  154         | simp_exp • '-' simp_exp

    '+'  shift, and go to state 100
    '-'  shift, and go to state 101
    '*'  shift, and go to state 102
    '/'  shift, and go to state 103
    '%'  shift, and go to state 104
    '^'  shift, and go to state 105

    $default  reduce using rule 145 (common_exp)

    Conflict between rule 145 and token '+' resolved as shift (CONCAT_OP < '+').
    Conflict between rule 145 and token '-' resolved as shift (CONCAT_OP < '-').
    Conflict between rule 145 and token '/' resolved as shift (CONCAT_OP < '/').

UNARY 優先度の役割

awkgram.yの優先度定義をみると単項演算子のためにUNARYという優先度が定義されています。 これがどのような役割を担っているか確認しておきましょう。

diff --git a/awkgram.y b/awkgram.y
index 5f0697b0..3d271a7d 100644
--- a/awkgram.y
+++ b/awkgram.y
@@ -1983,7 +1983,7 @@ non_post_simp_exp
                        $$ = list_append($2, $1);
                }
          }
-       | '+' simp_exp    %prec UNARY
+       | '+' simp_exp
          {
                if ($2->lasti->opcode == Op_push_i
                        && ($2->lasti->memory->flags & STRING) == 0

という変更をいれて、その前後でレポートファイルを比較することで、単項演算子+に対するUNARY優先度の役割がわかります。

State 62が影響を受けます。この状態はnon_post_simp_exp: '+' simp_exp •とあるようにまさしく単項演算子を含む式に関するルールです。単項演算子の対象の式もまたsimp_expなので、このsimp_expa * bであったりします。つまり+ a * b+ (a * b)とするのか(+ a) * bとするのかを決める必要があります。仕様によるとUnary plusはMultiplication, Division, Modulusよりも優先度が高いので、(+ a) * bと解釈するのが正しく、ここではReduceをする必要があります。'+' simp_exp %prec UNARY%prec UNARYは、このルールの優先度を*, /, %よりも高くするために必要であることがわかります。もし%prec UNARYがないと、+*の優先度の比較が行われ*のほうが優先度が高いので、+ (a * b)と解釈されてしまいます。

diff --git a/awkgram.output.before b/awkgram.output.after
index fab379f2..6f001b66 100644
--- a/awkgram.output.before
+++ b/awkgram.output.after
@@ -2151,18 +2151,21 @@ State 62
   152         | simp_exp • '%' simp_exp
   153         | simp_exp • '+' simp_exp
   154         | simp_exp • '-' simp_exp
-  179 non_post_simp_exp: '+' simp_exp •  [error, FUNC_CALL, NAME, YNUMBER, YSTRING, RELOP, IO_OUT, IO_IN, ASSIGNOP, ASSIGN, MATCHOP, LEX_GETLINE, LEX_IN, LEX_AND, LEX_OR, INCREMENT, DECREMENT, LEX_BUILTIN, LEX_LENGTH, NEWLINE, SLASH_BEFORE_EQUAL, '?', ':', ',', '<', '>', '+', '-', '*', '/', '%', '!', '$', '(', ')', '@', ']', '{', ';']
+  179 non_post_simp_exp: '+' simp_exp •  [error, FUNC_CALL, NAME, YNUMBER, YSTRING, RELOP, IO_OUT, IO_IN, ASSIGNOP, ASSIGN, MATCHOP, LEX_GETLINE, LEX_IN, LEX_AND, LEX_OR, INCREMENT, DECREMENT, LEX_BUILTIN, LEX_LENGTH, NEWLINE, SLASH_BEFORE_EQUAL, '?', ':', ',', '<', '>', '+', '-', '!', '$', '(', ')', '@', ']', '{', ';']

+    '*'  shift, and go to state 102
+    '/'  shift, and go to state 103
+    '%'  shift, and go to state 104
     '^'  shift, and go to state 105

     $default  reduce using rule 179 (non_post_simp_exp)

-    Conflict between rule 179 and token '+' resolved as reduce ('+' < UNARY).
-    Conflict between rule 179 and token '-' resolved as reduce ('-' < UNARY).
-    Conflict between rule 179 and token '*' resolved as reduce ('*' < UNARY).
-    Conflict between rule 179 and token '/' resolved as reduce ('/' < UNARY).
-    Conflict between rule 179 and token '%' resolved as reduce ('%' < UNARY).
-    Conflict between rule 179 and token '^' resolved as shift (UNARY < '^').
+    Conflict between rule 179 and token '+' resolved as reduce (%left '+').
+    Conflict between rule 179 and token '-' resolved as reduce (%left '-').
+    Conflict between rule 179 and token '*' resolved as shift ('+' < '*').
+    Conflict between rule 179 and token '/' resolved as shift ('+' < '/').
+    Conflict between rule 179 and token '%' resolved as shift ('+' < '%').
+    Conflict between rule 179 and token '^' resolved as shift ('+' < '^').

このように演算子の優先度というのはLR parserの状態を計算し、コンフリクトがあったときにそれを解消するために使われます。

まとめ

AWKにおいてa + b構文解析a (+b)と解釈するのか(a) + (b)と解釈するのかという質問を受けたので調べてみました。 今回のケースでは単項演算(Unary plus)と加算(Addition)の優先度ではなく、加算(Addition)と文字列結合(String concatenation)の優先度を考える必要がありました。gawkでは仕様にあるとおり加算(Addition)を優先する実装になっていました。

演算子の優先度はtableなどに切り出されていることが多く、一見すると広い範囲で使われているように思えますが、実際はLR parserの各状態でコンフリクトが発生したときにコンフリクトを解消するために使われます。このことをawkgram.yを変更したり、レポートファイルをみることで確認しました。

BuriKaigi 2024に参加してparser generatorの話をしてきた

1月20日に開催されたBuriKaigi 2024に参加するために19日から21日の三日間、富山に行ってきました。

burikaigi.dev

1/19 (0日目)

お昼過ぎに東京駅から北陸新幹線にのって富山駅へ移動しました。 人生3回目の北陸でしたが、はじめての北陸新幹線でした。本当に新幹線は楽で便利。 途中長野を過ぎたあたりから雪が降っていたり、雪が積もっていたりしてこれから雪国にいくんだという気持ちになりました1

夕方には富山駅について夕飯を他のRubyistと一緒に食べ、夜は翌日の発表の準備をして就寝。 街では雪吊や雪囲いを見ることができ、冬の北陸に来たんだなという気持ちになったのでした。

1/20 (カンファレンス当日)

@富山県立大学

会場は富山県立大学 DX教育研究センターというところで最寄駅からもちょっと遠かったので、他のRubyistと一緒にタクシーで向かいました。 会場となったDX教育研究センターは新しい建物で綺麗でよかったですね。 大学内には除雪車があったり、ポールが至るところに立っていたり、建物が渡り廊下で繋がっていたりと雪国の大学だなーとちょっと感動しました。

自分が発表したのはRoom-Hotaruikaという部屋で、他のRubyistの発表も同じ部屋に集中していたので前半はこの部屋にずっといました。

  • "何も知らない課金システムを移行した話" (相生ゆらさん)
  • "ブーリアン:なぜRubyにはBooleanクラスがないのか、クラスを巡る冒険" (大倉雅史さん)
  • "エンジニアとして「事業」作りに関わるということ" (井上周/INOUE Amaneさん)

の3つを聞いたあと小休憩をはさんでから自分の発表でした。

speakerdeck.com

今回はとくにRubyに限らずにLALR parser generatorの作り方のお話をしました。 発表の最初に"パーサーを書いたことがある人"、"パーサージェネレーターを作ったことがある人"と聞いたところ、聞きにきてくれていた人のうち結構な数の人がパーサーを書いたことがあり、ちょっとびっくりしました。

発表後にパーサージェネレーターを自作するのに参考になる本や資料があるかと聞かれたのですが、全体を説明した資料ってあんまりないんですよね。自分がLramaを最初に作ったときもRaccやBisonのコードを結構読んで理解を深めたということもあり、今回"LALR parser generatorの作り方"というテーマで発表しようと思ったのでした。 今回20分では全部のネタを話しきれなかったので、Lramaのコードを参考にしながら説明する形で詳細な解説を書くかもしれません2

自分の発表のあと受付にぶりしゃぶがdeployされたので、ぶりしゃぶを食べたりすこし日本酒を飲んだりしていました。 廊下で@kunitooさんといっぱい話すことができて、大満足です。

そのあとはunvalleyさんの"Biomeの裏側 - Parser / Linter編"を聞いたりしていました。PrettierしかりBiomeしかり日本に開発者がいてparser/lexerの話が聞けるのはよいですね。 発表のあとにunvalleyさんと廊下で話をしてBiomeの実装を読んでみようという気持ちになったので、落ち着いたら読もうと思います。

懇親会

美味しいぶりをいっぱいたべました。お腹いっぱいでもう食べれません。

1/21 (カンファレンス翌日)

友人おすすめの富山市ガラス美術館に行ったりしていました。建物も展示も素敵でとても満足です。

富山銘菓もいろいろと教えてもらったので買ってみました。

佐々木千歳堂さんのお菓子は買いそびれたので次回のお楽しみです。

まとめ

2023年のBuriKaigiの様子をインターネットで見ていて、楽しそう!ずるい!美味しそう!ずるい!と羨んでいたので、今年参加できてよかったです。 発表としてはどこかで話しておきたかったパーサージェネレーターを開発することが話せたので目的は達成されました。 数年ぶりに@kunitooさんとお会いしてお話できて本当によかったです。kunitooさんの鋭い意見やコメントはとても参考になりますし、ほどよい緊張感のある会話は楽しいですね。

今回も含めて3回富山に行っているのにまだ駅の北側に行ったことがないので、次回は富山環水公園や富山県立美術館に行きたいですね。あと路面電車もまだ乗っていないので。

というわけでBuriKaigi 2024を楽しむことができました。運営スタッフの方をはじめ関係者のみなさんありがとうございました!


  1. 雪が降った時のために防水性の暖かい靴を用意していくようにというアドバイスを事前にもらっていてとても助かりました。
  2. 自分のTODOリストには"Ruby Parser開発日誌 (XX) - LALR parser generatorの作り方"というTODOが去年から入っています。

Ruby Parser開発日誌 (16) - 2023年を振り返って

とにかくparserとparser generatorをやっている一年でした。世はまさに"大パーサー時代"!!!

その中でもとくに自分にとって大きな変化だったのは以下の出来事でした。

  • Lrama LALR (1) parser generatorを実装して、Rubyに取り込んだ。これによってRubyからGNU Bisonへの依存が消えた。
  • 最初は一人で始めたRubyのpaserの刷新大事業だったが、各方面に仲間ができてチームで開発ができるようになった。
  • RubyKaigi 2023をはじめ登壇の多い年になった。海外のカンファレンスにも初めて参加した。

Lramaの進捗

Ruby Parser Roadmap - Google スライド

ロードマップを作った当初はこれを全部やるのか... という気持ちになったりしていましたが、Lramaの開発でもparse.yの開発でも多くの人が協力してくれており、確実に進捗しています。

Replace hand written parser with Racc

Lramaのparserを手書きparserからRaccによる自動生成のparserへと置き換えるという目標です。 これは@junk0612さんが実装してくれました。 これによりParameterizing rulesをはじめとした新機能導入の際の文法変更が容易になったり、Lramaのエラーメッセージを改善することができたりとLramaの開発全体が恩恵を受けています。

個人的には新しい文法をLramaに入れる時に、既存の文法を壊していないかを機械的に判断できるようになったのが大きいです。 その結果、曖昧な文法を避けるためにLramaではTagによる型の指定を後置修飾にするという判断をしています。

Option, List (syntax sugar)

@ydah_さんがParameterizing rulesの実装を着々と進めてくださっております。

現在のLramaでは?+といった短縮記法を使えますし、%ruleを用いて独自のParameterizing rulesを定義することもできます。 Lramaの今後の機能拡張としてはMenhirがサポートしている%inlineのサポートがあります。通常のParameterizing rulesは新しくruleを定義するものですが、%inlineの場合はその場にruleを展開します。実装難易度が高いですが、conflictする可能性が減る便利機能なので実装したいです。

Remove Object from Node

Node構造体からRuby Objectへの依存を消すという目標です。 Ruby 3.3でNode構造体のリファクタリングをしたので、新しいNodeを定義するのも簡単になり、作業を始められるようになりました。

早速Ruby 3.4にむけて改善を入れました。__LINE__に対応するNodeを新しく追加し、Integerへの依存を1つ減らしています。 github.com

@S.H.さんが他のNODE_LIT(数値などのリテラル用のNode)の変更に取り組んでいて、現在はNumeric関連のNodeを実装しNODE_LITからRubyのObjectへの依存を消しているところです。

RubyのObjectへの依存を消していくためにはArrayやStringやEncodingのサブセットを独自に実装していく必要があります。ファーストステップとしてNodeからの依存を消すのがよいので、NODE_LITNODE_STRから順次取り組んでいきます。

Union to Struct (Node) & Optimize Node memory management

RubyのNode構造体のリファクタリングは無事に完了し、各Nodeが必要十分なメモリを使うように最適化しました。 詳しくはこちらのRuby Parser開発日誌 (15) - Ruby の NODE を Union から卒業させた - かねこにっき記事で書いています。

Delete parser level optimization

parserレベルで最適化をした結果、構文木の一部が消えてしまうことがあります。 Ruby 3.3では以下のような冗長な入力であっても、1に該当するNodeが残るようにしました。 なおcompile.cのレベルでは最適化されるので、生成されるISeqは変わりません。

ruby --dump=p  -e "1; 2;"
###########################################################
## Do NOT use this node dump for any purpose other than  ##
## debug and research.  Compatibility is not guaranteed. ##
###########################################################

# @ NODE_SCOPE (id: 4, line: 1, location: (1,0)-(1,5))
# +- nd_tbl: (empty)
# +- nd_args:
# |   (null node)
# +- nd_body:
#     @ NODE_BLOCK (id: 2, line: 1, location: (1,0)-(1,4))
#     +- nd_head (1):
#     |   @ NODE_LIT (id: 0, line: 1, location: (1,0)-(1,1))*
#     |   +- nd_lit: 1
#     +- nd_head (2):
#         @ NODE_LIT (id: 1, line: 1, location: (1,3)-(1,4))*
#         +- nd_lit: 2

まだ残っている最適化として、以下のようなコードでNODE_RETURNが消されてしまうというのがあります。 この辺も来年は変えていきたいです。

def m
  return 1
end

Scanner state update syntax

Rubyのparse.yが複雑な理由のひとつはsemantic analysisに必要な情報などを構文解析のstackで管理しているからだという分析をしました。 その分析に基づいて専用のstackを自動で作成する構文を導入する予定です。 このあたりの背景について詳しくはDeclarative parse.yのスライドで説明しています。

パッチはあるので整理してmergeしていきたいです。

RBS

Lramaではrbsファイルを適宜書くようにし、CIでsteep checkを実行しています。 @Little_RubyistさんがCIの整備などをしてくれて、rbsを導入してくれました。

久しぶりに触るファイルでメソッドの引数がなんだったか調べるときなどに便利ですね。 ある変数がnilになるかどうか手軽にわかるのもレビューをしているときに役立っています。

いまはSteepfileのconfigure_code_diagnosticsがデフォルトのままなのですが、いくつか設定を変えた方がいいかもなと思っています。

確認したところ現時点でlib以下のファイルが71に対して、sig以下のファイルが38でした。

登壇とカンファレンス

5月 RubyKaigi 2023

松本で開催されたRubyKaigi 2023に現地参加し登壇しました。福岡で開催したRubyKaigi 2019以来となる、4年ぶりのRubyKaigiへの参加でした。

RubyKaigiではレギュラーセッションでの登壇、LTでの登壇、Ruby Committers and The Worldでの登壇と3回ほど壇上に上がりました。

rubykaigi.org

"世はまさに大パーサー時代"の由来となったLTはこちらから。

speakerdeck.com

また会期中のRuby 3.3.0-preview1 リリースにあわせてLramaをRubyに取り込み、Bisonへの依存を消しました。

5月 手を動かして振り返る RubyKaigi 2023

手を動かして振り返る RubyKaigi 2023 - connpass

ゲストセッションに登壇しました。 LTが3つあったのですが、そのうち2つがLramaに関するものでした。とてもうれしいですね。

speakerdeck.com

scrapbox.io

6月 深掘りRubyKaigi 2023

STORESさんの企画「深掘りRubyKaigi 2023」にmakenowjustさんと一緒に登壇しました。 当日の様子は文字起こしレポートとしてSTORESさんのブログにまとまっています。 密度の濃い話ができてとても楽しかったです。

product.st.inc

8月 RubyKaigi 2023 follow up

RubyKaigi後の進捗発表をしてきました。

RubyKaigiから3ヶ月がたち、LR parserの利点やLR parserのあるべき姿についてより理解が深まったタイミングでした。 プログラミング言語の文法をデザインする際にLR parserがいかにそれをサポートできるかといった話や、parserとlexerの状態共有を統合的に設計しなおすことが大事という話をしました。

speakerdeck.com

9月 大阪Ruby会議03

@junk0612さんがLramaについて話をするということで、大阪にいってきました。 プログラミング言語のパースという一部分を担うparser generatorの内部構造もまた、言語処理系と似た構造になるという話が興味深いですね。

speakerdeck.com

11月 Fukuoka.rb #333 Ninja Talk 大会

Fukuoka.rb 333回記念ということで、parse.yの将来について話をしました。6人中4人がparserもしくはparser generatorの話をしていました。

私はparse.yをもっとDeclarative(宣言的)にしようという話をしました。 オートマトンおよびそれを応用したLR parserは良い構造をしています。 それをさらに発展させることでよりDeclarative(宣言的)なparser実装になるはずです。 論理に則ったDeclarativeなparserにすることで、教科書などで得られる知識とparse.yの実装の乖離をなくしていくことができ、それが"parse.y for Under graduate"というロードマップ上のひとつのゴールだという話です。

speakerdeck.com

LR parser界の巨人であるMenhirとLramaが対比されている『Menhir is here!』も感慨深い発表でした。

speakerdeck.com

11月 RubyWorld Conference 2023

Ruby Prize 2023 最終ノミネート者になったので、松江で開催されたRubyWorld Conference 2023に行ってきました。 Ruby Prize 最終ノミネートは2018年に続き2回目です。

結局毎晩parserの話をしていたような気がします。

12月 RubyConf Taiwan 2023

@junk0612さんが今度は台湾でLramaの話をするというので台湾にいってきました。 CFGやBNFの話からはじまりLR構文解析器の挙動、Lramaへのcontributionの話へとポイントを丁寧に押さえた発表でした。 @junk0612さんはRacc化とNamed Referencesという大きめの2つのテーマを今年実装してくださいました。次はIELRに取り組むとのことで楽しみにしています。

初めての海外カンファレンス、初めての台湾でしたがとても楽しめました。 台湾でも毎晩parserの話をしていたような気がします。

speakerdeck.com

Ruby Parser開発日誌 (ブログ)

実装や設計、関連する研究の紹介など、Rubyのparser(parse.y)とparser generator(lrama)に関することを主に書いている当ブログですが、今年は15本の記事を書きました。 その時々で取り組んでいることや考えていることを書いており、どの記事も思い出深いのですが、大舞台だったRubyKaigiについて書いたRuby Parser開発日誌 (9) - RubyKaigi 2023で発表してきた ~ 世はまさに”大パーサー時代” ~ - かねこにっきと、LR構文解析表と文法定義の関係を簡潔に説明したRuby Parser開発日誌 (14) - LR parser完全に理解した - かねこにっきがとくに印象に残った記事です。特後者はいままで教科書などで説明できていなかった内容だと思うので、人類の構文解析に関する知識の発展に貢献できたのではないかと考えいます。

現在進行形で開発しているプロジェクトにおける様々な設計方針や判断の背景が読める機会はあまりないと思うので、来年も引き続きRuby Parser開発日誌を書き、LR parserおよびparser generatorの面白さをお伝えできたらと思います。

それではみなさん、良いお年を!!!

Ruby Parser開発日誌 (15) - Ruby の NODE を Union から卒業させた

まもなくRuby 3.3.0がリリースされますね。

LramaによるBisonの置き換え、named referencesによるparse.yのリファクタリングなど、parser本体の大きな改善が入ったバージョンになります。

今回はRuby 3.3向けに行った改善のうち「Rubyの抽象構文木のデータ構造の改善」という内部的な改善を紹介します。

問題の背景

RubyのNodeはかつてGCで管理されていました。そのころの名残で全ての種類のNodeがたった一つのRNode構造体を共有しています。 他のNodeへのポインタやメソッド名を表すIDなどはu1, u2, u3というunionで管理しています。

typedef struct RNode {
    VALUE flags;
    union {
        struct RNode *node;
        ID id;
        VALUE value;
        rb_ast_id_table_t *tbl;
    } u1;
    union {
        struct RNode *node;
        ID id;
        long argc;
        VALUE value;
    } u2;
    union {
        struct RNode *node;
        ID id;
        long state;
        struct rb_args_info *args;
        struct rb_ary_pattern_info *apinfo;
        struct rb_fnd_pattern_info *fpinfo;
        VALUE value;
    } u3;
    rb_code_location_t nd_loc;
    int node_id;
} NODE;

これには三つの問題点があります。 メモリを無駄遣いしていること、一部のNodeが複雑な構造になっていること、Nodeの種類とそのフィールドの対応が一目で分からないことです。

メモリの無駄遣い

例えばtrueを表すNODE_TRUEnilを表すNODE_NILなどは他のNodeへの参照といった付加的な情報をもちません。 ということはRNode構造体分のメモリを確保していても、それらのu1, u2, u3といったフィールドは使われていません。

複雑な構造のNode

一方でstruct.field += fooを表すNODE_OP_ASGN2struct, field, +=, fooと4つのフィールドを必要としており、u1からu3では足りません。 そのため2つのNodeを組み合わせてNODE_OP_ASGN2を表現しています。

Nodeの種類とそのフィールドの対応

Unionとして定義されているu1, u2, u3の実際の型はNodeの種類によって変わります。 例えばNODE_IFであれば3つとも他のNodeへの参照ですし、NODE_CALLの場合はNodeとidが混在しています。 実際にNodeを使うときは、Nodeの種類に応じて適切なマクロを使い分ける必要があります。

// NODE_IFの場合は3つとも他のNodeのポインタになっている
#define nd_cond  u1.node
#define nd_body  u2.node
#define nd_else  u3.node

// NODE_CALLの場合はメソッド名がidで他はNodeのポインタ
#define nd_recv  u1.node
#define nd_mid   u2.id
#define nd_args  u3.node

Ruby Parser Roadmapとの関係

これらの問題は"Union to Struct (Node)"および"Optimize Node memory management"という目標としてRuby Parser Roadmapにも組み込まれています。 とくにNodeのデータ構造を改善する"Union to Struct (Node)"は、LSP向けにより使い勝手のよいASTを返すという大目標の前提となる目標です。

docs.google.com

やったこと

UnionからStructへ

github.com

まずはNODE_TRUEであればstruct RNode_TRUENODE_OP_ASGN2であればstruct RNode_OP_ASGN2といったように、Nodeの種類ごとに対応する構造体を用意します。 struct RNodeは各種構造体の先頭のフィールドとしてflagsnd_locなど共通する要素のみを含む構造体にします。 Node全体はstruct RNodeで扱い、必要に応じてそのtypeをみてRNODE_TRUEなどのマクロでキャストして使います。

ここでは使っていないフィールドもnot_usedという名前のフィールドとしていままで通り確保するようにしておきます。 この時点では"parse.y"や"compile.c"の一部のロジックがNODE_CALL系のNodeを一律NODE_CALLにキャストしていたりするので、not_usedなフィールドを手当たり次第消してしまうとSEGVすることがあるためです1

typedef struct RNode_TRUE {
    NODE node;

    // 実際は使っていないがnot_usedという名前にして確保しておく
    VALUE not_used;
    VALUE not_used2;
    VALUE not_used3;
} rb_node_true_t;

また今後構造体ごとのサイズが変わることを想定して、Node用のバッファで異なるサイズの構造体を管理できるように変更します

not_usedなフィールドを消していく

準備は完了しました。not_usedを消していきましょう!

SELF, NIL, TRUE, FALSEALIAS, VALIAS, UNDEFなどは単純に構造体からフィールドを消すだけでよいので対応は簡単です。一方でNODE_CALLなどメソッド呼び出しに関するNodeは複数種類のNodeを共通して扱う箇所があるので、補助的な関数を書いてフィールドアクセスを隠蔽する必要がありました。例えばレシーバーを取り出すget_nd_recvやメソッドのidを取り出すget_nd_midなどを実装する必要がありました。

このPRをもって全てのNodeからnot_usedなフィールドの削除を完了しています。

github.com

複数のNodeで表現していたものを一つにまとめる

たびたび話題にあがるNODE_OP_ASGN2ついに一つのNodeへと統合されました

またパターンマッチングに使うNODE_ARYPTNNODE_FNDPTNではそれぞれ専用の構造体を使ってNodeに収まらないデータを管理していましたが、今回の改善でそれらもNodeの中に埋め込むことができました

まとめ

たった一つのRNode構造体で管理していたRubyのNodeを、その種類に応じたNodeを定義してそれらを使うように変更しました。そしてNodeごとに必要なフィールドだけを定義するようにリファクタリングを行いました。

これらのリファクタリングによって以下の3つの点が改善されました。

  1. 各Nodeが必要十分なメモリを使うようになった
  2. Nodeのネストのような複雑な構造が解消された
  3. 各Nodeの構造体を見るだけでどのような型のフィールドがあるのか一目でわかるようになった

これらの改善をもとに、次はいよいよLSP向けに使いやすいNodeを提供するために、Nodeの構造を変更していきます。

この改善は遠藤さんが2017年に行った改善の延長にあるものです。"抽象構文木の表現のムリ・ムダを省いていける準備ができた"にあるように、準備はできていたのですがなかなか着手する機会がなく今日まできていました。2017年以来いつかやろうとおもっていた改善でしたが、今年ついに達成することができました。2017年にNodeのメモリ管理を整理していただき、ありがとうございました2

techlife.cookpad.com


  1. ここでいうNODE_CALL系のNodeとはNODE_CALL, NODE_OPCALL, NODE_FCALL, NODE_QCALL, NODE_VCALL, NODE_SUPER, NODE_ZSUPER, NODE_YIELD, NODE_RETURN, NODE_BREAK, NODE_NEXT, NODE_ATTRASGNのことです。具体例としてはparse.yのget_nd_argsをみるのがいいでしょう。
  2. 実はこれで終わりではなく、"Ripper 対応"に書いてある"NODE の先頭ワードはオブジェクトと同じ構造でないといけません"という問題がまだ残っています。というわけで"Ruby の NODE を GC から卒業させた"をめぐるお話はもうちょっとだけ続きます。

Ruby Parser開発日誌 (14) - LR parser完全に理解した

こんにちはかねこです。私はCRuby(ruby/ruby)のコミッタをやっているのですが、最近はCRubyをメインのターゲットとしてLALR parser generator Lramaの開発をしています。 現役のLALR parser generator開発者として、日頃私以上にLR parserのことを考えている人はそうはいないでしょう。

この記事を読んでいる皆さんは構文解析、なかでも特にLR parserを理解するためにいろいろな教科書や記事を読んできたと思います。 一方でどんなに調べてもどこか腑に落ちない部分が残っているのではないでしょうか。

LR構文解析を勉強すると構文解析表に出会うとおもいます。

構文解析表を作る方法そのものは教科書に説明が載っており、その通りに手を動かせばこのような表を作ることはできるでしょう。 また出来上がった構文解析表をもとに実際に構文解析する手順も理解できると思います。 しかしこの表が一体何者であり、元の文法定義とどのような関係にあるのか、構文解析表の作り方の背後にはどのようなアイデアがあるのかというと説明に困るのではないでしょうか。 正直に言って教科書を含めてもLR parserの解説というのはとても分かりにくいものであると思います。 大堀先生によるLR構文解析の原理はLR構文解析オートマトンの関係や、プッシュダウンオートマトンを用いることによる計算の最適化について詳しい説明を加えた優れた解説ですが、それでも構文解析表のもととなる非決定性オートマトンの構成方法に関しては従来通りitem(項)を用いるという方法をとっており、結果として生成規則との関係を直感的に理解することが難しくなっているように思います。

本日は今までとは違った切り口でLR parserの仕組みを解説し、皆さんがLR parserの基本原理の理解を深められたらと思い記事を書きました。

Rubyのサブセットをパースする

いきなりLR項やカーネル項の話をしても理解が深まると思えないので、まずは簡単な文法とそのparserを作りながら、基本的な動作原理を理解しましょう。 ここではRubyの(ごくごく)サブセットの実装を通じて、parserへの理解を深めます。

1. classだけが定義できる言語

最初はクラスが1つだけ定義できる言語を考えてみましょう。 以下の通りの入力だけを受け付けるparserを考えます。

class A
end

このような入力をパースするにはどうしたらいいでしょうか。そうですね決定性有限オートマトンを用意すればいいですね。

上図のオートマトンclass A endという入力のみを受理します。受理をしない例をいくつかあげてみましょう。

  • class: q1は受理状態ではないため
  • A: q0ではclassのみを受け付けるため
  • : q0は受理状態ではないため
  • class A end class: q3ではEOI(End Of Input / 入力の終了を表す特殊な記号)のみを受け付けるため

2. classの中に1つだけメソッドが定義できる言語

先ほどの例を拡張してclassの中にメソッドを1つだけ定義できる言語を考えてみましょう。 以下の通りの入力だけを受け付けるparserを考えます。

class A
  def m
  end
end

先ほど同様にオートマトンを用意します。

この例もオートマトンが1つあれば十分ですが、ここではあえてオートマトンを2つに分解してみましょう。

図中上段の青色のオートマトンclass A ... endのパースを行い、図中下段の赤色のオートマトンdef m endのパースを行います。 class Aまでパースが終わった時点で下段のオートマトンに処理が移ります。def m endの処理を下段のオートマトンが終えると、再び上段のオートマトンに戻ってきてclassに対応するendEOIを受け付けて全体の処理が完了します。

具体的な処理の流れをみていきましょう。

まずはclass Aまでを上段の青色のオートマトンで処理します。この次はdef m endをパースすることになるので、一旦処理を下段の赤色のオートマトンに移します。

つぎにdef m endの処理を赤色のオートマトンで行います。無事に赤色のオートマトンの最後まで遷移したので、ここで処理をもとの青色のオートマトンに移します。

最後にclass Aに対応するendを処理し、EOIを受け付けて全体の処理が完了します。

3. classの中に1つだけメソッドもしくは定数が定義できる言語

次はclass定義のなかにメソッド定義もしくは定数定義を書けるように拡張してみましょう。

class A
  def m
  end
end
class A
  CONST = 1
end

を用意します。class Aまでパースした時点で赤色/緑色のどちらかに制御が移ります。次の入力をみて移動先のオートマトンを決めてもいいですし、とりあえず赤色のオートマトンに移動してdefでなければ緑色のオートマトンに移動するという方法をとってもいいでしょう。ここでは上から順番に試すという後者の方法をとることにしましょう。

4. class定義がネストできる言語

これまでの例ではオートマトンを分割するメリットをまだ全ては説明できていません。 オートマトンを分割することのメリットを理解するために、次の例ではclassの定義の中にclassの定義をネストできるようにしましょう。説明を簡単にするために、一番内側のclass定義には必ず定数を定義するものとします1。 ネストは何段階でも可能なので、次の例はいずれも正しい入力となります。

class A
  class A
    CONST = 1
  end
end
class A
  class A
    class A
      CONST = 1
    end
  end
end

の2つがあればよいですが、今までの例と異なりclassをパースするオートマトンから同じくclassをパースするオートマトンに処理が移ることがあります。

実際のコードをパースする様子を見てみましょう。対象となるコードは以下のコードです。

class A
  class A
    CONST = 1
  end
end

1行目のclass Aまでパースを終えたところで次のclass Aをパースをするためのオートマトンを用意して制御を移します。

2行目のclass Aまでパースを終えたところで次のCONST = 1をパースをするためのオートマトンを用意して制御を移します。

3行目のCONST = 1をパースを終えた時には次のような状態になります。

CONST = 1のパースが終わったので、3つめのオートマトンの役割は完了です。1つ前のオートマトンに戻って4行目のendをパースします。

4行目のendまで処理が終わると2つめのオートマトンの役割は完了です。最初のオートマトンに戻って5行目のendをパースします。最後にEOIがきて入力全体のパースが完了します。

BNFとの対応を考える

ここまでは"class定義がネストできる言語"というように自然言語で言語を定義してきましたが、言語を定義するの適した記述方法がありますね。そうですね、BNFです。

program : class_def
        ;

const_decl : "CONST" "=" "1"
           ;

class_def : "class" "A" body "end"
          ;

body : class_def
     | const_decl
     ;

このBNFをじーっと見るとそれぞれのルールがオートマトンと対応していることがわかると思います。

またclass_defbodyといった非終端記号が次のオートマトンを生成するポイントと一致していることもわかると思います。

class A
  class A # <- ここまでをパースした状態
    CONST = 1
  end
end

Earley法

ここまでは文法のルール1つに対してオートマトンを1つ用意し、複数の候補があるときは上から順番に全部試すというバックトラックのある方法を説明してきました。 この方法はLR法ではなく、Earley法という方法です。Earley法の詳しい説明はMakeNowJustさんによる以下の記事をご覧ください。

makenowjust-labs.github.io

LR法へ

バックトラックなしでパースする方法としてLR法があります。LR法にはLR(0), SLR(1), LALR(1), LR(1)など様々な種類がありますが、ここではLR法全体で有効な考え方について説明します。

結論を先取りしていうと、LR法というのは各オートマトンを合成して、一つの大きなオートマトンにしたものです。

オートマトンの圧縮

オートマトンを合成して大きなオートマトンを作るという点を説明するために新しい言語を定義しましょう。 それは"class定義が一つありそのなかにメソッド定義もしくはシングルトンメソッド定義が1つある"という言語です。 具体的には以下の2つに対応するような言語です。

class A
  def m
  end
end
class A
  def self.m
  end
end

BNFで記述すると次のようになります。

program : class_def
        ;

class_def : "class" "A" body "end"
          ;

body : method_def
     | singleton_method_def
     ;

method_def : "def" "m" "end"
           ;

singleton_method_def : "def" "self" "." "m" "end"
                     ;

いまclass Aまでパースをしたとしましょう。 先ほどまでの方法ではまずmethod_def : "def" "m" "end"に対応するオートマトンを試し、それでうまくいかない場合はsingleton_method_def : "def" "self" "." "m" "end"に対応するオートマトンを試すのでした。

ここでオートマトンを1つに合成してみるとどうなるでしょうか。 例えばclass Aまでパースをした状態は図のような1つの状態として捉えることができます。 この状態は

  • class Aまでパースした状態であり、かつ
  • 次にbodyが来れば遷移することができる状態であり、かつ
  • 次にmethod_defが来れば遷移することができる状態であり、かつ
  • 次にdefが来れば遷移することができる状態であり、かつ
  • 次にsingleton_method_defが来れば遷移することができる状態であり、かつ
  • 次にdefが来れば遷移することができる状態である

といえます。

さらにmethod_def : keyword_def method_name keyword_endオートマトンsingleton_method_def : keyword_def keyword_self '.' method_name keyword_endオートマトンを合成してみます。

合成後のオートマトンではdefが来たときにu1に遷移します。その次がmであればu2(上の分岐)へ、selfであればu4(下の分岐)へ遷移します。 このようにオートマトンを合成することで、バックトラックなしで複数の候補を試すことができるようになりました。

Lramaをつかって各状態を表示するとclass Aまで読んだ状態はState 4で、これは先ほど説明した5つの状態が重なった状態です。このときにdefを読んだ場合はState 6に遷移します。State 6u1に対応しています。State 6ではmselfで遷移先が異なり、そこから先は通常のメソッド定義もしくはシングルトンメソッド定義のそれぞれに特化した処理になっていることがわかります。

State 4 (p2/u0に相当)

    2 class_def: "class" "A" • body "end"
    3 body: • method_def
    4     | • singleton_method_def
    5 method_def: • "def" "m" "end"
    6 singleton_method_def: • "def" "self" '.' "m" "end"

    "def"  shift, and go to state 6

    body                  go to state 7
    method_def            go to state 8
    singleton_method_def  go to state 9


State 6 (u1に相当)

    5 method_def: "def" • "m" "end"
    6 singleton_method_def: "def" • "self" '.' "m" "end"

    "self"  shift, and go to state 10
    "m"     shift, and go to state 11


State 10 (u4に相当)

    6 singleton_method_def: "def" "self" • '.' "m" "end"

    '.'  shift, and go to state 13


State 11 (u2に相当)

    5 method_def: "def" "m" • "end"

    "end"  shift, and go to state 14

構文解析表の生成

BNFのルールに対応するオートマトンを合成して、一つの大きなオートマトンを生成しました。 この一つの大きなオートマトンの動きを表にまとめたものが構文解析表です。

例としてState 4 (p2/u0)とState 6 (u1)について構文解析表を見てみましょう。

State 4 (p2/u0)はclass Aまでパースした状態であり、body, method_def, singleton_method_def, defのうちどれが来るかによって遷移先が異なる状態です。構文解析表の青色の行をみてみると、body, method_def, singleton_method_def, defのそれぞれで遷移する先の状態が異なることがわかります。

State 4 (p2/u0に相当)

    2 class_def: "class" "A" • body "end"
    3 body: • method_def
    4     | • singleton_method_def
    5 method_def: • "def" "m" "end"
    6 singleton_method_def: • "def" "self" '.' "m" "end"

    "def"  shift, and go to state 6

    body                  go to state 7
    method_def            go to state 8
    singleton_method_def  go to state 9

State 6 (u1)はdefまでパースした状態であり、mselfのどちらが来るかによって遷移先が異なる状態です。構文解析表の赤色の行をみてみると、mselfでそれぞれ遷移する先の状態が異なることがわかります。

State 6 (u1に相当)

    5 method_def: "def" • "m" "end"
    6 singleton_method_def: "def" • "self" '.' "m" "end"

    "self"  shift, and go to state 10
    "m"     shift, and go to state 11

まとめ

無事に構文解析表を生成することができました。 いままで天下り的にBNFから作り出していた構文解析表でしたが、その正体は複数のオートマトンから生成した一つの大きなオートマトンの遷移を表すものだったのです。

構文解析表ができるまでの順番を整理しておきましょう。

  1. BNFのルールに対応するオートマトンを用意する
  2. 1で用意したオートマトンを合成して1つのオートマトンにまとめる
  3. 2でつくったオートマトンの遷移を表にまとめると構文解析表になる

今後みなさんがLR parserを実装していて迷った時には、それは複数のオートマトンから生成した一つの大きなオートマトンであるということを思い出してください。

ruby-jp slackの#lr-parserチャンネルにおけるnaruseさんとmake_now_justさんとの議論からこのような説明の仕方は生まれました。両名に感謝します。ありがとうございます。


  1. ε (empty)を加えると説明が複雑になるため、定数定義をネストのストッパーとして利用しています

Ruby Parser開発日誌 (13) - Ruby Parser Roadmapをつくった

Ruby Parser Roadmapをつくったのでご紹介します。

docs.google.com

大きな2つのゴール

現在注力している領域が2つあります。ひとつはLSPのサポート(図中では青色)、もうひとつはparse.yをより平易にするための取り組みです(図中では赤色)。またその他のゴールとしてUniversal Parserがあり、さらに依存関係はないものの取り組みたいと思っているテーマがいつかあります。

なお図中の矢印は矢印の元から先に向けて依存があることを示しています。例えば"LSP"というゴールは"Delete parser level optimization"と"User friendly node structure"という2つに依存しています。図中の紫色の部分は2つの注力領域両方が依存しているものを表しています。

LSP

一つ目のゴールはLSPなどの各種ツールで使いやすいparserを提供することです。図の中では青色のボックスが該当します。 ゴールであるLSPから直接依存関係があるサブゴールとして

  • User friendly node structure
  • Delete parser level optimization

の2つがあり、それらはさらに1つのサブゴールに依存しています。

  • Union to Struct (Node)

また直接の依存関係はないですが、"Error tolerance"に関する機能を開発することもLSPのサポートでは重要なので、青色になっています。

それぞれ順番に見ていきましょう。

User friendly node structure

既存のAST Nodeは使い勝手の悪い部分がいくつかあります。

AbstractSyntaxTree#childrenがarrayを返すため、arrayの要素の位置からその意味が把握しにくいものになっています。 例えばNODE_IFの場合、nodeが3つのarrayを返します。それぞれcondition, then, elseに対応するnodeです。

RubyVM::AbstractSyntaxTree.parse("if a then 1 else 2 end").children[2].children
# => [(VCALL@1:3-1:4 :a), (LIT@1:10-1:11 1), (LIT@1:17-1:18 2)]

この場合#condition, #then, #elseのように要素名に対応したメソッドが提供されてたほうが使い勝手はよくなるでしょう。

node = RubyVM::AbstractSyntaxTree.parse("if a then 1 else 2 end").children[2]
node.condition
# => (VCALL@1:3-1:4 :a)

いまのNodeにはスクリプトと直接紐づかないNodeがあります。例えばNODE_SCOPEというNodeがあるのですが、これはメソッド定義やクラス定義のNodeの中にあらわれて引数の情報などを管理しています。引数の情報はNODE_DEFNに直接持たせて、NODE_SCOPEを消すのがよいでしょう。

$ ruby --dump=p -e 'def m(a); end'
#     @ NODE_DEFN (id: 1, line: 1, location: (1,0)-(1,13))*
#     +- nd_mid: :m
#     +- nd_defn:
#         @ NODE_SCOPE (id: 5, line: 1, location: (1,0)-(1,13))
#         +- nd_tbl: :a
#         +- nd_args:
#         |   @ NODE_ARGS (id: 3, line: 1, location: (1,6)-(1,7))

また実引数のNodeが複雑なのでもう少し簡単な構造にしたいと思っています。

# シンプルなケースではNODE_LIST
$ ruby --dump=p -e 'm(a)'
# +- nd_body:
#     @ NODE_FCALL (id: 0, line: 1, location: (1,0)-(1,4))*
#     +- nd_mid: :m
#     +- nd_args:
#         @ NODE_LIST (id: 2, line: 1, location: (1,2)-(1,3))
#         +- as.nd_alen: 1
#         +- nd_head:
#         |   @ NODE_VCALL (id: 1, line: 1, location: (1,2)-(1,3))
#         |   +- nd_mid: :a
#         +- nd_next:
#             (null node)

# &blockがあるときはNODE_BLOCK_PASSからはじまる
$ ruby --dump=p -e 'm(a, &block)'
#     +- nd_args:
#         @ NODE_BLOCK_PASS (id: 4, line: 1, location: (1,2)-(1,11))
#         +- nd_head:
#         |   @ NODE_LIST (id: 2, line: 1, location: (1,2)-(1,3))
#         |   +- as.nd_alen: 1
#         |   +- nd_head:
#         |   |   @ NODE_VCALL (id: 1, line: 1, location: (1,2)-(1,3))
#         |   |   +- nd_mid: :a
#         |   +- nd_next:
#         |       (null node)
#         +- nd_body:
#             @ NODE_VCALL (id: 3, line: 1, location: (1,6)-(1,11))
#             +- nd_mid: :block

Delete parser level optimization

いまのRubyにはparserレベルでの最適化がいくつか入っていて、その結果一部のNodeが削除されます。

def m
  1 # この行は意味がないのでASTに残らない
  2
end

#         +- nd_body:
#             @ NODE_LIT (id: 4, line: 3, location: (3,2)-(3,3))*
#             +- nd_lit: 2
def m
  return 1 # メソッドの最後のNODE_RETURNは削除されてNODE_LITだけが残る
end

#         +- nd_body:
#             @ NODE_LIT (id: 3, line: 1, location: (1,14)-(1,15))*
#             +- nd_lit: 1

LSPなどのツールに対して構文解析した結果を返すというユースケースにおいては、ASTのNodeレベルでの最適化は情報が落ちるので嬉しくありません。必要に応じて最適化をcompile.cに移し、parserでの最適化は消していく必要があります。

Union to Struct (Node)

"User friendly node structure"や"Delete parser level optimization"を行うためにはNodeの構造を変更していく必要があります。しかしいまのNodeの内部構造はそれらの変更に向かないものになっています1

RubyのNodeはかつてGCで管理されていました。そのころの名残で全ての種類のNodeがたった一つの構造体を共有しています。他のNodeへのポインタやmethod名を表すIDなどはu1, u2, u3というunionで管理しています。それぞれのunionがどの型かというのはNodeの種類によって変わり、Nodeの種類に応じて適切なマクロを使い分ける必要があります。

また構造体を共有しているということは、あるNodeで4つ以上の情報を扱うためにunionを追加すると、ほかの全てのNodeでもその分のメモリが確保され、必要以上にメモリを使うことになります。

Nodeの種類に応じた構造体を別々に定義することでこの問題は解決されます。

typedef struct RNode {
    VALUE flags;
    union {
        struct RNode *node;
        ID id;
        VALUE value;
        rb_ast_id_table_t *tbl;
    } u1;
    union {
        struct RNode *node;
        ID id;
        long argc;
        VALUE value;
    } u2;
    union {
        struct RNode *node;
        ID id;
        long state;
        struct rb_args_info *args;
        struct rb_ary_pattern_info *apinfo;
        struct rb_fnd_pattern_info *fpinfo;
        VALUE value;
    } u3;
    rb_code_location_t nd_loc;
    int node_id;
} NODE;
// NODE_IFの場合は3つとも他のNodeのポインタになっている
#define nd_cond  u1.node
#define nd_body  u2.node
#define nd_else  u3.node

// NODE_CALLの場合はmethod名がidで他はNodeのポインタ
#define nd_recv  u1.node
#define nd_mid   u2.id
#define nd_args  u3.node

Error tolerance

Error toleranceに関して4つのサブゴールがあります。

Efficient data structure (Cactuses)

Error toleranceの実装はどのようなtokenをinsert/deleteすればいいかを探し出すというものです。tokenの種類によってはreduceをして探索を進める必要があるため、候補となるtokenごとにparserのstackのコピーをもつというナイーブな実装になっています。

このようなケースではCactusesデータ構造というデータ構造を使うのがよいので、置き換えていきたいです。詳しくは以下のリンク先を参照してください。

Delete operation support

"どのようなtokenをinsert/deleteすればいいかを探し出す"と書きましたが、いまのところはinsertのみで探索を行います。入力されたスクリプトを残した方がいいと思うので基本的にはinsertをメインにして探索をするのでいいと思うのですが、場合によってはtokenをdeleteしたほうが良いケースもあるとおもうので、両方をサポートできるようにしたいです。

Integration to parse.y

Lrama側には実装をしたのですが、実はまだRuby本体と結合できていません。ちゃんと繋ぎ込みをして利用できるようにします。

More accurate recovery

Error toleranceの実装はparserの状態遷移を利用しています。そのためlex stateやparser_paramsで管理している状態をparserの状態に合体することで、エッジケースなどでよりよいリカバリーが期待できます。そのためこのゴールは"Scannerless parser"や"Scanner state update syntax"といった他のゴールに依存しています。

parse.y for Under graduate

二つ目のゴールはparse.yをより理解しやすいものにしていくことです。具体的には教科書などで構文解析を勉強すれば、parse.yを理解できるという状態にしたいと思っています。 そのためにはLexerやGrammar Ruleのactionに手書きされているロジックを減らしていき、より宣言的なGrammarにしていく必要があります。これが"More declarative parser"というゴールです。

このゴールを達成するためには大きく2つの課題があります。ParserとLexerを統一してデザインすることと、Lramaの文法ファイルのDSLをリッチにすることです。

Scannerless parser と IELR

Ruby Parser開発日誌 (11) - RubyKaigi 2023 follow upで進捗について話してきた - かねこにっきの"LexerとParserは分離可能なのか"で書いたように、RubyにおいてLexerとParserは相互に影響しながら動きます。 BisonではLexerとParserの相互作用はGrammar Ruleのactionによって記述する必要があったので、それらは全てC言語による実装になっています。

LexerとParserを仕組みとして統合する試みのひとつに PSLR(1) (Pseudo-Scannerless Minimal LR(1)) という種類のparserがあります。またそのベースとなるparserにIELR(1)というLALR(1)を進化させたparserがあります2

LALR, IELR, PSLRとparserのアルゴリズムを発展させていくというのが、ひとつのサブゴールです。

Scanner state update syntax

PSLRやIELRがparserのアルゴリズムの進化であるのに対して、こちらはDSLを発展させてLALRをより強力にしていくアプローチです。 例えばRubyにはkeyword_ifmodifier_ifというようにifが2種類あります。これらはlex_stateという状態に応じてどちらになるかが決まります。lex_stateの更新をDSLとしてサポートすることで、parse.yの見通しをよくすることができるのではないかと考えています。

stmt | stmt modifier_if expr_value

=>

stmt | stmt %lex(EXPR_END) modifier_if expr_value

Option, List (syntax sugar)

既存の文法についても改善の余地があります。,区切りのarg_valueの連続(args)や省略可能な改行(opt_nl)といったルールの書き方は頻出で、書き方もテンプレート化しています。 Menhirの機能を参考にDSLを拡張していきます。

args        : arg_value
            | args ',' arg_value
            ;

opt_terms   : /* none */
            | terms
            ;

=>

args        : separated_list(',', arg_value)
            ;

opt_terms   : option(terms)
            ;

Replace hand written parser with Racc

"Scanner state update syntax"や"Option, List (syntax sugar)"をやろうとすると、Lramaのサポートする文法を拡張していく必要があります。 いままではBisonの文法を移植していればよかったのですが、これからは自分で文法を考えて追加していかなくてはなりません。 いざ自分で文法を追加するようになるとわかるのですが、これから追加する文法が既存の文法とぶつかっていないかがすごく気になります3。LR parserであればそのような問題はconflictとしてレポートされるので開発段階で気がつくことができます。

そこで、Lramaの手書きパーサーをRaccで生成したLR parserに置き換えるというサブゴールがあります。 LramaのDSLの拡張すべてに対して有効なので、このサブゴールは他の多くのサブゴールから依存の矢印が伸びています。

その他のゴール

白色の表されたその他のゴールについてみていきましょう。

Universal Parser

Universal ParserとはTypeProf, Sorbetといった解析ツールや、mrubyなどCRuby以外のRuby実装でもCRubyのparserを使えるようにするというゴールです。 そのためにはいまのparserからCRubyの機能への依存を消す必要があります。

Decouple AST from imemo

最終的にはASTをimemoというinternalなobjectから剥がす必要があります。

Remove Object from Node

Nodeが参照しているCRubyのobjectのGCの関係で、ASTもまたCRubyのobjectである必要があります。なので先に各種NodeからCRubyのobjectへの参照を消す必要があります。

Refactoring Ripper

ParserとCRuby objectといえばRipperの存在がありますね。簡単にいうとRipperはparse時の情報をcallbackに渡し、その戻り値を次のcallbackに渡すということを繰り返します。callbackからの戻り値はもちろんRuby objectなので、GCなどを考慮しないといけません。いまはRipper用のRuby objectを管理する場所とparserのNodeを管理する場所が同じstackになっているので、これを何かしらの方法で分離する必要がありそうです。

たとえばRipperのときはRubyのArrayをつかってstackを管理するといった拡張をLramaのDSLでサポートすればよいかもしれません ("User defined stack")。

その他の課題

Optimize Node memory management

"Union to Struct (Node)"で話したとおり、いまのNodeは種類によらず同じ構造体を使っています。そのためNODE_NILNODE_TRUEといった他のNodeへの参照をもたないNodeであっても他のNodeと同じだけメモリーを使用します。なんとかしたいですね。

RBS

Lramaの取り組みの一つとしてRBSを導入して型付けを進めています。LramaはCRubyのbuildに使われるという関係上、実行時に外部のgemに依存しないように実装しています。 なので型付けが自身に閉じており、型付けの恩恵も受けやすいのではないかと思います。 またsteepやrbsへのフィードバックもしています。他のゴールとの間に依存関係はありませんが、重要なゴールの一つです。

まとめ

"LSP"と"parse.y for Under graduate"というテーマをメインにロードマップを作りました。

ご覧の通りParser/Parser generator、Ruby/C言語/RBSといろいろな切り口のゴールがあります。

ruby-jp slackに#lr-parserというチャンネルがあるので、ご興味のある方ぜひぜひお越しください。

ruby-jp.github.io


  1. 詳しくは Ruby の NODE を GC から卒業させた - クックパッド開発者ブログ を参照してください
  2. IELR(1)もPSLR(1)もJoel E. Dennyの発明で、PSLR(1)のためにIELR(1)を作ったのではないかと考えています
  3. Bisonの文法は%xxxというように%からはじまるtokenを多用するので、それに従っていれば多くの場合コンフリクトはしないはずですが、それでも気になります