Skip to content

Commit 152bde6

Browse files
committed
Merge branch 'worktree-agent-adb2c7c1d1635cc59'
# Conflicts: # src/gen-parser.ts
2 parents d5f1a9b + 22c1cf4 commit 152bde6

1 file changed

Lines changed: 106 additions & 5 deletions

File tree

src/gen-parser.ts

Lines changed: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,33 @@ export function createParser(grammar: CstGrammar) {
186186
for (const led of leds) ledFirst.set(led, firstTokenOf({ type: 'seq', items: led.items } as RuleExpr));
187187
}
188188

189+
// ── Mixfix operand re-bound info ──
190+
// A LED of the shape `<lit L1> $self <lit L2> …` (e.g. a ternary `? $ : $`) has
191+
// an *inner* operand (`$self`, the bit between L1 and L2) that the greedy
192+
// non-backtracking engine may over-consume — swallowing the L2 the operator
193+
// itself needs (the classic conditional-`?:` vs arrow-return-type-`:` clash:
194+
// `b ? (c) : d => e`, where `(c): d => e` parses as one arrow, leaving no `:`).
195+
// When the normal match of such a LED fails, the LED loop retries the inner
196+
// operand with a position cap so it stops before an L2 at the operator's own
197+
// bracket-nesting depth, freeing that L2 for the operator. Language-agnostic:
198+
// keyed purely on the structural shape, no knowledge of `?`/`:`/arrows.
199+
function selfRefName(e: RuleExpr | undefined, ruleName: string): boolean {
200+
return !!e && e.type === 'ref' && e.name === ruleName;
201+
}
202+
type MixfixInfo = { openLit: string; sepLit: string };
203+
const ledMixfix = new Map<object, MixfixInfo>();
204+
for (const [ruleName, { leds }] of prattClassified.entries()) {
205+
for (const led of leds) {
206+
const it = led.items;
207+
if (it.length >= 4
208+
&& it[0]?.type === 'literal'
209+
&& selfRefName(it[1], ruleName)
210+
&& it[2]?.type === 'literal') {
211+
ledMixfix.set(led, { openLit: it[0].value, sepLit: it[2].value });
212+
}
213+
}
214+
}
215+
189216
// ── FIRST sets ──
190217
// The set of tokens each rule can begin with (null = "anything" — left-recursive
191218
// / prefix-operator rules, which can't be characterized). Used to skip parsing a
@@ -281,8 +308,17 @@ export function createParser(grammar: CstGrammar) {
281308
// token splice (matchLiteral), which shifts later positions.
282309
const memo = new Map<string, Map<number, { node: CstNode | null; end: number }>>();
283310

311+
// Bounded-parse cap (token index). When >= 0, no token at index >= parseLimit
312+
// may be consumed — i.e. a sub-parse is forced to stop before that token. Used
313+
// for the mixfix-operand re-parse (see matchMixfixLed): re-running an over-greedy
314+
// operand so it leaves a required separator for the enclosing mixfix operator.
315+
// `null`-equivalent is -1 (no cap). A capped parse is position+cap dependent,
316+
// so it bypasses the packrat memo (which is keyed by start position only).
317+
let parseLimit = -1;
318+
284319
function peek(): Token | null {
285320
if (pos > maxPos) maxPos = pos;
321+
if (parseLimit >= 0 && pos >= parseLimit) return null;
286322
return tokens[pos] ?? null;
287323
}
288324

@@ -402,11 +438,13 @@ export function createParser(grammar: CstGrammar) {
402438
suppressNext = null;
403439

404440
// Memoizable (pratt / left-recursive): look up by start position. A
405-
// suppressed parse is context-dependent (its result differs from the
406-
// allow-`in` parse at the same spot), so it bypasses the memo entirely.
441+
// suppressed parse (no-`in` context) or a capped parse (parseLimit active,
442+
// from the mixfix re-bind retry) is context/position+cap dependent, so it
443+
// bypasses the position-keyed packrat memo entirely.
444+
const capped = parseLimit >= 0;
407445
const start = pos;
408446
let m = memo.get(name);
409-
if (!mySup) {
447+
if (!mySup && !capped) {
410448
const hit = m && m.get(start);
411449
if (hit !== undefined) { pos = hit.end; return hit.node; }
412450
}
@@ -422,7 +460,7 @@ export function createParser(grammar: CstGrammar) {
422460
currentPrattContext = prevContext;
423461
suppressCur = prevSup;
424462
}
425-
if (!mySup) {
463+
if (!mySup && !capped) {
426464
if (!m) { m = new Map(); memo.set(name, m); }
427465
m.set(start, { node: result, end: pos });
428466
}
@@ -565,7 +603,13 @@ export function createParser(grammar: CstGrammar) {
565603
if (!canStart(ledFirst.get(led), tok)) continue; // first-token dispatch for LED continuations
566604

567605
pos = ledSaved;
568-
const children = matchSeq(led.items);
606+
let children = matchSeq(led.items);
607+
// Mixfix operand re-bound: if a `<L1> $ <L2> …` LED failed, the inner
608+
// operand may have over-consumed the L2 it needs — retry it capped.
609+
if (children === null && ledMixfix.has(led)) {
610+
pos = ledSaved;
611+
children = matchMixfixLed(led, rule.name, ledMixfix.get(led)!);
612+
}
569613
if (children !== null) {
570614
lhs = {
571615
kind: 'node',
@@ -674,6 +718,63 @@ export function createParser(grammar: CstGrammar) {
674718
return children;
675719
}
676720

721+
// Mixfix operand re-bound (see ledMixfix). The LED shape is
722+
// `<openLit> $self <sepLit> …rest`; the normal match already failed. Re-parse
723+
// the inner `$self` operand with a position cap so it stops before a `sepLit`
724+
// token at the *same* bracket depth as `openLit`, freeing that token for the
725+
// operator's own separator, then match `sepLit` and the rest uncapped.
726+
// `pos` is at `openLit` on entry. Returns the LED children or null (restored).
727+
function matchMixfixLed(led: { items: RuleExpr[] }, ruleName: string, info: { openLit: string; sepLit: string }): CstChild[] | null {
728+
const saved = pos;
729+
const openLeaf = matchLiteral(info.openLit);
730+
if (!openLeaf) { pos = saved; return null; }
731+
const afterOpen = pos;
732+
733+
// Greedy parse of the operand, to (a) confirm the separator is missing right
734+
// after it (otherwise the failure is elsewhere → don't apply) and (b) bound
735+
// the scan window.
736+
const operand = parseRule(ruleName);
737+
if (!operand) { pos = saved; return null; }
738+
const greedyEnd = pos;
739+
// If the separator DOES match here, the LED's failure was later in `rest`,
740+
// not operand over-consumption — re-bounding wouldn't help.
741+
if (matchLiteral(info.sepLit)) { pos = saved; return null; }
742+
743+
// Candidate separator positions: `sepLit` tokens at bracket depth 0 within
744+
// (afterOpen, greedyEnd). A nested same-shape operator (e.g. a nested
745+
// ternary) contributes its own depth-0 `sepLit`, so we try candidates in
746+
// order and accept the first where the capped operand lands exactly on it.
747+
let depth = 0;
748+
const candidates: number[] = [];
749+
for (let i = afterOpen; i < greedyEnd; i++) {
750+
const t = tokens[i];
751+
if (t.type !== '') continue; // only punctuation carries brackets/sep
752+
if (t.text === '(' || t.text === '[' || t.text === '{') depth++;
753+
else if (t.text === ')' || t.text === ']' || t.text === '}') depth--;
754+
else if (depth === 0 && t.text === info.sepLit) candidates.push(i);
755+
}
756+
757+
for (const sepIdx of candidates) {
758+
// Re-parse the operand capped so it cannot consume the token at sepIdx;
759+
// accept only if the operand consumes everything up to exactly there.
760+
pos = afterOpen;
761+
const prevLimit = parseLimit;
762+
parseLimit = sepIdx;
763+
const reOperand = parseRule(ruleName);
764+
parseLimit = prevLimit;
765+
if (!reOperand || pos !== sepIdx) continue;
766+
767+
const sepLeaf = matchLiteral(info.sepLit);
768+
if (!sepLeaf) continue;
769+
const rest = matchSeq(led.items.slice(3));
770+
if (rest === null) continue;
771+
return [openLeaf, reOperand, sepLeaf, ...rest];
772+
}
773+
774+
pos = saved;
775+
return null;
776+
}
777+
677778
function matchQuantifier(body: RuleExpr, kind: '*' | '+' | '?'): CstChild[] | null {
678779
if (kind === '?') {
679780
const result = matchExpr(body);

0 commit comments

Comments
 (0)