以下の内容はhttps://yui-knk.hatenablog.com/entry/2025/09/12/110029より取得しました。


Ruby Parser開発日誌 (23) - Rubyの文法のいじり方

こんにちはみなさん。 突然ですがみなさんはRubyの文法を日頃いじっているでしょうか。 私はいじっています。

なにかチケットが起票されたり、知り合いから問い合わせを受けていじることもあれば、parse.yを眺めていてある箇所を変更するとどうなるだろうかと気のおもむくままにいじることもあります。

最近はFeature 17398のようなendless method definition関係のチケットや、Bug 21378Bug 21097といったpattern matching関係のチケットを調査しています。 RubyConf Taiwan 2025やRubyKaigi 2025 follow upの登壇でそれらの結果の一部を紹介しました。

speakerdeck.com

speakerdeck.com

とはいえこれらの発表では普段何を考えどのように調査をしているかという点は語られていないので、具体的なチケットを題材に文法のいじり方をまとめておこうと思います。

Feature 17398: SyntaxError in endless method とは

今回とりあげるチケットはendless method definitionに関するものです。

bugs.ruby-lang.org

このチケットのもともとの内容は以下のようにendless method definitionのbodyが特定の形をしているときにsyntax errorになるというものでした。

def foo() = puts("bar") # ok
def foo() = puts "bar" # SyntaxError

このケースに関していえばすでにパッチが入っており、現在のRubyでは問題なく動きます1

しかしこのパッチのコミットメッセージをみるとprivate def hello = puts "Hello"はtechnical reasonでパースできないと書かれています。 このケースについていえばtechnical reasonでパースができないはずはないなと思ったので最近パッチを作成しました。 ここからはパッチをつくるときに具体的に何をしているかを説明します。

1. 問題が発生している状態を割り出して、対応する文法ルールを把握する

文法をいじるまえにそもそもいま何が起きているかを確認します。 ここでは実際にパッチを作ったさいにベースにしたcommit 51cd877をもとに解説をします。

outputファイルの生成

調査ではparserの挙動が詳しく書いてあるoutputファイルというものをよく参照します。 building_ruby.mdのquick-start-guideを参考にして #4 のconfigureまでを実行します。 ちなみにconfigure--with-parser=parse.yを渡すとrubyの実行時に毎回optionを渡す必要がないので便利です。

configureを実行したらmake YFLAGS="--report=states,itemsets,lookaheads,solved" minirubyを実行します。 minirubyというのはrubyをビルドするのに使う機能が制限されたrubyです。 機能は制限されていますがparserの挙動を確認するさいなどには問題にならないので、ふだんは素早くビルドできるminirubyだけを使っています。

makeを上記のオプションをつけて実行するとカレントディレクトリにparse.outputというファイルが生成されます。 以降ではこのファイルを参照していきます。

問題が起きるケースと起きないケースを調べる

今回の例ではカッコの有無でsyntax errorになったりならなかったりすると言われています。 以下の2つのコードを例に調査をしていきます。

private def hello = puts("Hello") # ok
private def hello = puts "Hello" # SyntaxError

念のため前者のコードが問題なくパースされることと、後者でsyntax errorが起きることを確認します。

$ ./miniruby -e 'private def hello = puts("Hello")' # 問題なし
$ ./miniruby -e 'private def hello = puts "Hello"'
-e:1: syntax error, unexpected string literal, expecting 'do' or '{' or '('
private def hello = puts "Hello"
./miniruby: compile error (SyntaxError)

問題が発生している状態を特定する

それではsyntax errorになるコードを詳しくみていきましょう。 --dump=yオプションを指定して実際にパースを行っている様子を確認します。

$ ./miniruby --dump=y -e 'private def hello = puts "Hello"'
add_delayed_token:7644 (0: 0|0|0)
Starting parse
Entering state 0
Stack now 0
...
./miniruby: compile error (SyntaxError)

それなりの量のlogが出力されます。 最初に注目するのは実際にエラーが起きた箇所です。 Errorという文字列を探すか、syntax errorのメッセージである-e:1: syntax error, unexpected string literal, expecting 'do' or '{' or '('を探してみます。

...
Next token is token "string literal" (1.25-1.26: )
Reducing stack by rule 96 (line 3566):
   $1 = token "local variable or method" (1.20-1.24: puts)
-> $$ = nterm fcall (1.20-1.24: NODE_FCALL)
Entering state 274 # ここに注目すると現在のstateがわかる
Stack now 0 1 84 258 236 466 692 274
Next token is token "string literal" (1.25-1.26: ) # ここに注目すると次のトークンがわかる
-e:1: syntax error, unexpected string literal, expecting 'do' or '{' or '('
private def hello = puts "Hello"
Error: popping nterm fcall (1.20-1.24: NODE_FCALL)
...

