ANTLRメモ - パーサーの字句・構文エラー自動復帰

with Copilot

User: LPARENTの手前、 PREPROCESSOR_SET_COND までしか入力がない場合構文エラーを期待しているのですが、実際は通過してしまいます。 USE_CODE_IF の場合では LPARENT がない時点で構文エラーになってくれるのですが、何が違うのかがわかりません。教えてください。

GitHub Copilot: 原因切り分けとして「SET_CONDITION が本当に PREPROCESSOR_SET_COND として字句解析されているか(それとも IDENTIFIER 扱いになっているか)」と、「ANTLR の既定エラー回復で“不足トークン自動挿入”されて見かけ上通っているか」を確認します。まずレキサー側(KSPExLexer.g4 含む)を読みます。

の読み取り

User: LPARENTの手前、 PREPROCESSOR_SET_COND までしか入力がない場合構文エラーを期待しているのですが、実際は通過してしまいます。 USE_CODE_IF の場合では LPARENT がない時点で構文エラーになってくれるのですが、何が違うのかがわかりません。教えてください。

GitHub Copilot: 差分が出る箇所を絞るため、今度はパーサーの入口と preprocessor の到達経路を確認します。これで「本当にその規則で読まれているのか」と「EOF 時の回復余地がどこまであるか」が見えます。

の 1 から 80 行を読み取り

正規表現 preprocessorDefine|preprocessorIfdefine|compilationUnit|statement (**/KSPParser.g4) を検索し、結果は 18 件でした

字句側で差がないなら、次は「その2語が他の規則から別解釈される余地があるか」を見ます。キーワード名の重複や別定義があれば、そこで挙動が分かれます。

