fix(shader-compiler): resolve ( conflict explicitly, not via add order

`_addAction`'s ELSE-only handler left the second shift/reduce conflict
introduced by #2974 (`type_specifier_nonarray → macro_call_symbol` on
`(` lookahead) to be resolved by whichever action `_inferNextState`
happens to register last. Today that's Shift (correct), because the
first loop writes Reduce actions and the second loop writes Shift —
Shift overwrites. But the resolution is implicit: any future refactor
that flips the registration order would silently route every
`MacroCall(args)` to a type-position reduce, breaking macro-as-type
shaders without warning.

Promote the existing ELSE handler into a `_isKnownShiftPreferred`
whitelist matching both conflicts:
  - terminal === Keyword.ELSE (dangling-else)
  - terminal === LEFT_PAREN AND reduce target is the precise
    `type_specifier_nonarray → macro_call_symbol` production

Resolution is order-independent by construction:
  - Shift first, Reduce second → early-return keeps Shift
  - Reduce first, Shift second → falls through to `table.set`, Shift overwrites

Any future conflict not in the whitelist still emits the verbose
`conflict detect` warning, so grammar/runtime drift stays loud.

This pairs with `%expect 2` in TargetParser.y (previous commit): the
bison directive asserts the conflict count at grammar level, this
commit makes the LALR builder's resolution deterministic at code level.
This commit is contained in:
chenmo.gl
2026-05-13 19:37:15 +08:00
parent 40ae2f9a2b
commit 5163b6143a

View File

@@ -2,6 +2,7 @@ import { ETokenType } from "../common";
import { Keyword } from "../common/enums/Keyword";
import { Grammar } from "../parser/Grammar";
import { GrammarSymbol, NoneTerminal, Terminal } from "../parser/GrammarSymbol";
import Production from "./Production";
import State from "./State";
import StateItem from "./StateItem";
import { default as GrammarUtils, default as Utils } from "./Utils";
@@ -157,9 +158,14 @@ export class LALR1 {
private _addAction(table: ActionTable, terminal: Terminal, action: ActionInfo) {
const exist = table.get(terminal);
if (exist && !Utils.isActionEqual(exist, action)) {
// Resolve dangling else ambiguity
if (terminal === Keyword.ELSE && exist.action === EAction.Shift && action.action === EAction.Reduce) {
return;
// Known shift-preferred conflicts (see TargetParser.y `%expect 2`).
// Enforce shift regardless of the order `_inferNextState` registers
// actions: when `exist` is already Shift and `action` is Reduce, keep
// `exist` (early-return); the reverse order (`exist` Reduce, `action`
// Shift) falls through to `table.set` below and Shift overwrites
// Reduce. Order-independent by construction.
if (LALR1._isKnownShiftPreferred(terminal, exist, action)) {
if (exist.action === EAction.Shift && action.action === EAction.Reduce) return;
} else {
// #if _VERBOSE
console.warn(
@@ -174,6 +180,28 @@ export class LALR1 {
table.set(terminal, action);
}
// Catalog of expected shift/reduce conflicts. Each entry must correspond to
// one of TargetParser.y's `%expect`-ed conflicts; any new conflict not in
// this list falls through to the verbose `conflict detect` warning so the
// grammar/runtime drift is loud rather than silent.
// - ELSE: dangling-else, bind to nearest `if`
// - '(' + `type_specifier_nonarray → macro_call_symbol`: macro-as-type-alias
// (#2974), prefer the expression-position `macro_call_function` over the
// type-position reduce
private static _isKnownShiftPreferred(terminal: Terminal, exist: ActionInfo, action: ActionInfo): boolean {
if (terminal === Keyword.ELSE) return true;
if (terminal !== ETokenType.LEFT_PAREN) return false;
const reduce = exist.action === EAction.Reduce ? exist : action.action === EAction.Reduce ? action : null;
if (!reduce) return false;
const prod = Production.pool.get(reduce.target!);
return (
!!prod &&
prod.goal === NoneTerminal.type_specifier_nonarray &&
prod.derivation.length === 1 &&
prod.derivation[0] === NoneTerminal.macro_call_symbol
);
}
// https://people.cs.pitt.edu/~jmisurda/teaching/cs1622/handouts/cs1622-first_and_follow.pdf
private computeFirstSet() {
for (const production of this.grammar.productions.slice(1)) {