これによるとstate 274という状態で次のトークンが"string literal"であるためエラーになったということがわかります。

問題が発生している状態を理解する

先ほど作成したoutputファイルを開いてState 274をさがしてみます。

State 274

  305 paren_args: • '(' opt_call_args rparen
  306           | • '(' args ',' args_forward rparen
  307           | • '(' args_forward rparen
  365 primary: fcall • brace_block # これに注目する
  407 k_do: • "'do'"
  501 method_call: fcall • paren_args # これに注目する
  509 brace_block: • '{' brace_body '}'
  510            | • k_do do_body k_end

    "'do'"  shift, and go to state 351
    '{'     shift, and go to state 352
    '('     shift, and go to state 256

    paren_args   go to state 353
    k_do         go to state 355
    brace_block  go to state 356

状態をみるときにはが右辺の途中にあるルールを確認します2。 State 274でいうと365 primary: fcall • brace_block501 method_call: fcall • paren_argsが該当します。 これはfcallまで読み終わって、次にbrace_blockもしくはparen_argsがくる状態であることを意味しています。

fcallが何かわからない場合は出力されたlogをfcallで検索します。

...
Reducing stack by rule 96 (line 3566):
   $1 = token "local variable or method" (1.0-1.7: private)
-> $$ = nterm fcall (1.0-1.7: NODE_FCALL)
...
Reducing stack by rule 96 (line 3566):
   $1 = token "local variable or method" (1.20-1.24: puts)
-> $$ = nterm fcall (1.20-1.24: NODE_FCALL)
...

2つのnterm fcallという文字列がみつかりますが、ここではエラーが起きた位置により近い、後者に注目します。 するとfcallはtoken "local variable or method" (1.20-1.24: puts)がreduceによって変化したものであるとわかります。

State 274とは今回のコードでいうとputs"Hello"の間にいる状態だとわかります。

private def hello = puts • "Hello"

もう一度outputファイルでState 274を確認してみましょう。 この状態は次にbrace_blockもしくはparen_argsがくる状態でした。 brace_blockは'{'もしくは"'do'"から始まります(k_doは"'do'"のこと)。 またparen_argsは必ず'('から始まります。 さらにこの状態においてはが右辺の最後におかれている項がないため、reduceが発生することはありません。 以上をまとめると、State 274では"'do'", '{', '('のみをshiftできることがわかります。 このことは2段落目のXXX shift, and go to state YYYというところでも確認できます。

State 274

  305 paren_args: • '(' opt_call_args rparen # paren_argsは必ず`'('`から始まる
  306           | • '(' args ',' args_forward rparen
  307           | • '(' args_forward rparen
  365 primary: fcall • brace_block
  407 k_do: • "'do'"
  501 method_call: fcall • paren_args
  509 brace_block: • '{' brace_body '}' # brace_blockは'{'もしくは"'do'"(k_do)から始まる
  510            | • k_do do_body k_end

    "'do'"  shift, and go to state 351 # ここをみるとState 274でshift可能なトークンの一覧がわかる
    '{'     shift, and go to state 352
    '('     shift, and go to state 256

    paren_args   go to state 353
    k_do         go to state 355
    brace_block  go to state 356

つまり以下のようなコードであれば、State 274ではエラーが発生しないということです3。 それぞれ{}ブロック、do endブロック、(...)による引数の指定を想定しています。