正規表現 SET_CONDITION|USE_CODE_IF|PREPROCESSOR_SET_COND|PREPROCESSOR_CODE_IF (**/*.{g4,java,kt,ts,cs}) を検索し、結果は 62 件でした

生成済みパーサーの該当メソッドを見ると、ANTLR が実際にどこで match() しているかが分かります。ここで差がなければ、文法差ではなく呼び出し側のエラー処理差を疑うのが筋です。

の 1268 から 1308 行を読み取り

の 1446 から 1498 行を読み取り

結論だけ先に言うと、貼ってある grammar の範囲では PREPROCESSOR_SET_COND と USE_CODE_IF に、LPARENT 手前で挙動差が出る要素はありません。

根拠は3つあります。

  • レキサーでは両方とも単なる固定キーワードです。KSPLexer.g4 と KSPLexer.g4
  • パーサーでも、LPARENT まではほぼ同型です。KSPParser.g4 と KSPParser.g4
  • 生成済み Java を見ても、どちらも同じように match(LPARENT) を呼んでいます。KSPParser.java と KSPParser.java

なので、SET_CONDITION だけが LPARENT なしで「通過してしまう」理由は、文法そのものではなく、たぶん呼び出し側の解析方法です。いちばんありがちなのは ANTLR の既定エラー回復です。ANTLR は不足トークンがあっても即失敗せず、回復して parse を継続します。つまり「parse メソッドが正常 return した」ことと「構文エラーがなかった」ことは同じではありません。エラーリスナを見ていないと、回復後に通ったように見えます。

もう1点、この grammar ではトップレベルの入口 KSPParser.g4 の compilationUnit は preprocessor を受けません。preprocessor が許されるのは block 配下だけです。KSPParser.g4 と KSPParser.g4 そのため、実際に何を入口規則として呼んでいるかで、エラーの出方はかなり変わります。もしエディタ側が compilationUnit ではなく statement や preprocessor を直接叩いているなら、そこで見えている挙動差は十分ありえます。

USE_CODE_IF だけエラーが見えやすい理由としては、後続に block と END_USE_CODE があるぶん、既定回復でごまかし切れず、結果として診断に出やすい、という説明がいちばん自然です。ただし重要なのは、「LPARENT 手前の grammar 差」ではない、という点です。

厳密に「LPARENT が無ければ必ず失敗」にしたいなら、呼び出し側で次を確認してください。

  1. 入口規則を preprocessorDefine EOF や statement EOF のように EOF まで強制しているか
  2. syntaxError を確実に拾っているか
  3. 失敗即終了にしたいなら BailErrorStrategy を使っているか

もし解析を呼んでいる Java 側のコードも見せてもらえれば、どこで「回復したのに通った扱い」になっているかはかなり具体的に絞れます。

User: トップレベルではなく、block内での定義は意図的にしています。 一方、呼び出し側のコードではリスナー登録してハンドリングを行っています。

GitHub Copilot: 意図が block 内である点と、呼び出し側でリスナー登録している点を踏まえても、結論は変わりません。違いは LPARENT の直前にはなく、規則の後半と ANTLR の既定エラー回復にあります。

KSPParser.g4 の preprocessorDefine と KSPParser.g4 の preprocessorIfdefine は、LPARENT を要求するところまでは同型です。生成済みコードでも KSPParser.java と KSPParser.java のどちらも同じ match(LPARENT) です。つまり、SET_CONDITION だけが「LPARENT がなくても文法上通る」わけではありません。

実際に差を生んでいるのはその先です。

  • preprocessorDefine は ) で終わる短い規則です。KSPParser.g4
  • preprocessorIfdefine は ) の後に block と END_USE_CODE まで必要です。KSPParser.g4

ANTLR の DefaultErrorStrategy は、不足トークンを補って先へ進もうとします。短い preprocessorDefine は「不足を補って規則を閉じる」ことが比較的しやすく、結果として Parse が最後まで返ってきやすいです。対して preprocessorIfdefine は後続要求が長いので、回復してもその後で破綻しやすく、エラーとして見えやすくなります。差は PREPROCESSOR_SET_COND と PREPROCESSOR_CODE_IF の違いではなく、規則全体の回復しやすさの違いです。

加えて、提示された Parse 実装では、エラーを検出しても処理を止めていません。リスナー登録はされていますが、Parse の最後で無条件に CST から AST を作っています。つまり、

  • リスナーは通知する
  • しかし Parse はそのまま AST 生成まで進む

という実装です。なので「構文エラーがあったのに API としては通過したように見える」状態が普通に起きます。特に preprocessorDefine のような短い規則だと、その見え方が強くなります。

見るべきポイントは次の3つです。

  1. parserErrorListener.HasError か parser.NumberOfSyntaxErrors を Parse 後に見ているか
  2. エラーがあった場合に AST 生成を中断しているか
  3. 既定回復を許したくないなら ErrorHandler を BailErrorStrategy に変えているか

要するに、今回の違いは「文法上 SET_CONDITION だけ甘い」のではなく、「既定回復で補完しやすい短い規則か、補完後もまだ長く続く規則か」の差です。挙動を揃えたいなら、リスナー登録だけでなく、Parse 後に HasError を見て失敗扱いにするか、最初から BailErrorStrategy で即失敗にするのが確実です。

必要なら次に、あなたの Parse 実装に対して「どこで失敗扱いにすべきか」を最小変更で具体的に示します。

User: あなたの回答を元に試してみました。確かに ANTLR 側から例外・リスナー通知はないけど、 NumberOfSyntaxErrors が 1 となりました。 NumberOfSyntaxErrorsの値も考慮して呼び出し側へ伝搬していくことで解消できそうです。

GitHub Copilot: その理解で合っています。

今回の本質は、ANTLR が preprocessorDefine を既定エラー回復で完走させているため、

  • 例外は投げない
  • リスナーも条件次第では期待通りに見えない
  • しかし parser.NumberOfSyntaxErrors は増える

という状態になっていた、ということです。なので呼び出し側で parser.NumberOfSyntaxErrors を見て失敗扱いにするのは妥当です。提示された Parse 実装なら、parser.compilationUnit() の直後で lexerErrorListener.HasError、parserErrorListener.HasError、parser.NumberOfSyntaxErrors のいずれかをチェックして、AST 変換に進まないようにすれば挙動は安定します。

実務上は NumberOfSyntaxErrors を最終判定に含めるのが安全です。必要なら次は、「既定回復は維持したままエラー伝搬する形」と「BailErrorStrategy で即失敗に寄せる形」の2案を、あなたの C# 実装に合わせて短く整理できます。

User: 可能な限りフォールバックして文法エラーで止めずに意味解析フェーズでのエラーの検出を配慮していたのですが、それが裏目に出た形になりました。このようにフォールバックを意識したた意味解析でもASTが不揃い前提で評価が必要そうですね(最悪文法解析フェーズと重複しそうですが・・・)

GitHub Copilot: その認識は妥当です。回復重視の構文解析を採るなら、意味解析側は「AST が完全に整っている」という前提を捨てる必要があります。今回のように parser が回復して CST/AST を返す以上、意味解析はどうしても不揃い入力耐性を持つ設計になります。

実務的には、全部を二重実装する必要はありません。分けると整理しやすいです。

  1. 構文解析フェーズの責務 不足トークン、閉じ忘れ、規則不成立など「形の破綻」を報告する。回復はしてよいが、NumberOfSyntaxErrors などで「不完全な AST」であることを明示する。

  2. AST 生成フェーズの責務 欠損ノードを null のまま放置するより、ErrorNode 相当や MissingXxxNode のようなプレースホルダを作る方が後段は扱いやすいです。どこが欠けたかをノードに残せるからです。

  3. 意味解析フェーズの責務 前提条件チェックを先に行い、必要な子ノードが欠けている場合はその枝だけ解析を打ち切る。つまり「評価不能」と「意味エラー」を分けるのが重要です。ここを分けないと、構文エラー起因の二次エラーが大量発生します。

重複を抑えるコツは、「文法エラーそのもの」を意味解析で再検出しないことです。意味解析では次のように扱うのが無難です。

  • 必須ノード欠落: 追加の意味エラーは出さず、その部分はスキップ
  • 型不一致や未宣言参照など、構文が多少壊れていても意味的に判定可能なものだけ診断
  • 既に構文エラーが出ている周辺では、派生エラーを抑制

つまり意味解析は「壊れた AST を受け入れる」が、「壊れていること自体を再度責めない」という設計にするとノイズが減ります。

今回のケースなら、呼び出し側で少なくとも「構文エラーあり」のフラグを AST か解析コンテキストに持たせて、意味解析側で参照できるようにするのが良いです。そうすると、

  • 構文エラーがあっても解析継続はできる
  • ただし意味解析は防御的に動ける
  • 二次エラーの抑制条件も書きやすい

という形にできます。

設計としては次の2案が現実的です。

  1. AST は常に返すが、ParseResult に SyntaxErrorCount と IsRecovered を持たせる
  2. AST ノード自体に Missing や IsInvalid を持たせる

個人的には 1 を基本にして、局所的に欠損が重要な箇所だけ 2 を併用するのがバランスが良いです。AST 全体を nullable だらけにするより、解析コンテキストで「この木は回復済み」と持つほうが意味解析のコードが崩れにくいです。

必要なら次に、C# での ParseResult の形と、意味解析での二次エラー抑制の最小パターンを具体例で示します。

User: ANTLRが字句・構文解析で自動復帰する基準ってありますか?それが分かればASTの評価で特定のノードに絞り込みはできそうなのですが。

GitHub Copilot: あります。ANTLR4 の既定復帰は、ざっくり言うと「今この場で最小限の補正で先へ進めるか」を順に試します。基準は人間向けのルールというより、現在位置の期待トークン集合と、その後続で許されるトークン集合です。

今回の grammar でいうと、KSPParser.g4 の preprocessorDefine でも KSPParser.g4 の preprocessorIfdefine でも、LPARENT を読む場面では同じ復帰ロジックが働きます。違いは「その後の規則長」と「EOF まで含めた後続集合」によって、復帰後に完走しやすいかどうかです。

ANTLR の既定復帰は主に次の順で考えると理解しやすいです。

  1. 単一トークン削除 現在のトークンが不要で、1個捨てた次のトークンが期待トークンなら、それを採用します。 例: 期待が LPARENT で、入力が 余計な記号 LPARENT なら、余計な記号を捨てて続行します。

  2. 単一トークン挿入 今のトークン自体は期待ではないが、「本来ここに期待トークンが1個あれば、その次に今のトークンが合法になる」なら、不足トークンを仮想挿入して続行します。 今回のように PREPROCESSOR_SET_COND の直後で EOF になった場合、LPARENT を仮想挿入して進める余地があります。だから例外も出ず、parse も返るのに、NumberOfSyntaxErrors だけ増える、ということが起きます。

  3. 同期復帰 上のどちらも無理なら、後続集合に入るまでトークンを読み飛ばします。ループや選択肢の境界でよく使われます。

  4. ルール脱出を伴う復帰 その規則内ではもう立て直せないと判断すると、呼び出し元に戻れる位置まで飛ばします。

実装上の見分け方としては、復帰の“型”ごとに AST/CST への現れ方が少し違います。

  • 単一トークン挿入 不足トークンは仮想トークンです。Parse tree 上では ErrorNode 相当として載ることがあります。これが取れれば「ここは missing LPARENT だった」とかなり絞れます。
  • 単一トークン削除 消された余計なトークン側が ErrorNode としてぶら下がることがあります。
  • 大きな同期復帰 その部分の子ノードが欠けたり、想定より短い subtree になります。

ただし、ここで重要なのは「ANTLR は自動復帰した理由を高水準の意味で保持してくれない」ことです。持っているのは、期待集合と、挿入・削除・読み飛ばしの結果です。なので AST 評価で特定ノードに絞り込むなら、次の方針が現実的です。

  1. NumberOfSyntaxErrors だけでなく、SyntaxError コールバックの offendingSymbol とメッセージを蓄積する missing LPARENT at EOF のような情報はここに出ます。

  2. CstConverterVisitor で ErrorNode を拾って AST に移す これがいちばん効きます。どの構文要素のどの位置で回復が起きたかを、意味解析に渡せます。

  3. AST ノードに IsRecovered や HasSyntaxError を持たせる たとえば preprocessorDefine ノードだけにフラグが立てば、意味解析はそのノードを厳密評価せずスキップできます。

  4. 二次エラー抑制を入れる 構文回復済みノード配下では、未定義シンボルや型不一致のような派生診断を抑えるとノイズが減ります。

今回のケースに即して言うと、PREPROCESSOR_SET_COND の直後に EOF なら、ANTLR は 「ここに LPARENT が足りない」 「さらに symbol や RPARENT も足りない」 という不足挿入を段階的に行って、短い規則を閉じにいく可能性があります。だから preprocessorDefine は“通ったように見えやすい”です。一方で preprocessorIfdefine はその後に block と END_USE_CODE まであるので、復帰後も破綻しやすく、表面化しやすい、という差です。

絞り込みの実務的な落としどころはこれです。

  1. 構文エラー情報を単なる件数ではなく、位置付きで保持する
  2. ErrorNode または missing token を AST に反映する
  3. 意味解析は recovered ノードを防御的に扱う

必要なら次に、C# の Antlr4.Runtime で ErrorNode を CST から拾う方法 missing token を AST フラグへ落とす方法 意味解析での二次エラー抑制の最小実装 の3点を、あなたの構成に合わせて具体化します。

User: ANTLRで生成されたコード見たらたしかに ErrorNode を引数に持つ Visit メソッドがありました。ここに到達するということなのですね?

GitHub Copilot: はい、その理解で合っています。

ANTLR4 で parse tree visitor/listener に ErrorNode 用のフックがあるなら、それは「パーサーがエラー回復の結果として、その位置に通常ノードではないエラーノードを木へ入れる場合がある」ことを意味します。なので visitor の VisitErrorNode に到達したなら、その箇所では少なくとも「正常な構文要素としては読めなかったが、木の構築は継続した」ということです。

ただし少しだけ正確に言うと、常にすべての構文エラーが ErrorNode になるわけではありません。

  • 余計なトークンを削除した場合 その余剰トークンが ErrorNode として木に現れやすいです。
  • 不足トークンを挿入した場合 実装や状況によっては missing token として扱われ、終端ノード側に仮想トークンが入ることがあります。必ずしも VisitErrorNode だけで全部拾えるとは限りません。
  • 大きく同期復帰した場合 ErrorNode よりも「子が欠けた不完全な rule node」として見えることもあります。

なので実務上はこう考えるのが安全です。

  • VisitErrorNode に来たら、その部分は確実に recovered
  • でも VisitErrorNode に来ない recovered もある
  • したがって ErrorNode だけでなく NumberOfSyntaxErrors や SyntaxError リスナー情報も併用する

今回のケースだと、LPARENT 欠落は「不足トークン挿入」で回復している可能性が高いので、VisitErrorNode に必ず出るとは限りません。むしろ、 missing ’(’ at '' のような診断と、対応する終端ノードが仮想トークンになっているかを見るほうが確実です。

意味解析へつなぐ観点では、優先順位は次です。

  1. SyntaxError の発生位置とメッセージを保持する
  2. VisitErrorNode を拾って AST に recovered 情報を付ける
  3. 必須トークン由来の終端ノードが仮想挿入かどうかを見られるなら、それも反映する

要するに、VisitErrorNode に到達するのは「自動復帰が木に明示的に残ったケース」です。ただしそれだけで全回復ケースを網羅できるわけではない、という理解がちょうどよいです。

必要なら次に、C# の Antlr4.Runtime で ErrorNode missing token NumberOfSyntaxErrors をどう組み合わせて recovered フラグを AST に落とすか、最小の判定パターンを整理します。