Skip to content

Commit 86fd02f

Browse files
Define left recursion by the left-corner relation; reject indirect/hidden cycles at build time (#29)
1 parent ef3e3e7 commit 86fd02f

3 files changed

Lines changed: 200 additions & 27 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"test:tm-diagnostics": "node test/redcmd-tm-diagnostics.ts",
1414
"test:tm-guards": "node test/tm-highlight-guards.ts",
1515
"test:yaml-issues": "node test/yaml-issue12-regressions.ts",
16+
"test:left-recursion": "node test/left-recursion.ts",
1617
"spike:html-lexer": "node test/html-lexer-spike.ts",
1718
"bench:html-official": "node test/html-bench.ts",
1819
"bench:html-embed": "node test/html-embed-js.ts",

src/gen-parser.ts

Lines changed: 136 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,116 @@ export function createParser(grammar: CstGrammar) {
179179
return { atoms, continuations };
180180
}
181181

182+
// ── Left recursion = a left-corner cycle ──
183+
// What "left-recursive" MEANS in this engine is the left-corner relation, not the
184+
// syntactic `items[0]===self` shape. A rule is left-recursive iff it can derive
185+
// ITSELF as its leftmost symbol without consuming input — i.e. it can reach itself
186+
// through the transitive closure of the left-corner edge map below. That relation is
187+
// the single source of truth: it captures DIRECT recursion (A → A …), INDIRECT cycles
188+
// (A → B → A) and recursion HIDDEN behind a nullable prefix (A → opt(x) A …) alike,
189+
// all of which re-enter the rule at the same input position. The narrower syntactic
190+
// test `items[0]===self` is NOT the definition; it only identifies which alternatives
191+
// the local atom/continuation (and Pratt NUD/LED) transform can peel into an iterative
192+
// loop — see classifyAlts/classifyLeftRec and the residual graph below.
193+
//
194+
// Nullability feeds the left-corner edges (a nullable leftmost element passes through
195+
// to the next), so compute it first. op/prefix/postfix consume an operator token, so
196+
// they are left-edge BARRIERS, not pass-through.
197+
const nullableRules = new Set<string>();
198+
function exprNullable(e: RuleExpr): boolean {
199+
switch (e.type) {
200+
case 'literal': return false;
201+
case 'ref': return tokenNames.has(e.name) ? false : nullableRules.has(e.name);
202+
case 'seq': return e.items.every(exprNullable);
203+
case 'alt': return e.items.some(exprNullable);
204+
case 'quantifier': return e.kind === '+' ? exprNullable(e.body) : true;
205+
case 'group': return exprNullable(e.body);
206+
case 'not': return true; // zero-width assertion: consumes nothing
207+
case 'sep': return true; // sep matches zero elements
208+
default: return true; // op/prefix/postfix markers don't consume
209+
}
210+
}
211+
for (let changed = true; changed; ) {
212+
changed = false;
213+
for (const rule of grammar.rules) {
214+
if (!nullableRules.has(rule.name) && exprNullable(rule.body)) { nullableRules.add(rule.name); changed = true; }
215+
}
216+
}
217+
// The set of rules reachable at the LEFT CORNER of an expression: every rule ref that
218+
// could be the leftmost symbol, looking through nullable prefixes and stopping at the
219+
// first non-nullable element or operator barrier.
220+
function leftRuleRefs(e: RuleExpr): Set<string> {
221+
switch (e.type) {
222+
case 'ref': return tokenNames.has(e.name) ? new Set() : new Set([e.name]);
223+
case 'seq': {
224+
const acc = new Set<string>();
225+
for (const item of e.items) {
226+
if (item.type === 'op' || item.type === 'prefix' || item.type === 'postfix') break; // consumes an operator token → barrier
227+
for (const r of leftRuleRefs(item)) acc.add(r);
228+
if (!exprNullable(item)) break; // a non-nullable element ends the left edge
229+
}
230+
return acc;
231+
}
232+
case 'alt': { const acc = new Set<string>(); for (const b of e.items) for (const r of leftRuleRefs(b)) acc.add(r); return acc; }
233+
case 'quantifier': case 'group': return leftRuleRefs(e.body);
234+
case 'sep': return leftRuleRefs(e.element);
235+
default: return new Set(); // literal / not / sameLine / … : no leftmost rule ref
236+
}
237+
}
238+
function altsOf(rule: RuleDecl): RuleExpr[] {
239+
return rule.body.type === 'alt' ? rule.body.items : [rule.body];
240+
}
241+
function itemsOf(alt: RuleExpr): RuleExpr[] {
242+
return alt.type === 'seq' ? alt.items : [alt];
243+
}
244+
// Does this alternative begin with a DIRECT self-reference (`A → A …`)? This is the
245+
// ONLY thing `items[0]===self` decides: which alts the local transform peels into an
246+
// iterative loop (and so which edges drop out of the residual graph). It is no longer
247+
// a standalone definition of "is this rule left-recursive".
248+
function peelsDirect(rule: RuleDecl, alt: RuleExpr): boolean {
249+
const items = itemsOf(alt);
250+
return items[0]?.type === 'ref' && items[0].name === rule.name;
251+
}
252+
// The PURE left-corner edge map, over ALL alternatives (nothing pre-excluded). This is
253+
// the relation that DEFINES left recursion.
254+
const leftCorner = new Map<string, Set<string>>();
255+
for (const rule of grammar.rules) {
256+
const edges = new Set<string>();
257+
for (const alt of altsOf(rule)) for (const r of leftRuleRefs(alt)) edges.add(r);
258+
leftCorner.set(rule.name, edges);
259+
}
260+
// The RESIDUAL left-corner edge map: same as `leftCorner` but with each rule's direct
261+
// `items[0]===self` alts removed — those are exactly the edges the local transform
262+
// turns into an iterative loop instead of a recursive descent. A left-recursive rule
263+
// is HANDLEABLE iff peeling its direct self-alts breaks every cycle through it, i.e. it
264+
// can no longer reach itself in this residual graph.
265+
const residualCorner = new Map<string, Set<string>>();
266+
for (const rule of grammar.rules) {
267+
const edges = new Set<string>();
268+
for (const alt of altsOf(rule)) {
269+
if (peelsDirect(rule, alt)) continue; // peeled into an iterative loop → not a recursive descent
270+
for (const r of leftRuleRefs(alt)) edges.add(r);
271+
}
272+
residualCorner.set(rule.name, edges);
273+
}
274+
// Find a cycle start → … → start in a left-corner graph, returned as a path naming the
275+
// genuinely-recursive edges; null if `start` cannot reach itself.
276+
function cornerCycle(graph: Map<string, Set<string>>, start: string): string[] | null {
277+
const stack: { node: string; path: string[] }[] = [{ node: start, path: [start] }];
278+
const seen = new Set<string>();
279+
while (stack.length) {
280+
const { node, path } = stack.pop()!;
281+
for (const next of graph.get(node) ?? []) {
282+
if (next === start) return [...path, next];
283+
if (!seen.has(next)) { seen.add(next); stack.push({ node: next, path: [...path, next] }); }
284+
}
285+
}
286+
return null;
287+
}
288+
// THE definition of left recursion: the rule reaches itself through the transitive
289+
// closure of the pure left-corner relation.
182290
function isLeftRecursive(rule: RuleDecl): boolean {
183-
const alts = rule.body.type === 'alt' ? rule.body.items : [rule.body];
184-
return alts.some(alt => {
185-
const items = alt.type === 'seq' ? alt.items : [alt];
186-
return items[0]?.type === 'ref' && items[0].name === rule.name;
187-
});
291+
return cornerCycle(leftCorner, rule.name) !== null;
188292
}
189293

190294
// Maximum binding power for non-operator LED patterns (member access, call, etc.)
@@ -195,8 +299,32 @@ export function createParser(grammar: CstGrammar) {
195299
// Rule lookup, left-recursion, and the NUD/LED (Pratt) / atom-continuation
196300
// (left-rec) classification are functions of the static grammar only, so we
197301
// compute them ONCE here instead of re-deriving them on every parse call.
302+
//
303+
// Left-recursive rules split two ways against the local transform:
304+
// • HANDLEABLE — peeling the direct `items[0]===self` alts breaks every cycle (the
305+
// residual graph is acyclic for this rule). These go in `leftRecSet`, and
306+
// classifyLeftRec / parseLeftRec (or the Pratt NUD/LED path) handle them unchanged.
307+
// • UNHANDLEABLE — a cycle survives in the residual graph (an INDIRECT cycle, or one
308+
// HIDDEN behind a nullable prefix so its first item is not a bare self-ref). The
309+
// local transform cannot peel it, recursive descent would not terminate, so we
310+
// reject it at build time with a diagnostic naming the residual cycle. This is the
311+
// correct product behavior — the engine does not parse indirect/hidden LR.
198312
const ruleByName = new Map<string, RuleDecl>(grammar.rules.map(r => [r.name, r]));
199-
const leftRecSet = new Set<string>(grammar.rules.filter(isLeftRecursive).map(r => r.name));
313+
const leftRecSet = new Set<string>();
314+
for (const rule of grammar.rules) {
315+
if (!isLeftRecursive(rule)) continue; // not left-recursive (per the relation): ordinary rule
316+
const residual = cornerCycle(residualCorner, rule.name);
317+
if (residual) {
318+
throw new Error(
319+
`Unhandled left recursion in rule '${rule.name}': it can derive itself as its leftmost `
320+
+ `symbol without consuming input (left-corner cycle ${residual.join(' → ')}). The engine `
321+
+ `transforms only DIRECT left recursion (an alternative beginning with the rule itself); `
322+
+ `this cycle is indirect or hidden behind a nullable prefix, so recursive descent would `
323+
+ `not terminate. Break the cycle or rewrite it as a direct left-recursive/precedence rule.`,
324+
);
325+
}
326+
leftRecSet.add(rule.name); // handleable: the residual graph is acyclic
327+
}
200328
const prattClassified = new Map<string, ReturnType<typeof classifyAlts>>();
201329
const leftRecClassified = new Map<string, ReturnType<typeof classifyLeftRec>>();
202330
for (const rule of grammar.rules) {
@@ -333,27 +461,8 @@ export function createParser(grammar: CstGrammar) {
333461
// / prefix-operator rules, which can't be characterized). Used to skip parsing a
334462
// non-nullable rule reference outright when the lookahead can't start it — this
335463
// is what stops e.g. DecoratorExpr/TypeParams being speculatively parsed (and
336-
// failing) at every member/parameter position.
337-
const nullableRules = new Set<string>();
338-
function exprNullable(e: RuleExpr): boolean {
339-
switch (e.type) {
340-
case 'literal': return false;
341-
case 'ref': return tokenNames.has(e.name) ? false : nullableRules.has(e.name);
342-
case 'seq': return e.items.every(exprNullable);
343-
case 'alt': return e.items.some(exprNullable);
344-
case 'quantifier': return e.kind === '+' ? exprNullable(e.body) : true;
345-
case 'group': return exprNullable(e.body);
346-
case 'not': return true; // zero-width assertion: consumes nothing
347-
case 'sep': return true; // sep matches zero elements
348-
default: return true; // op/prefix/postfix markers don't consume
349-
}
350-
}
351-
for (let changed = true; changed; ) {
352-
changed = false;
353-
for (const rule of grammar.rules) {
354-
if (!nullableRules.has(rule.name) && exprNullable(rule.body)) { nullableRules.add(rule.name); changed = true; }
355-
}
356-
}
464+
// failing) at every member/parameter position. (Nullability and the left-corner
465+
// relation that DEFINES left recursion are computed earlier, above leftRecSet.)
357466
const firstSets = new Map<string, Set<string> | null>(); // null = top (anything)
358467
function exprFirst(e: RuleExpr): Set<string> | null {
359468
switch (e.type) {

test/left-recursion.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Regression: left recursion is defined by the LEFT-CORNER relation, not the syntactic
2+
// `items[0] === self` shape. A rule is left-recursive iff it can derive ITSELF as its
3+
// leftmost symbol without consuming input. DIRECT recursion (A → A …) whose self-alt the
4+
// transform peels is handleable and parses; INDIRECT cycles (A → B → A) and recursion
5+
// HIDDEN behind a nullable prefix (A → opt(x) A …) cannot be peeled, so they are rejected
6+
// at BUILD time with a named-cycle diagnostic — NOT left to overflow the stack at parse
7+
// time, which is what the old syntactic check allowed (it only saw the length-1,
8+
// non-nullable case).
9+
import { token, rule, defineGrammar, range, opt } from '../src/api.ts';
10+
import { createParser } from '../src/gen-parser.ts';
11+
12+
let ok = 0, fail = 0;
13+
const check = (label: string, cond: boolean) => { cond ? ok++ : (fail++, console.log(' ✗', label)); };
14+
15+
// 'parsed' | 'rejected' (build-time Error) | 'overflow' (parse-time RangeError) | 'parse-error'.
16+
function outcome(build: () => any, input: string): string {
17+
let parser: any;
18+
try { parser = createParser(build()); }
19+
catch (e: unknown) { return e instanceof RangeError ? 'overflow' : 'rejected'; }
20+
try { parser.parse(input); return 'parsed'; }
21+
catch (e: unknown) { return e instanceof RangeError ? 'overflow' : 'parse-error'; }
22+
}
23+
24+
// DIRECT — A → A W | W. Left-corner self-cycle whose direct self-alt is peeled into an
25+
// iterative loop ⇒ handleable ⇒ parses.
26+
check('direct left recursion parses', outcome(() => {
27+
const W = token(range('a', 'z'), { identifier: true });
28+
const A: any = rule(($: any) => [[$, W], W]);
29+
return defineGrammar({ name: 'lr_direct', tokens: { W }, rules: { A }, entry: A });
30+
}, 'ab') === 'parsed');
31+
32+
// INDIRECT — A → B | W ; B → A | W. The cycle A → B → A survives peeling (no direct
33+
// self-alt to peel), so it is rejected at build time, not overflowed at parse time.
34+
check('indirect left recursion rejected at build time', outcome(() => {
35+
const W = token(range('a', 'z'), { identifier: true });
36+
const A: any = rule(() => [B, W]);
37+
const B: any = rule(() => [A, W]);
38+
return defineGrammar({ name: 'lr_indirect', tokens: { W }, rules: { A, B }, entry: A });
39+
}, 'a') === 'rejected');
40+
41+
// NULLABLE-HIDDEN — A → opt(D) A W | W. A references itself directly, but behind a
42+
// nullable prefix, so its first item is not a bare self-ref. The left-corner relation
43+
// still sees the cycle (the nullable element passes through) ⇒ rejected at build time.
44+
check('nullable-hidden left recursion rejected at build time', outcome(() => {
45+
const W = token(range('a', 'z'), { identifier: true });
46+
const D = token(range('0', '9'));
47+
const A: any = rule(($: any) => [[opt(D), $, W], W]);
48+
return defineGrammar({ name: 'lr_hidden', tokens: { W, D }, rules: { A }, entry: A });
49+
}, 'a') === 'rejected');
50+
51+
// The diagnostic names the offending rule and the left-corner cycle path.
52+
let msg = '';
53+
try {
54+
const W = token(range('a', 'z'), { identifier: true });
55+
const A: any = rule(() => [B, W]);
56+
const B: any = rule(() => [A, W]);
57+
createParser(defineGrammar({ name: 'lr_msg', tokens: { W }, rules: { A, B }, entry: A }));
58+
} catch (e: unknown) { msg = e instanceof Error ? e.message : String(e); }
59+
check('diagnostic names the rule and the left-corner cycle',
60+
/left recursion/i.test(msg) && msg.includes("'A'") && msg.includes('→'));
61+
62+
console.log(fail === 0 ? `\n${ok}/${ok} left-recursion checks pass` : `\n${fail} FAILED`);
63+
process.exit(fail === 0 ? 0 : 1);

0 commit comments

Comments
 (0)