private def hello = puts • {...
private def hello = puts • do...
private def hello = puts • (...

このような状態で次にくるトークンが"Hello"というstring literalだったのでエラーが起きているということが確認できました。

問題の状態までの遷移を理解する

syntax errorが発生している状態だけでなく、そこに至るまでの遷移もまた様々な情報を与えてくれます。 もう一度logを見てみましょう。 するとエラーが発生する直前にStack now ...という文言があることに気が付きます。 これはざっくりいうと、State 274に至るまでに通ってきた道のりを示しています。

Entering state 274
Stack now 0 1 84 258 236 466 692 274
Next token is token "string literal" (1.25-1.26: )
-e:1: syntax error, unexpected string literal, expecting 'do' or '{' or '('

エラーが発生しているState 274の一つ前、State 692をoutputファイルで見てみます。

State 692

  ...
  279 def_endless_method_endless_arg: • defn_head f_opt_paren_args '=' endless_arg
  279                               | defn_head f_opt_paren_args '=' • endless_arg # これに注目
  280                               | • defs_head f_opt_paren_args '=' endless_arg
  ...

    fcall                           go to state 274
    ...

State 692というのはdef ... =までの処理が終わり、これからメソッド定義のbodyを解析しようとしている状態だとわかります。 そこからputs(fcall)を処理してState 274に遷移した結果、string literalが待ち構えておりsyntax errorになったということがわかりました。

ここまでの解説でLR parserの状態というものがよく分からないと感じた場合には、以下の記事を読むことをお勧めします。 yui-knk.hatenablog.com

問題が起きないケースを観察する

今度は問題が起きないケースを確認します。 先ほどと同様に--dump=yオプションを指定してコマンドを実行します。 カッコの有無に注目しているので'('で検索をします。 するとさきほどと同様にState 274に遷移し、そこで'('をシフトして次の状態 256へ遷移していることがわかります。

$ ./miniruby --dump=y -e 'private def hello = puts("Hello")'

...
Next token is token '(' (1.24-1.25: )
Reducing stack by rule 96 (line 3566):
   $1 = token "local variable or method" (1.20-1.24: puts)
-> $$ = nterm fcall (1.20-1.24: NODE_FCALL)
Entering state 274 # さきほどエラーを起こしていた状態
Stack now 0 1 84 258 236 466 692 274
Next token is token '(' (1.24-1.25: )
Shifting token '(' (1.24-1.25: )
Entering state 256 # '('を処理して次の状態に遷移できている
Stack now 0 1 84 258 236 466 692 274 256
...

またlogを追っていくとdef hello = puts("Hello")がdef_endless_method_endless_argへreduceされ、その後argにreduceされていることもわかります。

Reducing stack by rule 279 (line 4049):
   $1 = nterm defn_head (1.8-1.17: NODE_DEF_TEMP)
   $2 = nterm f_opt_paren_args (1.17-1.17: NODE_ARGS)
   $3 = token '=' (1.18-1.19: )
   $4 = nterm endless_arg (1.20-1.33: NODE_FCALL)
vtable_free:14858: cur_table(0x0000600002cbc320)
vtable_free:14858: cur_table(0x0000600002cbc340)
cmdarg_stack(pop): 1 at line 14888
cond_stack(pop): 0 at line 14889
-> $$ = nterm def_endless_method_endless_arg (1.8-1.33: NODE_DEFN)
Entering state 96
Stack now 0 1 84 258 96
Reducing stack by rule 281 (line 4049):
   $1 = nterm def_endless_method_endless_arg (1.8-1.33: NODE_DEFN)
-> $$ = nterm arg (1.8-1.33: NODE_DEFN)

その後の流れをみていくと最終的にcommand: fcall command_argsというルールによってdef hello = puts("Hello")privateの引数になっていることがわかります。

Reducing stack by rule 297 (line 4116):
   $1 = nterm arg (1.8-1.33: NODE_DEFN)
-> $$ = nterm value_expr_arg (1.8-1.33: NODE_DEFN)

Reducing stack by rule 298 (line 4116):
   $1 = nterm value_expr_arg (1.8-1.33: NODE_DEFN)
-> $$ = nterm arg_value (1.8-1.33: NODE_DEFN)

Reducing stack by rule 327 (line 4286):
   $1 = nterm arg_value (1.8-1.33: NODE_DEFN)
-> $$ = nterm args (1.8-1.33: NODE_LIST)

Reducing stack by rule 317 (line 4201):
   $1 = nterm args (1.8-1.33: NODE_LIST)
   $2 = nterm opt_block_arg (1.33-1.33: )
-> $$ = nterm call_args (1.8-1.33: NODE_LIST)

Reducing stack by rule 322 (line 4222):
   $1 = nterm $@13 (1.7-1.7: )
   $2 = nterm call_args (1.8-1.33: NODE_LIST)
cmdarg_stack(pop): 0 at line 4253
-> $$ = nterm command_args (1.7-1.33: NODE_LIST)

Reducing stack by rule 99 (line 3573):
   $1 = nterm fcall (1.0-1.7: NODE_FCALL) # これは private のこと
   $2 = nterm command_args (1.7-1.33: NODE_LIST)
-> $$ = nterm command (1.0-1.33: NODE_FCALL)

ルール arg: def_endless_method_endless_argを調べる

先ほどから出ているdef_endless_method_endless_argについて調べておきましょう。 これはparse.yに書かれています。が、parse.yを検索してもできません。 そういうときはlogをながめてparse.yの該当する行を探します。

Reducing stack by rule 281 (line 4049): # parse.yの4049行目に定義されているルールという意味
   $1 = nterm def_endless_method_endless_arg (1.8-1.33: NODE_DEFN)
-> $$ = nterm arg (1.8-1.33: NODE_DEFN)

実際に4049行目をみてみるとdef_endless_method(endless_arg)と書かれています。 これはparameterized ruleという機能で共通した構造などを再利用できる仕組みです。 endless_argに引数に渡すことでルール名と引数名を結合した名前のルール(def_endless_method_endless_arg)が作られます。

3918: arg: ...
4049:    | def_endless_method(endless_arg)

parse.yの2947行にparameterized ruleの定義が書かれています。 引数として受け取ったbodystmtをメソッド定義のbodyとし、通常のメソッド定義(def m = ...)とシングルトンメソッド定義(def self.m = ...)用のルールを生成します。

%rule def_endless_method(bodystmt) <node>
                : defn_head[head] f_opt_paren_args[args] '=' bodystmt
                | defs_head[head] f_opt_paren_args[args] '=' bodystmt
                ;

ここまでのまとめ

  • 正常に処理されるコードではputs("Hello")がendless_argになり、def hello = puts("Hello")がdef_endless_method_endless_argになっている
  • syntax errorがおきるコードではputs "Hello"がendless_argになることができないのでエラーになっている
  • def_endless_method_endless_argはparameterized ruleを利用して作られたルールでparse.yのdef_endless_method(endless_arg)に該当する

2. 似たような正常なコードを分析する

private def hello = puts "Hello"が通るように文法ファイルを修正していくにあたって、似たような正常なコードがなぜパースできているのか調べることも重要です。 ここでは似たようなコードであるdef hello = puts "Hello"がどのようにパースされているか確認します。

private def hello = puts "Hello" # SyntaxError
def hello = puts "Hello" # OK

--dump=yオプションをつけて実行し、logを眺めます。 =という特徴的なトークンがあるので'='を検索してみると、def_endless_method_endless_commandによるreduceが目にとまります。

$ ./miniruby --dump=y -e 'def hello = puts "Hello"'
...
Reducing stack by rule 58 (line 3417):
   $1 = nterm defn_head (1.0-1.9: NODE_DEF_TEMP)
   $2 = nterm f_opt_paren_args (1.9-1.9: NODE_ARGS)
   $3 = token '=' (1.10-1.11: )
   $4 = nterm endless_command (1.12-1.24: NODE_FCALL)
vtable_free:14858: cur_table(0x0000600000cbf740)
vtable_free:14858: cur_table(0x0000600000cbf760)
cmdarg_stack(pop): 0 at line 14888
cond_stack(pop): 0 at line 14889
-> $$ = nterm def_endless_method_endless_command (1.0-1.24: NODE_DEFN)
...

parse.yの3417行目をみるとdef_endless_method(endless_arg)によく似たdef_endless_method(endless_command)というルールが見つかります。

3415: command_asgn    : asgn(command_rhs)
3416:                 | op_asgn(command_rhs)
3417:                 | def_endless_method(endless_command)
3418:                 ;

ここまでに出てきたいくつかのルールについて調べておきます。

まずendless_arg(4063行)です。 これは簡単にいえばargと後置rescueとnotを許しているというルールです。

endless_arg : arg %prec modifier_rescue // 要するにarg
            | endless_arg modifier_rescue after_rescue arg // xxx rescue yyy と書くことができる
            | keyword_not '\n'? endless_arg // not xxx と書くことができる
            ;

例えば以下のコードに対応します。

def hello = puts("Hello")
def hello = puts("Hello") rescue nil
def hello = not puts("Hello")

つぎにendless_command(3420行)です。 さきほどのargがcommandというものに置き換わっていると言えます。

endless_command : command // 要するにcommand
                | endless_command modifier_rescue after_rescue arg // xxx rescue yyy と書くことができる
                | keyword_not '\n'? endless_command // not xxx と書くことができる
                ;

rubyの文法でcommandというと、それはカッコなしのメソッド呼び出しのことを意味します。 例えば以下のコードに対応します。

def hello = puts "Hello"
def hello = puts "Hello" rescue nil
def hello = not puts "Hello"

最後にcommand_asgn(3415行)です。 これは右辺がcommandである代入(assignment)を意味しています。 例えば以下のコードに対応します。

x = puts "hello"
a[0] = puts "hello"
def hello = puts "Hello"

ここで気をつけるべきはcommand_asgnがstmtの一部でありargの一部でないということです。

3293: stmt: ...
3384:     | command_asgn

Rubyの文法にはstmt, expr, arg, primaryという主要な4つの要素がありますが、そのうちm(arg1, arg2 ...)のように引数の要素として書くことができるのはargとprimaryの2つです。 そのためstmtであるdef hello = puts "Hello"はメソッド呼び出しの引数にすることができません。

一方でdef_endless_method(endless_arg)はargの一部なのでメソッド呼び出しの引数にすることができます。

3918:  arg: ...
4049:     | def_endless_method(endless_arg)

これらの4つの要素の詳細はUnderstanding Ruby Grammar Through Conflictsの18ページから23ページをご覧ください。

ここまでのまとめ

  • def hello = puts "Hello"def_endless_method(endless_command)というルールに対応している。これはstmtに相当する
  • def hello = puts("Hello")def_endless_method(endless_arg)というルールに対応している。これはargに相当する
  • argは引数の要素になるが、stmtは引数の要素にならないためprivate def hello = puts "Hello"と書くことができない

3. 文法ファイルをいじってみる

argを拡張してみる

privateの引数になってほしいdef hello = puts "Hello"がstmtであることが問題だとわかりました。 ではdef hello = puts "Hello"をargにすることで問題が解決しないか試してみます。 具体的には現在command_asgnとして定義されているdef_endless_method(endless_command)をargに移動してみます。

diff --git a/parse.y b/parse.y
index 765b4bdfd0..dc592ae7d8 100644
--- a/parse.y
+++ b/parse.y
@@ -3414,7 +3414,6 @@ stmt              : keyword_alias fitem {SET_LEX_STATE(EXPR_FNAME|EXPR_FITEM);} fitem

 command_asgn   : asgn(command_rhs)
                 | op_asgn(command_rhs)
-                | def_endless_method(endless_command)
                 ;

 endless_command : command
@@ -4047,6 +4046,7 @@ arg               : asgn(arg_rhs)
                     /*% ripper: defined!($:4) %*/
                     }
                 | def_endless_method(endless_arg)
+                | def_endless_method(endless_command)
                 | ternary
                 | primary
                 ;

parse.yを変更したうえで再度minirubyをビルドします。 すると31個のコンフリクトが検出されビルドに失敗します。

$ make YFLAGS="--report=states,itemsets,lookaheads,solved" miniruby
...
generating parse.c
shift/reduce conflicts: 31 found, 0 expected
make: *** [parse.c] Error 1
make: *** Deleting file `parse.c'

outputファイルを確認してみます。 コンフリクトが発生したときにはファイルの先頭にどの状態でコンフリクトが発生しているか表示されます。

State 238 conflicts: 26 shift/reduce
State 240 conflicts: 1 shift/reduce
State 244 conflicts: 1 shift/reduce
State 251 conflicts: 1 shift/reduce
State 690 conflicts: 1 shift/reduce
State 693 conflicts: 1 shift/reduce

コンフリクト数の多いState 238は後回しにしてState 240をみてみます。

State 240

  327 args: arg_value •  ["end-of-input", "'rescue'", "'ensure'", "'end'", "'then'", "'elsif'", "'else'", "'when'", "'in'", "'do' for condition", "'do' for block", "'do' for lambda", "'and'", "'or'", "'if' modifier", "'unless' modifier", "'while' modifier", "'until' modifier", "'rescue' modifier", "dummy end", "**", "<=>", "==", "===", "!=", ">=", "<=", "&&", "||", "=~", "!~", "..", "...", "<<", ">>", "=>", "{ arg", "'}'", tLAMBEG, '?', ':', '>', '<', '|', '^', '&', '+', '-', '*', '/', '%', '}', '\n', ',', ')', ']', ';']
  783 assoc: arg_value • "=>" arg_value

    "=>"  shift, and go to state 465

    ...
    "=>"                  reduce using rule 327 (args)
    ...

State 240では=>をshiftするという動作と、=>であればreduceするという動作が定義されていて、たしかにshift/reduceコンフリクトが発生しています。

ではこのState 240とは具体的にどのような状況なのでしょうか? この状態にはargsとassocという2つのルールが関係が関係しています。

327 args: arg_value •
783 assoc: arg_value • "=>" arg_value

argsというのはargが複数あるということを意味していそうです。 実際にparse.yにおける定義をみるとargsは1つのargやargを,でつなげたものを指しています。

args: arg_value // 1つのarg
    | arg_splat // 1つの `*` や `*a`
    | args[non_last_args] ',' arg_value // argを ',' でつなげたもの
    | args[non_last_args] ',' arg_splat // argの後ろに `*` や `*a` をおいたもの
    ;

一方でassocというのはarg => argという形をしているため引数におけるハッシュを表していそうです。 parse.yにおける定義をみるとほかにもlabel: arg**argを含んでいることがわかります。

assoc: arg_value tASSOC arg_value // `arg => arg`
     | tLABEL arg_value // `sym: arg`
     | tLABEL // `sym: `
     | tSTRING_BEG string_contents tLABEL_END arg_value // `"str": arg`
     | tDSTAR arg_value // `**arg`
     | tDSTAR // `**`
     ;

このことからState 240では... arg => argというコードを解析していて、... argまでを引数全体と捉えるケースとarg => argを引数の1つと捉えるケースが混ざってしまっているのではないかと想像できます。

今回の変更でcommand(カッコなしのメソッド呼び出し)をbodyとするendless method definitionを引数に書けるようにしたことを踏まえると、次のようなコードが2通りに解釈できるようになってしまったのではないかと思いつきます。

ひとつめはcmd 1までを一区切りとしてdef m2 = cmd 1というメソッドを定義したうえで、そのメソッド定義をkey、2valueとしてm1メソッドの呼び出しに渡しているという解釈です。

もうひとつはcmd 1 => 2までを一区切りとしてdef m2 = cmd 1 => 2というメソッドを定義したうえで、そのメソッド定義をm1メソッドの呼び出しに渡しているという解釈です。

m1(def m2 = cmd 1 => 2)

# Case 1
m1(def m2 = cmd 1 => 2)
                ^~ args
   ^~~~~~~~~~~~~~ arg (endless method definition)

m1(def m2 = cmd 1 => 2)
                     ^~ value
   ^~~~~~~~~~~~~~ key

# Case 2
m1(def m2 = cmd 1 => 2)
                ^~~~~~ assoc
            ^~~~~~~~~~ command
   ^~~~~~~~~~~~~~~~~~~ endless method definition

ここまでの説明はコンフリクトを起こしている状態をもとに、こんな感じのコードが問題になるんじゃないかなという推測をしたにすぎません。 実際にこのコードが問題となっているState 240まで到達するか確認しておきましょう。 output ファイルを片手にState 0から順に状態遷移を追っていくと、State 256で1(arg_value)を処理したのちにState 240へ遷移することがわかります。

State 0
    $@1               go to state 1

State 1
    fcall             go to state 83  // m1

State 83
    $@13              go to state 256 // m1

State 256
    "(" shift,    and go to state 230 // m1(

State 230

    defn_head         go to state 79  // m1(def m2

State 79
    f_opt_paren_args  go to state 342 // m1(def m2

State 342
    '=' shift,    and go to state 589 // m1(def m2 =

State 589
    fcall             go to state 83  // m1(def m2 = cmd

State 83
    $@13              go to state 256 // m1(def m2 = cmd

State 256
    arg_value         go to state 240 // m1(def m2 = cmd 1

State 240
    今注目している状態

コンフリクトしているその他の状態についてもみておきましょう。 State 690はState 240と同様に=>でコンフリクトが発生しています。 State 240がargs: arg_valueであったのに対して、State 690はargs: args ',' arg_valueである点が異なります。

329 args: args ',' arg_value •
783 assoc: arg_value • "=>" arg_value

複数の引数がcmdに渡されているコードを考えてみるとよいでしょう。

m1(def m2 = cmd 1, 2 => 3)

# Case 1
m1(def m2 = cmd 1, 2) => 3)

# Case 2
m1((def m2 = cmd 1, 2 => 3))

State 244, 251, 693をみると,でもコンフリクトが起きていることがわかります。

State 244

  317 call_args: args • opt_block_arg
  319          | args • ',' assocs opt_block_arg
  325 opt_block_arg: • ',' block_arg
  326              | • none
  329 args: args • ',' arg_value
  330     | args • ',' arg_splat
  811 none: ε • 

    ','  shift, and go to state 466

    ','                   reduce using rule 811 (none)

次のようなコードを考えてみると2cmdの第2引数とする解釈と、m1の第2引数とする解釈の二つを考えることができます。 そのためState 244では,に対してコンフリクトが発生してしまいます。

m1(def m2 = cmd 1, 2)

# Case 1
m1((def m2 = cmd 1), 2)

# Case 2
m1((def m2 = cmd 1, 2))

ここまで理解が深まればState 238も見当がつくようになります。 State 238では+==など様々な二項演算子でコンフリクトが発生しています。

State 238

  243 range_expr_arg: arg • ".." arg
  244               | arg • "..." arg
  245               | arg • ".."
  246               | arg • "..."
  250 arg: arg • '+' arg
  ...
  264    | arg • "==" arg
  ...
  284 ternary: arg • '?' arg option_'\n' ':' arg
  288 relop: • '>'
  289      | • '<'
  290      | • ">="
  291      | • "<="
  292 rel_expr: arg • relop arg
  297 value_expr_arg: arg •

    ...
    "=="   shift, and go to state 367
    ...
    '+'    shift, and go to state 387
    ...

    ...
    "=="                  reduce using rule 297 (value_expr_arg)
    ...
    '+'                   reduce using rule 297 (value_expr_arg)
    ...

    relop  go to state 392

以下のようなコードを考えてみましょう。 +に注目して左辺がdef m1 = cmd 1であると解釈することもできますし、左辺は1であると解釈することもできます。 ちなみにcmd 1はcommandでありargではないので、(cmd 1) + 2がメソッド定義のbodyであるという解釈は成り立ちません。

def m1 = cmd 1 + 2

# Case 1
(def m1 = (cmd 1)) + 2

# Case 2
def m1 = cmd(1 + 2)

# Case 3 こうはならない
def m1 = ((cmd 1) + 2)

コンフリクトの解消を考える

これらのコンフリクトを解消していくにあたって、どのように解消されるのが望ましいか考えてみましょう。

あくまで私見ですが、二項演算子=>についてはshift側に倒すという解釈のほうが望ましいと思います。 つまりdef m1 = cmd 1 + 2ではメソッド定義のbodyがcmd(1 + 2)と解釈され、m1(def m2 = cmd 1 => 2)ではdef m2 = cmd 1 => 2全体で1つと解釈されるとします。

一方で判断が難しいのが,におけるコンフリクトです。 このケースも二項演算子=>と同様にshift側、つまりm1((def m2 = cmd 1, 2))に解釈されてほしいと思うかもしれません。 しかしdef m2 = cmd 1, 2をargにするということはその前後に引数を置くことができるようになるということもあります。

m1(def m2 = cmd 1, 2) # 仮に m1((def m2 = cmd 1, 2)) と解釈するとする
m1(def m2 = cmd 1, 2, 3) # m1((def m2 = cmd 1, 2, 3)) と解釈される
m1(1, 2, def m2 = cmd 1, 2) # m1(1, 2, (def m2 = cmd 1, 2)) と解釈される

この解釈はdef ...の前には任意の個数の引数をおくことができるが、一度def m = cmd ...をおくとそこから先の引数はメソッド定義のbodyに吸収されるという解釈になります。 言い換えれば引数の最後にだけbodyがcommand形式のendless method definitionを書くことができる文法ということになります。

一方でreduce側に倒した場合はメソッドのbodyであるcmdには1つまでしか引数を渡せないということになります。

m1(def m2 = cmd 1, 2) # m1((def m2 = (cmd 1)), 2) と解釈される

どちらの文法がよいかというのは難しい問いだと思います。

別のアプローチ: 引数全体の構造に変更を加える

def_endless_method(endless_command)をargにするというのは、個々の引数を拡張するアプローチといえます。 ここで他のアプローチとして、引数全体の構造を変えるというアプローチを考えてみましょう。 Rubyにおいてはcommandが唯一の引数であるときは、commandを引数に書くことができます。

cmd1 cmd2 1, 2, a => b, c => d # OK
cmd1(cmd2(1, 2, a => b, c => d)) # こう解釈される

cmd1 1, 2, cmd2 1, 2, a => b, c => d # commandを含んでいるが引数が1つではないのでSyntaxError

これを援用してdef_endless_method(endless_command)が唯一の引数であれば書くことができるという文法を考えてみます。

m1(def m2 = cmd 1, 2) # 仮に m1((def m2 = cmd 1, 2)) と解釈される
m1(def m2 = cmd 1, 2, 3) # m1((def m2 = cmd 1, 2, 3)) と解釈される
m1(1, 2, def m2 = cmd 1, 2) # SyntaxError

これはdef m = ...を他のメソッドの引数にするときはprivate def m = ...という形式であることがほとんどであろうという割り切りに基づいています。

private def m = cmd 1, 2, 3 # こう書くことはあっても
private :m1, def m2 = cmd 1, 2, 3 # こうは書かないだろう
private def m1 = cmd 1, 2, 3, def m2 = cmd 1, 2, 3 # こうも書かないだろう

private def m1 = cmd 1, 2, 3 # 複数のメソッド定義をprivateに渡したいのであれば分けて書く
private def m2 = cmd 1, 2, 3

parse.yを眺めたり、cmd1 cmd2 1, 2, 3がパースされる様子を観察すると、call_argsというルールが引数のまとまりを管理していることがわかります。 なので以下のようなパッチをいれてみましょう。

diff --git a/parse.y b/parse.y
index 765b4bdfd0..b01bd8b2ed 100644
--- a/parse.y
+++ b/parse.y
@@ -4198,6 +4198,11 @@ call_args        : value_expr(command)
                         $$ = NEW_LIST($1, &@$);
                     /*% ripper: args_add!(args_new!, $:1) %*/
                     }
+                | def_endless_method(endless_command)
+                    {
+                        $$ = NEW_LIST($1, &@$);
+                    /*% ripper: args_add!(args_new!, $:1) %*/
+                    }
                 | args opt_block_arg
                     {
                         $$ = arg_blk_pass($1, $2);

make YFLAGS="--report=states,itemsets,lookaheads,solved" minirubyを実行しても特にコンフリクトは発生しません。 そのためdef_endless_method(endless_command)が唯一の引数であれば書くことができるという文法は一意に解釈が定まることがわかります。

またcall_argsの定義が以下のようになっていることから、commandが唯一の引数であるときにcommandを引数に書くことができるという文法規則も同じ場所で定義されていることがわかります。

call_args : value_expr(command)
          | ...

コンフリクトのない拡張が見つかったので、新しく定義した文法を含むスクリプトがどのように解釈されるかいくつか確認しておきましょう。

private :m, def hello = puts "Hello" # SyntaxError

private def hello = puts "Hello", "World" # obj.m cmd 1, 2 の解釈と同じ

private def hello = puts "Hello" do expr end # obj.m cmd 1, 2 do expr endの解釈と同じ

1つめのようにendless method definitionの前に他の引数が指定されているケースでは意図したとおりにsyntax errorが発生します。

2つめのコードではprivate def hello = puts("Hello", "World")というようにputsに2つの引数が渡されていると解釈されます。 これはobj.m cmd 1, 2obj.m cmd(1, 2)と解釈されるのと一致しています。

3つめのコードではprivate (def hello = puts "Hello") do expr endというようにブロックはprivateメソッド呼び出しのほうにかかります。 これはobj.m cmd 1, 2 do expr endobj.m (cmd 1, 2) do expr endと解釈されるのと一致しています。

このようにdef_endless_method(endless_command)とcommandの解釈を揃えておくことで、Rubyの文法全体の学習コストを抑えることができるはずです。 実際のチケットでもこのアプローチを提案しています。

ここまでのまとめ

  • private def hello = puts "Hello"と書けるようにするために、まずdef_endless_method(endless_command)をstmtからargに移動させてみた
  • その結果,=>二項演算子でコンフリクトが発生した。原因はcommandに渡す引数がどこで終わるかわからないということだった
  • コンフリクトのなかには文法のデザイン上決定が難しいものもあることがわかった
  • 引数の要素(arg)に書くことができるようにするという方針以外に、def_endless_method(endless_command)が唯一の引数であるときだけ許可するという文法を考えた

まとめ

今回はFeature 17398というチケットで議論されているprivate def hello = puts "Hello"がsyntax errorになるという点についてみてきました。

はじめにparserの内部状態を表したoutputファイルの作成をしました。 そしてsyntax errorが発生するスクリプトとしないスクリプトをパースさせてみて、前者がパースできない理由を調べました。

private def hello = puts("Hello") # ok
private def hello = puts "Hello" # SyntaxError

つぎにdef hello = puts "Hello"というsyntax errorが発生しないスクリプトを調べ、関連する構文規則を把握しました。

ここまで調べた結果をもとに、def_endless_method(endless_command)をargにすることで引数として渡せるように変更を試みました。 ,=>二項演算子で発生するコンフリクトを詳しく調べたところ、とくに,についてはどのように解釈してあげるとうれしいか判断が難しい問題であることがわかりました。 汎用的にargにするというアプローチとは別の方法として、private def hello = puts "Hello"というケースに絞った方法を考えて実装しました。

今回のお話を通じて、与えられたコードと関連する文法ルールの探し方などparse.yのデバッグ方法がわかったと思います。 いろいろと調べることが多くて大変そうだなと感じたかもしれませんが、何度もparse.yをいじっているとRubyの文法構造が頭に入ってきますし、よくあるコンフリクトについても知見が溜まってきます。 次回はBug 21097を題材に、演算子の優先順位やendless method definitionのbodyに何が書けるとよいのかといったお話をする予定です。


  1. 正確にはRuby 3.1からサポートされている
  2. いわゆるカーネル
  3. private def hello = puts do exprのようにインプットが不完全であれば、この状態から遷移していった先の状態でエラーが発生することはある



以上の内容はhttps://yui-knk.hatenablog.com/entry/2025/09/12/110029より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14