@tabnas/abnf 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2106 @@
1
+ "use strict";
2
+ /* Copyright (c) 2025 Richard Rodger and other contributors, MIT License */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.AbnfParseError = exports.abnfRules = void 0;
5
+ exports.abnf = abnf;
6
+ exports.parseAbnf = parseAbnf;
7
+ exports.emitGrammarSpec = emitGrammarSpec;
8
+ exports.eliminateLeftRecursion = eliminateLeftRecursion;
9
+ // Declarative definition of the ABNF grammar itself, expressed as
10
+ // tabnas rules. Each rule names its `open`/`close` alt list and, where
11
+ // necessary, a `bo`/`bc` state hook for AST assembly.
12
+ //
13
+ // Stage 8: incremental alternatives via `name =/ alt` now fold
14
+ // into the earlier production with the same name. Quoted strings
15
+ // default to case-insensitive (ABNF semantics), `%s` / `%i` force
16
+ // sensitivity explicitly, numeric values and repetition prefixes
17
+ // work as in previous stages.
18
+ //
19
+ // Token vocabulary:
20
+ // #DEF `=` (rule-definition operator)
21
+ // #DEFA `=/` (incremental-alternatives operator)
22
+ // #ALT `/` (alternation)
23
+ // #STAR `*` (repetition separator)
24
+ // #NUM decimal repetition count (matched via match.token)
25
+ // #NV `%[xdb]NN[(-NN|(.NN)*)]` numeric value (match.token)
26
+ // #SS `%s` (case-sensitive string prefix)
27
+ // #SI `%i` (case-insensitive string prefix — same as default)
28
+ // #LP `(`
29
+ // #RP `)`
30
+ // #OB `[` (optional-group open)
31
+ // #CB `]` (optional-group close)
32
+ // #TX bare identifier (tabnas default text token)
33
+ // #ST quoted string literal (tabnas default string token)
34
+ // #ZZ end-of-source
35
+ //
36
+ // Grammar:
37
+ // abnf = production*
38
+ // production = IDENT ('=' / '=/') alts
39
+ // alts = seq ('/' seq)*
40
+ // seq = element*
41
+ // element = repetition? atom
42
+ // repetition = NUM '*' NUM / NUM '*' / '*' NUM / '*' / NUM
43
+ // atom = IDENT | STRING | ['%s' | '%i'] STRING | NUMVAL
44
+ // | '(' alts ')' | '[' alts ']'
45
+ // numval = '%' ('x' / 'd' / 'b') DIGITS [ '-' DIGITS | ('.' DIGITS)* ]
46
+ const abnfRules = {
47
+ // Top-level: accumulates productions into r.node.
48
+ abnf: {
49
+ bo: (r) => { r.node = []; },
50
+ open: [
51
+ { s: '#ZZ', g: 'empty' },
52
+ { p: 'prod' },
53
+ ],
54
+ close: [{ s: '#ZZ' }],
55
+ },
56
+ // One production per invocation; tail-recurses (r:'prod') for the
57
+ // next. Inherits its parent's node (the productions array) and
58
+ // appends to it in `bc` once its `alts` child has returned.
59
+ // Production header is `IDENT =` — a bareword rule name followed
60
+ // by the `=` definition operator.
61
+ prod: {
62
+ open: [
63
+ // Standalone definition: name = alts
64
+ {
65
+ s: '#TX #DEF',
66
+ a: (r) => {
67
+ r.u.name = r.o[0].val;
68
+ r.u.incremental = false;
69
+ },
70
+ p: 'alts',
71
+ },
72
+ // Incremental alternatives: name =/ alts
73
+ {
74
+ s: '#TX #DEFA',
75
+ a: (r) => {
76
+ r.u.name = r.o[0].val;
77
+ r.u.incremental = true;
78
+ },
79
+ p: 'alts',
80
+ },
81
+ ],
82
+ close: [
83
+ // A TX followed by `=` or `=/` means the next production has
84
+ // begun — back up 2 tokens so a fresh `prod` invocation sees
85
+ // them.
86
+ { s: '#TX #DEF', b: 2, r: 'prod' },
87
+ { s: '#TX #DEFA', b: 2, r: 'prod' },
88
+ { b: 1 },
89
+ ],
90
+ bc: (r) => {
91
+ if (r.child && r.child.node !== undefined) {
92
+ const prod = { name: r.u.name, alts: r.child.node };
93
+ if (r.u.incremental)
94
+ prod.incremental = true;
95
+ r.node.push(prod);
96
+ }
97
+ },
98
+ },
99
+ // A list of alternative sequences separated by `/` (ABNF
100
+ // alternation). Owns its own array (`bo` resets it) and pushes
101
+ // each seq result in `bc`.
102
+ alts: {
103
+ bo: (r) => { r.node = []; },
104
+ open: [{ p: 'seq' }],
105
+ close: [
106
+ { s: '#ALT', p: 'seq' },
107
+ { b: 1 },
108
+ ],
109
+ bc: (r) => {
110
+ if (r.child && r.child.node !== undefined) {
111
+ r.node.push(r.child.node);
112
+ }
113
+ },
114
+ },
115
+ // A (possibly empty) sequence of elements. The 2-token lookahead
116
+ // `#TX #DEF` detects a following production boundary and bails
117
+ // out without consuming the tokens; a plain `#TX` at the leading
118
+ // position (tried later so the longer alt wins) is a rule
119
+ // reference inside the current sequence.
120
+ seq: {
121
+ bo: (r) => { r.node = []; },
122
+ open: [
123
+ { s: '#TX #DEF', b: 2, g: 'end' },
124
+ { s: '#TX #DEFA', b: 2, g: 'end' },
125
+ { s: '#ALT', b: 1, g: 'end' },
126
+ { s: '#ZZ', b: 1, g: 'end' },
127
+ { s: '#RP', b: 1, g: 'end' },
128
+ { s: '#CB', b: 1, g: 'end' },
129
+ // Listing element-starter tokens in `s:` here ensures the
130
+ // tcol-driven matcher considers each one when lexing.
131
+ { s: '#ST', b: 1, p: 'elem' },
132
+ { s: '#NV', b: 1, p: 'elem' },
133
+ { s: '#SS', b: 1, p: 'elem' },
134
+ { s: '#SI', b: 1, p: 'elem' },
135
+ { s: '#TX', b: 1, p: 'elem' },
136
+ { s: '#LP', b: 1, p: 'elem' },
137
+ { s: '#OB', b: 1, p: 'elem' },
138
+ { s: '#STAR', b: 1, p: 'elem' },
139
+ { s: '#NUM', b: 1, p: 'elem' },
140
+ { p: 'elem' },
141
+ ],
142
+ close: [
143
+ { s: '#TX #DEF', b: 2, g: 'end' },
144
+ { s: '#TX #DEFA', b: 2, g: 'end' },
145
+ { s: '#ALT', b: 1, g: 'end' },
146
+ { s: '#ZZ', b: 1, g: 'end' },
147
+ { s: '#RP', b: 1, g: 'end' },
148
+ { s: '#CB', b: 1, g: 'end' },
149
+ { s: '#ST', b: 1, p: 'elem' },
150
+ { s: '#NV', b: 1, p: 'elem' },
151
+ { s: '#SS', b: 1, p: 'elem' },
152
+ { s: '#SI', b: 1, p: 'elem' },
153
+ { s: '#TX', b: 1, p: 'elem' },
154
+ { s: '#LP', b: 1, p: 'elem' },
155
+ { s: '#OB', b: 1, p: 'elem' },
156
+ { s: '#STAR', b: 1, p: 'elem' },
157
+ { s: '#NUM', b: 1, p: 'elem' },
158
+ { b: 1 },
159
+ ],
160
+ },
161
+ // One element: an optional ABNF repetition prefix (`*A`, `1*A`,
162
+ // `m*nA`, `*nA`, `m*A`, `nA`) followed by an atom. The prefix is
163
+ // matched up front, stored on `r.u.min`/`r.u.max`; then `atom` is
164
+ // pushed to parse the actual element body, whose result is wrapped
165
+ // into an AST node and appended to the parent seq's array in close.
166
+ elem: {
167
+ bo: (r) => { r.u.min = 1; r.u.max = 1; },
168
+ open: [
169
+ // NUM '*' NUM — bounded repetition, followed by the atom
170
+ // itself (listed via the ATOM tokenset so every atom-starter
171
+ // tin — including `#NV` — is in tcol for this position).
172
+ {
173
+ s: '#NUM #STAR #NUM #ATOM',
174
+ b: 1,
175
+ a: (r) => {
176
+ r.u.min = parseInt(r.o[0].src, 10);
177
+ r.u.max = parseInt(r.o[2].src, 10);
178
+ },
179
+ p: 'atom',
180
+ },
181
+ // NUM '*' — at-least-NUM repetition followed by an atom.
182
+ {
183
+ s: '#NUM #STAR #ATOM',
184
+ b: 1,
185
+ a: (r) => {
186
+ r.u.min = parseInt(r.o[0].src, 10);
187
+ r.u.max = Infinity;
188
+ },
189
+ p: 'atom',
190
+ },
191
+ // '*' NUM — at-most-NUM repetition.
192
+ {
193
+ s: '#STAR #NUM #ATOM',
194
+ b: 1,
195
+ a: (r) => {
196
+ r.u.min = 0;
197
+ r.u.max = parseInt(r.o[1].src, 10);
198
+ },
199
+ p: 'atom',
200
+ },
201
+ // '*' — zero-or-more.
202
+ {
203
+ s: '#STAR #ATOM',
204
+ b: 1,
205
+ a: (r) => { r.u.min = 0; r.u.max = Infinity; },
206
+ p: 'atom',
207
+ },
208
+ // NUM — exact repetition count.
209
+ {
210
+ s: '#NUM #ATOM',
211
+ b: 1,
212
+ a: (r) => {
213
+ const n = parseInt(r.o[0].src, 10);
214
+ r.u.min = n;
215
+ r.u.max = n;
216
+ },
217
+ p: 'atom',
218
+ },
219
+ // No prefix — push atom directly (min = max = 1).
220
+ { p: 'atom' },
221
+ ],
222
+ close: [{
223
+ // Wrap the returned atom (r.child.node) based on r.u.min/max
224
+ // and append to the parent seq's array.
225
+ a: (r) => {
226
+ const item = r.child.node;
227
+ const { min, max } = r.u;
228
+ if (min === 1 && max === 1) {
229
+ r.node.push(item);
230
+ }
231
+ else if (min === 0 && max === Infinity) {
232
+ r.node.push({ kind: 'star', inner: item });
233
+ }
234
+ else if (min === 1 && max === Infinity) {
235
+ r.node.push({ kind: 'plus', inner: item });
236
+ }
237
+ else if (min === 0 && max === 1) {
238
+ r.node.push({ kind: 'opt', inner: item });
239
+ }
240
+ else {
241
+ r.node.push({ kind: 'rep', min, max, inner: item });
242
+ }
243
+ },
244
+ }],
245
+ },
246
+ // The atom body — a bareword ref, quoted-string terminal,
247
+ // parenthesised group, or bracketed optional. Sets its OWN r.node
248
+ // to the AST element so the enclosing `elem` rule can read it
249
+ // from `r.child.node` in its close state.
250
+ atom: {
251
+ bo: (r) => { r.node = undefined; },
252
+ open: [
253
+ // Case-sensitive string: %s"foo"
254
+ {
255
+ s: '#SS #ST',
256
+ a: (r) => {
257
+ r.node = {
258
+ kind: 'term',
259
+ literal: r.o[1].val,
260
+ caseSensitive: true,
261
+ };
262
+ },
263
+ },
264
+ // Case-insensitive string: %i"foo" (same as bare "foo" below,
265
+ // but spelled explicitly).
266
+ {
267
+ s: '#SI #ST',
268
+ a: (r) => {
269
+ r.node = { kind: 'term', literal: r.o[1].val };
270
+ },
271
+ },
272
+ // Bare quoted string — case-insensitive per ABNF default.
273
+ {
274
+ s: '#ST',
275
+ a: (r) => {
276
+ r.node = { kind: 'term', literal: r.o[0].val };
277
+ },
278
+ },
279
+ {
280
+ s: '#NV',
281
+ a: (r) => {
282
+ r.node = parseNumericValue(r.o[0].src);
283
+ },
284
+ },
285
+ {
286
+ s: '#TX',
287
+ a: (r) => {
288
+ r.node = { kind: 'ref', name: r.o[0].val };
289
+ },
290
+ },
291
+ {
292
+ s: '#LP',
293
+ a: (r) => { r.u.groupKind = 'group'; },
294
+ p: 'alts',
295
+ },
296
+ {
297
+ s: '#OB',
298
+ a: (r) => { r.u.groupKind = 'opt'; },
299
+ p: 'alts',
300
+ },
301
+ ],
302
+ close: [
303
+ {
304
+ s: '#RP',
305
+ c: (r) => r.u.groupKind === 'group',
306
+ a: (r) => {
307
+ r.node = { kind: 'group', alts: r.child.node };
308
+ },
309
+ },
310
+ {
311
+ s: '#CB',
312
+ c: (r) => r.u.groupKind === 'opt',
313
+ a: (r) => {
314
+ r.node = {
315
+ kind: 'opt',
316
+ inner: { kind: 'group', alts: r.child.node },
317
+ };
318
+ },
319
+ },
320
+ // For simple atoms (string/ref), r.node is already set by
321
+ // open; we want to pop without consuming the next token.
322
+ // List every token that can legitimately follow an atom so
323
+ // the lexer's tcol-driven match-matcher emits #NUM, #STAR,
324
+ // and friends as their proper types here — otherwise the
325
+ // default number-matcher would lex `1` as #NR and the
326
+ // enclosing seq.close wouldn't recognise the digit as the
327
+ // start of a repetition prefix.
328
+ { s: '#TX', b: 1 },
329
+ { s: '#ST', b: 1 },
330
+ { s: '#NV', b: 1 },
331
+ { s: '#SS', b: 1 },
332
+ { s: '#SI', b: 1 },
333
+ { s: '#NUM', b: 1 },
334
+ { s: '#STAR', b: 1 },
335
+ { s: '#LP', b: 1 },
336
+ { s: '#OB', b: 1 },
337
+ { s: '#RP', b: 1 },
338
+ { s: '#CB', b: 1 },
339
+ { s: '#ALT', b: 1 },
340
+ { s: '#DEF', b: 1 },
341
+ { s: '#ZZ', b: 1 },
342
+ { b: 1 },
343
+ ],
344
+ },
345
+ };
346
+ exports.abnfRules = abnfRules;
347
+ // Lazily built tabnas instance that parses ABNF source. Deferred
348
+ // construction avoids a circular-import failure at module load time.
349
+ let _abnfParser = null;
350
+ function getAbnfParser() {
351
+ if (_abnfParser)
352
+ return _abnfParser;
353
+ const { Tabnas } = require('@tabnas/parser');
354
+ // ABNF defines its own grammar from scratch, so we don't load any
355
+ // grammar plugin — just use the bare engine with default tokens.
356
+ const j = new Tabnas({
357
+ rule: { start: 'abnf' },
358
+ fixed: {
359
+ token: {
360
+ // Clear JSON-oriented defaults we're not using so `:`, `,`
361
+ // and `{` have no special meaning inside ABNF source.
362
+ '#OS': null,
363
+ '#CS': null,
364
+ '#CL': null,
365
+ '#CA': null,
366
+ // Re-map `#OB` / `#CB` from JSON's `{` / `}` to ABNF's
367
+ // `[` / `]` optional-group brackets.
368
+ '#OB': '[',
369
+ '#CB': ']',
370
+ '#DEF': '=',
371
+ // `=/` — ABNF's incremental-alternatives operator. Longer
372
+ // than `=`, so tabnas's longest-match-wins fixed matcher
373
+ // tries it first.
374
+ '#DEFA': '=/',
375
+ '#ALT': '/',
376
+ '#STAR': '*',
377
+ '#LP': '(',
378
+ '#RP': ')',
379
+ },
380
+ },
381
+ match: {
382
+ token: {
383
+ // ABNF repetition counts: decimal integers.
384
+ '#NUM': /^[0-9]+/,
385
+ // ABNF numeric value notation:
386
+ // %xNN single hex code point
387
+ // %dNN single decimal code point
388
+ // %bNN single binary code point
389
+ // %xNN-NN hex range
390
+ // %xNN.NN.NN concatenated hex code points (= string)
391
+ // Digits are permissive (hex covers the decimal / binary
392
+ // subsets); `parseNumericValue` re-validates against the
393
+ // actual base.
394
+ '#NV': /^%[xdbXDB][0-9a-fA-F]+(?:[-.][0-9a-fA-F]+)*/,
395
+ // `%s` / `%i` prefixes on a quoted string. The lookahead
396
+ // requires `"` so they don't steal the `%` of `%xNN`.
397
+ '#SS': /^%[sS](?=")/,
398
+ '#SI': /^%[iI](?=")/,
399
+ },
400
+ },
401
+ tokenSet: {
402
+ // Tokens that can legitimately open an atom. Declaring this
403
+ // as a set lets elem.open use `#ATOM` inside its `s:` patterns
404
+ // — that way the tcol at the atom-starter position includes
405
+ // every matcher tin (notably #NV), so the lexer doesn't fall
406
+ // through to #TX when the actual atom is `%xNN`.
407
+ ATOM: ['#ST', '#NV', '#TX', '#LP', '#OB', '#SS', '#SI'],
408
+ },
409
+ comment: {
410
+ // ABNF uses `;` to start a line comment. Override tabnas's
411
+ // default `hash` definition (which used `#`) and disable the
412
+ // other comment styles so `//` and `/* */` aren't confused
413
+ // with the alternation operator.
414
+ def: {
415
+ hash: { line: true, start: ';', lex: true, eatline: false },
416
+ slash: null,
417
+ multi: null,
418
+ },
419
+ },
420
+ });
421
+ // Drop the default JSON rules — they would otherwise compete with
422
+ // ours for the starting token set.
423
+ const existing = j.rule();
424
+ for (const name of Object.keys(existing)) {
425
+ j.rule(name, null);
426
+ }
427
+ for (const name of Object.keys(abnfRules)) {
428
+ const spec = abnfRules[name];
429
+ j.rule(name, (rs) => {
430
+ if (spec.bo)
431
+ rs.bo(spec.bo);
432
+ if (spec.bc)
433
+ rs.bc(spec.bc);
434
+ if (spec.open)
435
+ rs.open(spec.open);
436
+ if (spec.close)
437
+ rs.close(spec.close);
438
+ });
439
+ }
440
+ _abnfParser = (src) => j.parse(src);
441
+ return _abnfParser;
442
+ }
443
+ // Rewrite a grammar so that the only element kinds remaining are
444
+ // `term` and `ref`. Each `X?`, `X*`, `X+` occurrence is replaced by a
445
+ // reference to a newly-generated helper production that expresses the
446
+ // same language in plain ABNF.
447
+ // Eliminate left recursion — both direct (P → P α) and indirect
448
+ // (P → Q α, Q → P β) — via Paull's algorithm.
449
+ //
450
+ // Order the productions, and for each A_i walk back over A_1..A_{i-1}
451
+ // inlining any leading reference into A_i's alternatives. Once the
452
+ // only remaining leading self-reference on A_i is direct, rewrite to
453
+ // the iterative form
454
+ // P → (β_1 | … | β_m) (α_1 | … | α_n)*
455
+ // which tabnas's push-down parser can execute without re-entering P
456
+ // at the same source position.
457
+ //
458
+ // The substitution step can duplicate alternatives, so pathological
459
+ // grammars will enlarge — caller is expected to keep the grammar
460
+ // reasonably small (this is a first-step converter, not a full
461
+ // toolchain).
462
+ function eliminateLeftRecursion(grammar) {
463
+ const originalOrder = grammar.productions.map((p) => p.name);
464
+ // Order productions so that rules referenced at a leading position
465
+ // are processed before the rules that reference them. Paull's
466
+ // substitution inlines A_j's alts into A_i for j < i, so putting
467
+ // dependencies first is what makes nullable-prefixed hidden left
468
+ // recursion reachable by the substitution step.
469
+ //
470
+ // Note: substitution here always runs, even for cycle-free
471
+ // grammars. The reason is pragmatic rather than theoretical —
472
+ // populating tcol from multi-token altPrefixes (needed so the
473
+ // lexer's regex matchers fire with the right tin in nested
474
+ // contexts) requires the full inlined shape. A future refactor
475
+ // could compute tcol from the un-substituted grammar and only
476
+ // apply Paull's to the cyclic SCCs, which would preserve more
477
+ // named-rule structure in the emitted AST.
478
+ let prods = topoOrderForPaull(grammar.productions.map((p) => ({
479
+ name: p.name,
480
+ alts: p.alts.map((a) => a.slice()),
481
+ nodeKind: p.nodeKind,
482
+ })));
483
+ for (let i = 0; i < prods.length; i++) {
484
+ // For each earlier production A_j, inline any alternative of
485
+ // A_i whose leading element is a reference to A_j.
486
+ for (let j = 0; j < i; j++) {
487
+ prods[i] = substituteLeadingRef(prods[i], prods[j]);
488
+ }
489
+ prods[i] = eliminateDirectLeftRec(prods[i]);
490
+ }
491
+ // Restore the caller's declared order, so the start rule still
492
+ // ends up first (and the user sees their rule names in a
493
+ // recognisable order when inspecting the spec).
494
+ const byName = new Map(prods.map((p) => [p.name, p]));
495
+ const ordered = [];
496
+ for (const name of originalOrder) {
497
+ const p = byName.get(name);
498
+ if (p) {
499
+ ordered.push(p);
500
+ byName.delete(name);
501
+ }
502
+ }
503
+ // Any generated productions created during substitution (none in
504
+ // the current implementation) would fall through here.
505
+ for (const p of byName.values())
506
+ ordered.push(p);
507
+ return { productions: ordered };
508
+ }
509
+ // Tarjan-flavoured SCC scan over the leading-reference graph:
510
+ // returns the names of productions that participate in at least one
511
+ // cycle (self-loop or longer). Used to scope Paull's substitution to
512
+ // only the rules that actually need it.
513
+ function findLeadingRefCycleMembers(prods) {
514
+ const byName = new Map(prods.map((p) => [p.name, p]));
515
+ const leadingRefs = (p) => {
516
+ const out = [];
517
+ for (const alt of p.alts) {
518
+ if (alt.length === 0)
519
+ continue;
520
+ const first = alt[0];
521
+ if (first.kind === 'ref' && byName.has(first.name))
522
+ out.push(first.name);
523
+ }
524
+ return out;
525
+ };
526
+ // Tarjan's SCC algorithm.
527
+ let index = 0;
528
+ const stack = [];
529
+ const onStack = new Set();
530
+ const indices = new Map();
531
+ const lowlinks = new Map();
532
+ const cyclic = new Set();
533
+ function strongConnect(name) {
534
+ indices.set(name, index);
535
+ lowlinks.set(name, index);
536
+ index++;
537
+ stack.push(name);
538
+ onStack.add(name);
539
+ const prod = byName.get(name);
540
+ if (prod) {
541
+ for (const target of leadingRefs(prod)) {
542
+ if (!indices.has(target)) {
543
+ strongConnect(target);
544
+ lowlinks.set(name, Math.min(lowlinks.get(name), lowlinks.get(target)));
545
+ }
546
+ else if (onStack.has(target)) {
547
+ lowlinks.set(name, Math.min(lowlinks.get(name), indices.get(target)));
548
+ }
549
+ }
550
+ }
551
+ if (lowlinks.get(name) === indices.get(name)) {
552
+ // Pop the SCC. If it has more than one member, or it's a
553
+ // single member with a self-loop, mark as cyclic.
554
+ const scc = [];
555
+ let w;
556
+ do {
557
+ w = stack.pop();
558
+ onStack.delete(w);
559
+ scc.push(w);
560
+ } while (w !== name);
561
+ const isCycle = scc.length > 1 ||
562
+ (scc.length === 1 && leadingRefs(byName.get(scc[0])).includes(scc[0]));
563
+ if (isCycle)
564
+ for (const n of scc)
565
+ cyclic.add(n);
566
+ }
567
+ }
568
+ for (const p of prods) {
569
+ if (!indices.has(p.name))
570
+ strongConnect(p.name);
571
+ }
572
+ return cyclic;
573
+ }
574
+ // Topological order over the "leading-position reference" graph:
575
+ // an edge A → B exists when A has at least one alternative whose
576
+ // first element is a reference to B. Cycles are preserved as-is
577
+ // (Paull's handles them via the substitution + direct-LR rewrite).
578
+ function topoOrderForPaull(prods) {
579
+ const byName = new Map(prods.map((p) => [p.name, p]));
580
+ const colour = new Map(); // 0 unseen, 1 in-progress, 2 done
581
+ const order = [];
582
+ function visit(name) {
583
+ const c = colour.get(name) ?? 0;
584
+ if (c !== 0)
585
+ return; // already seen or on the current path
586
+ colour.set(name, 1);
587
+ const p = byName.get(name);
588
+ if (p) {
589
+ for (const alt of p.alts) {
590
+ if (alt.length > 0 && alt[0].kind === 'ref' && byName.has(alt[0].name)) {
591
+ visit(alt[0].name);
592
+ }
593
+ }
594
+ colour.set(name, 2);
595
+ order.push(p);
596
+ }
597
+ else {
598
+ colour.set(name, 2);
599
+ }
600
+ }
601
+ for (const p of prods)
602
+ visit(p.name);
603
+ return order;
604
+ }
605
+ // For every alternative of `target` that begins with a ref to
606
+ // `source`, replace that alt with |source.alts| copies — each one
607
+ // with the leading source-ref expanded to one of source's alts.
608
+ function substituteLeadingRef(target, source) {
609
+ const newAlts = [];
610
+ for (const alt of target.alts) {
611
+ if (alt.length > 0 &&
612
+ alt[0].kind === 'ref' &&
613
+ alt[0].name === source.name) {
614
+ const tail = alt.slice(1);
615
+ for (const srcAlt of source.alts) {
616
+ newAlts.push([...srcAlt, ...tail]);
617
+ }
618
+ }
619
+ else {
620
+ newAlts.push(alt);
621
+ }
622
+ }
623
+ return { name: target.name, alts: newAlts, nodeKind: target.nodeKind };
624
+ }
625
+ // Rewrite a single production's direct left recursion to its
626
+ // iterative equivalent. Equivalent to the previous version of
627
+ // `eliminateLeftRecursion` but scoped to one production.
628
+ function eliminateDirectLeftRec(prod) {
629
+ const recursive = [];
630
+ const seeds = [];
631
+ for (const alt of prod.alts) {
632
+ if (alt.length > 0 &&
633
+ alt[0].kind === 'ref' &&
634
+ alt[0].name === prod.name) {
635
+ recursive.push(alt.slice(1));
636
+ }
637
+ else {
638
+ seeds.push(alt);
639
+ }
640
+ }
641
+ // A trivial recursive alt `[P]` (P ::= P, nothing else) would
642
+ // derive P from P with no progress — semantically a no-op. Drop
643
+ // them silently, since nullable-prefix expansion in Paull's can
644
+ // legitimately produce them and erroring would hide a legal
645
+ // grammar.
646
+ const nonTrivialRecursive = recursive.filter((t) => t.length > 0);
647
+ if (nonTrivialRecursive.length === 0) {
648
+ // Either no recursion at all, or only trivial self-refs — keep
649
+ // just the seeds.
650
+ return { name: prod.name, alts: seeds, nodeKind: prod.nodeKind };
651
+ }
652
+ if (seeds.length === 0) {
653
+ throw new Error(`abnf: rule '${prod.name}' is purely left-recursive ` +
654
+ `(no seed alternative); cannot eliminate`);
655
+ }
656
+ const seedElement = seeds.length === 1 && seeds[0].length === 1
657
+ ? seeds[0][0]
658
+ : { kind: 'group', alts: seeds };
659
+ const tailInner = nonTrivialRecursive.length === 1 && nonTrivialRecursive[0].length === 1
660
+ ? nonTrivialRecursive[0][0]
661
+ : { kind: 'group', alts: nonTrivialRecursive };
662
+ return {
663
+ name: prod.name,
664
+ alts: [[seedElement, { kind: 'star', inner: tailInner }]],
665
+ nodeKind: prod.nodeKind,
666
+ };
667
+ }
668
+ function desugar(grammar) {
669
+ const extra = [];
670
+ const used = new Set(grammar.productions.map((p) => p.name));
671
+ function freshName(hint) {
672
+ // Collision-avoiding name like `_gen1`, `_gen2`, …
673
+ let i = extra.length;
674
+ let name;
675
+ do {
676
+ i++;
677
+ name = `_gen${i}_${hint}`;
678
+ } while (used.has(name));
679
+ used.add(name);
680
+ return name;
681
+ }
682
+ function desugarAlt(alt) {
683
+ return alt.map(desugarElement);
684
+ }
685
+ function desugarElement(el) {
686
+ if (el.kind === 'term' || el.kind === 'ref' || el.kind === 'regex') {
687
+ return el;
688
+ }
689
+ if (el.kind === 'group') {
690
+ // Recurse into the group's alts so nested sugar is flattened,
691
+ // then emit a helper production whose body is those alts.
692
+ const innerAlts = el.alts.map((a) => desugarAlt(a));
693
+ const name = freshName('group');
694
+ extra.push({ name, alts: innerAlts, nodeKind: 'helper' });
695
+ return { kind: 'ref', name };
696
+ }
697
+ // `opt`, `star`, `plus` all wrap a single inner element.
698
+ const inner = desugarElement(el.inner);
699
+ const hint = inner.kind === 'ref' ? inner.name :
700
+ inner.kind === 'term' ? 'term' : 'x';
701
+ if (el.kind === 'opt') {
702
+ // H ::= inner | (empty)
703
+ const name = freshName('opt_' + hint);
704
+ extra.push({ name, alts: [[inner], []], nodeKind: 'helper' });
705
+ return { kind: 'ref', name };
706
+ }
707
+ if (el.kind === 'star') {
708
+ // H = inner H / (empty)
709
+ const name = freshName('star_' + hint);
710
+ const selfRef = { kind: 'ref', name };
711
+ extra.push({ name, alts: [[inner, selfRef], []], nodeKind: 'helper' });
712
+ return { kind: 'ref', name };
713
+ }
714
+ if (el.kind === 'plus') {
715
+ // H = inner Tail where Tail = inner Tail / (empty)
716
+ const tailName = freshName('star_' + hint);
717
+ const plusName = freshName('plus_' + hint);
718
+ const tailRef = { kind: 'ref', name: tailName };
719
+ extra.push({
720
+ name: tailName,
721
+ alts: [[inner, tailRef], []],
722
+ nodeKind: 'helper',
723
+ });
724
+ extra.push({
725
+ name: plusName,
726
+ alts: [[inner, tailRef]],
727
+ nodeKind: 'helper',
728
+ });
729
+ return { kind: 'ref', name: plusName };
730
+ }
731
+ // ABNF m*n bounded repetition. Desugars to a concatenation of
732
+ // `min` mandatory copies of the inner element followed by a
733
+ // tail that accepts up to `(max - min)` more.
734
+ // m*n A => A{m} [A[A[A...[A]]]] (nested optionals)
735
+ // m* A => A{m} *A (mandatory prefix + star)
736
+ // *n A => [A [A ... [A]]] (n nested optionals)
737
+ // The helper's single alt has `min` repetitions of inner, then
738
+ // either a star-helper for (min, ∞) or `max - min` nested
739
+ // optionals for a finite range.
740
+ const { min, max } = el;
741
+ const repName = freshName('rep_' + hint);
742
+ const repAlt = [];
743
+ for (let i = 0; i < min; i++)
744
+ repAlt.push(inner);
745
+ if (max === Infinity) {
746
+ // Tail: unbounded star of inner.
747
+ const tailStarName = freshName('star_' + hint);
748
+ const tailStarRef = { kind: 'ref', name: tailStarName };
749
+ extra.push({
750
+ name: tailStarName,
751
+ alts: [[inner, tailStarRef], []],
752
+ nodeKind: 'helper',
753
+ });
754
+ repAlt.push(tailStarRef);
755
+ }
756
+ else {
757
+ // Nest (max - min) optionals: [A [A [A ...]]].
758
+ let nested = [];
759
+ for (let i = 0; i < max - min; i++) {
760
+ // Wrap current `nested` into an optional and prepend `inner`.
761
+ if (nested.length === 0) {
762
+ nested = [{ kind: 'opt', inner: { kind: 'group', alts: [[inner]] } }];
763
+ }
764
+ else {
765
+ nested = [{
766
+ kind: 'opt',
767
+ inner: { kind: 'group', alts: [[inner, ...nested]] },
768
+ }];
769
+ }
770
+ }
771
+ repAlt.push(...nested);
772
+ }
773
+ extra.push({ name: repName, alts: [desugarAlt(repAlt)], nodeKind: 'helper' });
774
+ return { kind: 'ref', name: repName };
775
+ }
776
+ const rewritten = grammar.productions.map((p) => {
777
+ const out = {
778
+ name: p.name,
779
+ alts: p.alts.map(desugarAlt),
780
+ nodeKind: p.nodeKind,
781
+ };
782
+ // Probe-dispatch flags survive desugar unchanged — the emitter
783
+ // routes around the standard alt-compilation path for these.
784
+ if (p.probeDispatch)
785
+ out.probeDispatch = p.probeDispatch;
786
+ if (p.probeHelper)
787
+ out.probeHelper = p.probeHelper;
788
+ return out;
789
+ });
790
+ return { productions: [...rewritten, ...extra] };
791
+ }
792
+ // Error raised when the ABNF source itself can't be parsed. Surfaces
793
+ // line and column from the underlying tabnas error so the caller can
794
+ // report them directly. The original error is kept on `.cause`.
795
+ class AbnfParseError extends Error {
796
+ constructor(message, location, cause) {
797
+ super(message);
798
+ this.name = 'AbnfParseError';
799
+ this.line = location?.line;
800
+ this.column = location?.column;
801
+ this.cause = cause;
802
+ }
803
+ }
804
+ exports.AbnfParseError = AbnfParseError;
805
+ // Parse ABNF source into a grammar AST via the tabnas-based parser.
806
+ function parseAbnf(src) {
807
+ const parser = getAbnfParser();
808
+ let productions;
809
+ try {
810
+ productions = parser(src) ?? [];
811
+ }
812
+ catch (e) {
813
+ // TabnasError carries `lineNumber` / `columnNumber`; fall back to
814
+ // ad-hoc extraction from the error message otherwise.
815
+ const line = e?.lineNumber ?? e?.row;
816
+ const column = e?.columnNumber ?? e?.col;
817
+ const loc = (line != null && column != null)
818
+ ? ` at line ${line}, column ${column}`
819
+ : '';
820
+ const raw = e?.message ? String(e.message).split('\n')[0] : String(e);
821
+ throw new AbnfParseError(`abnf: parse error${loc}: ${raw}`, { line, column }, e);
822
+ }
823
+ if (!Array.isArray(productions) || productions.length === 0) {
824
+ throw new AbnfParseError('abnf: no productions found');
825
+ }
826
+ const merged = mergeIncrementals(productions);
827
+ return { productions: withCoreRules(merged) };
828
+ }
829
+ // RFC 5234 Appendix B.1 core rules. Parsed lazily on first use
830
+ // and spliced into any user grammar that references them but
831
+ // doesn't define them locally.
832
+ const CORE_RULES_ABNF = `
833
+ ALPHA = %x41-5A / %x61-7A
834
+ BIT = "0" / "1"
835
+ CHAR = %x01-7F
836
+ CR = %x0D
837
+ LF = %x0A
838
+ CRLF = CR LF
839
+ CTL = %x00-1F / %x7F
840
+ DIGIT = %x30-39
841
+ DQUOTE = %x22
842
+ HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F"
843
+ HTAB = %x09
844
+ OCTET = %x00-FF
845
+ SP = %x20
846
+ VCHAR = %x21-7E
847
+ WSP = SP / HTAB
848
+ `;
849
+ let _coreRules = null;
850
+ function getCoreRules() {
851
+ if (_coreRules)
852
+ return _coreRules;
853
+ const parser = getAbnfParser();
854
+ const raw = parser(CORE_RULES_ABNF);
855
+ // Core rules flatten to `src` in the output AST — they're
856
+ // character-class bricks, not structural nodes users want to see
857
+ // one-per-matched-character.
858
+ for (const p of raw)
859
+ p.nodeKind = 'core';
860
+ _coreRules = new Map(raw.map((p) => [p.name, p]));
861
+ return _coreRules;
862
+ }
863
+ function refsIn(alt, out) {
864
+ for (const el of alt) {
865
+ if (el.kind === 'ref')
866
+ out.add(el.name);
867
+ else if (el.kind === 'opt' || el.kind === 'star' ||
868
+ el.kind === 'plus' || el.kind === 'rep') {
869
+ refsIn([el.inner], out);
870
+ }
871
+ else if (el.kind === 'group') {
872
+ for (const a of el.alts)
873
+ refsIn(a, out);
874
+ }
875
+ }
876
+ }
877
+ // Add each RFC 5234 core rule that the user's grammar references
878
+ // but doesn't define locally. Resolution is transitive: if the
879
+ // user mentions HEXDIG, DIGIT is pulled in too. User definitions
880
+ // always win — a local `DIGIT = …` is left untouched.
881
+ function withCoreRules(user) {
882
+ const core = getCoreRules();
883
+ const defined = new Set(user.map((p) => p.name));
884
+ const needed = new Set();
885
+ const scan = (prods) => {
886
+ for (const p of prods) {
887
+ for (const alt of p.alts)
888
+ refsIn(alt, needed);
889
+ }
890
+ };
891
+ scan(user);
892
+ const out = [];
893
+ // Transitively add core rules, in declaration order.
894
+ let added = true;
895
+ while (added) {
896
+ added = false;
897
+ for (const [name, prod] of core) {
898
+ if (defined.has(name))
899
+ continue;
900
+ if (!needed.has(name))
901
+ continue;
902
+ defined.add(name);
903
+ out.push(prod);
904
+ scan([prod]);
905
+ added = true;
906
+ }
907
+ }
908
+ return [...user, ...out];
909
+ }
910
+ // Fold every `name =/ alt` production into the earlier production
911
+ // with the same name by appending its alternatives. Throws if an
912
+ // incremental references a name that hasn't been defined yet — ABNF
913
+ // requires the base production to appear first.
914
+ function mergeIncrementals(prods) {
915
+ const out = [];
916
+ const byName = new Map();
917
+ for (const p of prods) {
918
+ if (p.incremental) {
919
+ const base = byName.get(p.name);
920
+ if (!base) {
921
+ throw new AbnfParseError(`abnf: '${p.name} =/ …' has no earlier '${p.name} = …' to extend`);
922
+ }
923
+ base.alts.push(...p.alts);
924
+ continue;
925
+ }
926
+ // Strip the (absent) flag on a cleanly-written production so
927
+ // downstream code never sees it.
928
+ const clean = { name: p.name, alts: p.alts };
929
+ if (p.nodeKind)
930
+ clean.nodeKind = p.nodeKind;
931
+ out.push(clean);
932
+ byName.set(p.name, clean);
933
+ }
934
+ return out;
935
+ }
936
+ // -- Probe-dispatch analyser + rewriter -----------------------------
937
+ //
938
+ // ABNF has a large family of grammars that aren't LL(k) for any
939
+ // bounded k. The canonical example is RFC 3986's `authority`:
940
+ //
941
+ // authority = [ userinfo "@" ] host [ ":" port ]
942
+ // userinfo = *( unreserved / pct-encoded / sub-delims / ":" )
943
+ // host = IP-literal / IPv4address / reg-name
944
+ // reg-name = *( unreserved / pct-encoded / sub-delims )
945
+ //
946
+ // `userinfo` and `reg-name` share a character vocabulary, so a
947
+ // FIRST-set dispatcher can't decide which branch the optional
948
+ // `[ userinfo "@" ]` belongs to — the disambiguating `@` can be
949
+ // arbitrarily far from the start.
950
+ //
951
+ // For the common pattern `[X D] Y` — an optional group whose body
952
+ // ends with a terminal D, followed by a sequence Y whose leading
953
+ // terminals overlap with X's — we handle the ambiguity by rewriting
954
+ // the rule to a probe+phase-retry dispatcher:
955
+ //
956
+ // 1. On first entry (phase 0), mark the token position and push a
957
+ // failure-proof probe rule that greedily consumes every token
958
+ // in the joint vocabulary of X and Y.
959
+ // 2. When the probe returns, peek ctx.t[0]:
960
+ // D seen → phase = 1 (take the `X D Y` branch)
961
+ // D absent → phase = 2 (take the `Y` branch)
962
+ // Rewind to the mark and `r:` back into the dispatcher.
963
+ // 3. The dispatcher's open has a `c:`-guarded alt for each phase
964
+ // that pushes the corresponding committed branch.
965
+ //
966
+ // The primitives used (`r:`, `k:`, `c:`, `ctx.mark`, `ctx.rewind`,
967
+ // `ctx.t`) are the same building blocks rules/parser already exposes
968
+ // — no new tabnas machinery is needed.
969
+ // Predicate: element is `[ X D ]` where X is one or more elements
970
+ // and D is a terminal literal or a regex terminal.
971
+ function isProbeableOpt(el) {
972
+ if (el.kind !== 'opt')
973
+ return null;
974
+ const inner = el.inner;
975
+ if (inner.kind !== 'group')
976
+ return null;
977
+ if (inner.alts.length !== 1)
978
+ return null;
979
+ const seq = inner.alts[0];
980
+ if (seq.length < 2)
981
+ return null;
982
+ const last = seq[seq.length - 1];
983
+ if (last.kind !== 'term' && last.kind !== 'regex')
984
+ return null;
985
+ return { xSeq: seq.slice(0, -1), disambiguator: last };
986
+ }
987
+ // Union of every terminal reachable by walking an element's subtree,
988
+ // following refs transitively. Cycles are broken by the visited set.
989
+ // Returns terminals as AbnfElements so the caller isn't tied to the
990
+ // emitter's token-allocation step.
991
+ function collectTerminalVocabElements(el, grammar, out, visited) {
992
+ if (el.kind === 'term') {
993
+ const k = termKey(el);
994
+ if (!out.has(k))
995
+ out.set(k, el);
996
+ return;
997
+ }
998
+ if (el.kind === 'regex') {
999
+ const k = regexKey(el);
1000
+ if (!out.has(k))
1001
+ out.set(k, el);
1002
+ return;
1003
+ }
1004
+ if (el.kind === 'ref') {
1005
+ if (visited.has(el.name))
1006
+ return;
1007
+ visited.add(el.name);
1008
+ const prod = grammar.productions.find((p) => p.name === el.name);
1009
+ if (!prod)
1010
+ return;
1011
+ for (const alt of prod.alts)
1012
+ for (const sub of alt)
1013
+ collectTerminalVocabElements(sub, grammar, out, visited);
1014
+ return;
1015
+ }
1016
+ if (el.kind === 'opt' || el.kind === 'star' || el.kind === 'plus' ||
1017
+ el.kind === 'rep') {
1018
+ collectTerminalVocabElements(el.inner, grammar, out, visited);
1019
+ return;
1020
+ }
1021
+ if (el.kind === 'group') {
1022
+ for (const alt of el.alts)
1023
+ for (const sub of alt)
1024
+ collectTerminalVocabElements(sub, grammar, out, visited);
1025
+ return;
1026
+ }
1027
+ }
1028
+ function collectSeqVocabElements(seq, grammar) {
1029
+ const out = new Map();
1030
+ const visited = new Set();
1031
+ for (const el of seq)
1032
+ collectTerminalVocabElements(el, grammar, out, visited);
1033
+ return out;
1034
+ }
1035
+ function mapsOverlap(a, b) {
1036
+ for (const x of a.keys())
1037
+ if (b.has(x))
1038
+ return true;
1039
+ return false;
1040
+ }
1041
+ // Rewrite every ambiguous `[X D] Y` subsequence in `grammar` into a
1042
+ // probe-dispatch pattern. The grammar at this point still has `opt`,
1043
+ // `group`, `star`, `plus`, `rep` sugar — intentionally, since that's
1044
+ // where the pattern is easy to recognise. Runs BEFORE token
1045
+ // allocation; probe metadata stores AbnfElements, and the emitter
1046
+ // resolves them to token names at emit time.
1047
+ function rewriteProbeDispatches(grammar) {
1048
+ const reports = grammar.ambiguities ?? [];
1049
+ const extra = [];
1050
+ const used = new Set(grammar.productions.map((p) => p.name));
1051
+ function freshName(hint) {
1052
+ let name = hint;
1053
+ let i = 1;
1054
+ while (used.has(name)) {
1055
+ name = hint + i;
1056
+ i++;
1057
+ }
1058
+ used.add(name);
1059
+ return name;
1060
+ }
1061
+ const rewritten = [];
1062
+ for (const prod of grammar.productions) {
1063
+ let newAlts = [];
1064
+ let touched = false;
1065
+ for (let altIdx = 0; altIdx < prod.alts.length; altIdx++) {
1066
+ const alt = prod.alts[altIdx];
1067
+ let resultAlt = [];
1068
+ for (let i = 0; i < alt.length; i++) {
1069
+ const el = alt[i];
1070
+ const info = isProbeableOpt(el);
1071
+ if (!info) {
1072
+ resultAlt.push(el);
1073
+ continue;
1074
+ }
1075
+ const ySeq = alt.slice(i + 1);
1076
+ if (ySeq.length === 0) {
1077
+ // `[X D]` is the last thing in the alt — nothing follows, so
1078
+ // there's nothing to disambiguate against. Standard emit.
1079
+ resultAlt.push(el);
1080
+ continue;
1081
+ }
1082
+ const xVocab = collectSeqVocabElements(info.xSeq, grammar);
1083
+ const yVocab = collectSeqVocabElements(ySeq, grammar);
1084
+ if (!mapsOverlap(xVocab, yVocab)) {
1085
+ // The optional's leading tokens don't overlap with the tail's
1086
+ // leading tokens, so the normal FIRST-based dispatcher can
1087
+ // decide. No rewrite needed.
1088
+ resultAlt.push(el);
1089
+ continue;
1090
+ }
1091
+ // Joint vocab: union of everything the probe might need to
1092
+ // consume. Includes the disambiguator, which we then remove so
1093
+ // the probe stops on it and the peek works.
1094
+ const vocab = new Map([...xVocab, ...yVocab]);
1095
+ const d = info.disambiguator;
1096
+ const dKey = d.kind === 'term' ? termKey(d)
1097
+ : d.kind === 'regex' ? regexKey(d)
1098
+ : null;
1099
+ if (dKey)
1100
+ vocab.delete(dKey);
1101
+ const dispatchName = freshName(`${prod.name}$pd${i}`);
1102
+ const probeName = freshName(`${dispatchName}$probe`);
1103
+ const withName = freshName(`${dispatchName}$with`);
1104
+ const noName = freshName(`${dispatchName}$no`);
1105
+ // Synthesise the probe helper.
1106
+ extra.push({
1107
+ name: probeName,
1108
+ alts: [],
1109
+ probeHelper: { vocabElements: [...vocab.values()] },
1110
+ nodeKind: 'helper',
1111
+ });
1112
+ // Synthesise the committed branches. `with` = X D Y, `no` = Y.
1113
+ extra.push({
1114
+ name: withName,
1115
+ alts: [[...info.xSeq, info.disambiguator, ...ySeq]],
1116
+ nodeKind: 'helper',
1117
+ });
1118
+ extra.push({
1119
+ name: noName,
1120
+ alts: [ySeq],
1121
+ nodeKind: 'helper',
1122
+ });
1123
+ // Synthesise the dispatcher. The `alts` list is a "virtual"
1124
+ // spec — two ref-only alts — that exists solely to feed
1125
+ // computeFirstSets the right FIRST/nullable answers (FIRST
1126
+ // = FIRST(with) ∪ FIRST(no)). The emitter checks
1127
+ // `probeDispatch` first and emits the phase-retry body
1128
+ // instead of compiling `alts`.
1129
+ extra.push({
1130
+ name: dispatchName,
1131
+ alts: [
1132
+ [{ kind: 'ref', name: withName }],
1133
+ [{ kind: 'ref', name: noName }],
1134
+ ],
1135
+ probeDispatch: {
1136
+ probeRule: probeName,
1137
+ disambiguator: info.disambiguator,
1138
+ withBranch: withName,
1139
+ noBranch: noName,
1140
+ },
1141
+ nodeKind: 'helper',
1142
+ });
1143
+ reports.push({
1144
+ rule: prod.name, altIdx, optIdx: i,
1145
+ reason: `optional prefix shares vocabulary with tail`,
1146
+ resolved: true,
1147
+ });
1148
+ resultAlt.push({ kind: 'ref', name: dispatchName });
1149
+ // Everything that followed the opt is now inside the dispatcher
1150
+ // (withBranch / noBranch), so skip the rest of the alt.
1151
+ i = alt.length;
1152
+ touched = true;
1153
+ }
1154
+ newAlts.push(resultAlt);
1155
+ }
1156
+ if (touched) {
1157
+ rewritten.push({
1158
+ name: prod.name,
1159
+ alts: newAlts,
1160
+ nodeKind: prod.nodeKind,
1161
+ });
1162
+ }
1163
+ else {
1164
+ rewritten.push(prod);
1165
+ }
1166
+ }
1167
+ return {
1168
+ productions: [...rewritten, ...extra],
1169
+ ambiguities: reports,
1170
+ };
1171
+ }
1172
+ // Emit a probe helper production. A self-looping rule that matches any
1173
+ // one of the vocab tokens and restarts; a final empty-alt fallback
1174
+ // ensures the rule NEVER fails — if the current lookahead isn't in the
1175
+ // vocab (or we're at #ZZ), the rule pops cleanly. This is the
1176
+ // failure-proof property the probe pattern relies on.
1177
+ function emitProbeHelper(prod, tag, ruleSpec, literals, regexTokens) {
1178
+ const elems = prod.probeHelper.vocabElements;
1179
+ const opens = [];
1180
+ for (const el of elems) {
1181
+ const tok = el.kind === 'term'
1182
+ ? literals.get(termKey(el))
1183
+ : el.kind === 'regex' ? regexTokens.get(regexKey(el))
1184
+ : undefined;
1185
+ if (tok)
1186
+ opens.push({ s: tok, r: prod.name, g: tag });
1187
+ }
1188
+ // Empty fallback — pops without consuming anything. Must be last.
1189
+ opens.push({ g: tag });
1190
+ ruleSpec[prod.name] = { open: opens };
1191
+ }
1192
+ // Emit a probe-dispatch production. Encodes the three-phase retry
1193
+ // pattern; uses only standard tabnas primitives (r:, p:, c:, k:,
1194
+ // ctx.mark/rewind/t).
1195
+ function emitProbeDispatch(prod, tag, ruleSpec, refs, literals, regexTokens, useBuiltins) {
1196
+ const { probeRule, disambiguator, withBranch, noBranch } = prod.probeDispatch;
1197
+ const disambiguatorToken = disambiguator.kind === 'term'
1198
+ ? literals.get(termKey(disambiguator))
1199
+ : disambiguator.kind === 'regex'
1200
+ ? regexTokens.get(regexKey(disambiguator))
1201
+ : undefined;
1202
+ if (!disambiguatorToken) {
1203
+ throw new Error(`abnf: probe-dispatch rule '${prod.name}' has unresolvable ` +
1204
+ `disambiguator (kind=${disambiguator.kind})`);
1205
+ }
1206
+ // `bubble` lifts the committed child's node up — pure tree-building
1207
+ // (a `@bubble$` builtin or a closure, per refs mode; dropped in
1208
+ // recognition mode either way).
1209
+ const bubbleFields = refs.bubble((r) => {
1210
+ if (r.child && r.child.node !== undefined)
1211
+ r.node = r.child.node;
1212
+ });
1213
+ if (useBuiltins) {
1214
+ // Function-free dispatcher: control logic is engine `$`-builtins,
1215
+ // the disambiguator token rides in `k` config. See
1216
+ // docs/design/alt-action-refs.md §6.3.
1217
+ ruleSpec[prod.name] = {
1218
+ open: [
1219
+ { c: '@probePhase0$', a: '@probeInit$', p: probeRule,
1220
+ k: { pd_d: disambiguatorToken }, g: tag },
1221
+ { c: '@probePhase1$', p: withBranch, g: tag },
1222
+ { c: '@probePhase2$', p: noBranch, g: tag },
1223
+ ],
1224
+ close: [
1225
+ { c: '@probePhase0$', a: '@probeDecide$', r: prod.name, g: tag },
1226
+ { ...bubbleFields, g: tag },
1227
+ ],
1228
+ };
1229
+ return;
1230
+ }
1231
+ const initMark = refs.register((r, ctx) => {
1232
+ r.k.pd_phase = 0;
1233
+ r.k.pd_mark = ctx.mark();
1234
+ });
1235
+ const decide = refs.register((r, ctx) => {
1236
+ // ctx.t[0] is the first token the probe didn't consume. The probe
1237
+ // never fails, so this always reflects a real position.
1238
+ const peek = ctx.t[0];
1239
+ ctx.rewind(r.k.pd_mark);
1240
+ const matched = peek && peek.name === disambiguatorToken;
1241
+ r.k.pd_phase = matched ? 1 : 2;
1242
+ });
1243
+ ruleSpec[prod.name] = {
1244
+ open: [
1245
+ // Phase 0 — first pass: mark and probe.
1246
+ {
1247
+ c: refs.register((r) => !r.k.pd_phase),
1248
+ a: initMark,
1249
+ p: probeRule,
1250
+ g: tag,
1251
+ },
1252
+ // Phase 1 — disambiguator was seen: commit to X D Y.
1253
+ {
1254
+ c: refs.register((r) => r.k.pd_phase === 1),
1255
+ p: withBranch,
1256
+ g: tag,
1257
+ },
1258
+ // Phase 2 — disambiguator was not seen: commit to Y alone.
1259
+ {
1260
+ c: refs.register((r) => r.k.pd_phase === 2),
1261
+ p: noBranch,
1262
+ g: tag,
1263
+ },
1264
+ ],
1265
+ close: [
1266
+ // Phase 0 close: decide phase based on peek, rewind, retry self.
1267
+ {
1268
+ c: refs.register((r) => r.k.pd_phase === 0),
1269
+ a: decide,
1270
+ r: prod.name,
1271
+ g: tag,
1272
+ },
1273
+ // Phase 1 / 2 close: lift the committed child's node up.
1274
+ { ...bubbleFields, g: tag },
1275
+ ],
1276
+ };
1277
+ }
1278
+ // Convert an ABNF grammar AST into a tabnas GrammarSpec.
1279
+ function emitGrammarSpec(grammar, opts) {
1280
+ const start = opts?.start ?? grammar.productions[0].name;
1281
+ const tag = opts?.tag ?? 'abnf';
1282
+ // Eliminate direct left recursion (P → P α | β) by rewriting to
1283
+ // the equivalent right-recursive form P → β (α)*, then detect
1284
+ // ambiguous `[X D] Y` optional-prefix patterns and rewrite them
1285
+ // into probe-dispatch helpers; finally flatten any EBNF sugar
1286
+ // (`?`, `*`, `+`, grouping) into plain ABNF.
1287
+ grammar = eliminateLeftRecursion(grammar);
1288
+ grammar = rewriteProbeDispatches(grammar);
1289
+ grammar = desugar(grammar);
1290
+ // Allocate a fixed token for each unique literal, and a match
1291
+ // token for each unique regex terminal. Literals are keyed by
1292
+ // (literal, effective-case-sensitivity) so a `%s"foo"` (sensitive)
1293
+ // and a bare `"foo"` (insensitive) produce distinct tokens.
1294
+ const literals = new Map(); // literal-key -> token name
1295
+ const regexTokens = new Map(); // regex key -> token name
1296
+ const usedNames = new Set();
1297
+ const fixedTokens = {};
1298
+ const matchTokens = {};
1299
+ for (const prod of grammar.productions) {
1300
+ for (const alt of prod.alts) {
1301
+ for (const el of alt) {
1302
+ if (el.kind === 'term') {
1303
+ const key = termKey(el);
1304
+ if (!literals.has(key)) {
1305
+ const name = allocTokenName(el.literal, usedNames);
1306
+ literals.set(key, name);
1307
+ if (isEffectivelyCaseSensitive(el)) {
1308
+ fixedTokens[name] = el.literal;
1309
+ }
1310
+ else {
1311
+ // Insensitive literal with at least one letter — emit
1312
+ // as an anchored regex with the `i` flag. Mark the
1313
+ // matcher `eager$` so tabnas's lexer fires it even
1314
+ // when the current rule's tcol doesn't list its tin.
1315
+ const re = new RegExp('^' + escapeRegExp(el.literal), 'i');
1316
+ re.eager$ = true;
1317
+ matchTokens[name] = re;
1318
+ }
1319
+ }
1320
+ }
1321
+ else if (el.kind === 'regex') {
1322
+ const key = regexKey(el);
1323
+ if (!regexTokens.has(key)) {
1324
+ const name = allocTokenName('rx_' + el.pattern, usedNames);
1325
+ regexTokens.set(key, name);
1326
+ matchTokens[name] = new RegExp('^' + el.pattern, el.flags);
1327
+ }
1328
+ }
1329
+ }
1330
+ }
1331
+ // Probe-helper productions store their vocab as AbnfElements —
1332
+ // walk those too so the required tokens get allocated.
1333
+ if (prod.probeHelper) {
1334
+ for (const el of prod.probeHelper.vocabElements) {
1335
+ if (el.kind === 'term') {
1336
+ const key = termKey(el);
1337
+ if (!literals.has(key)) {
1338
+ const name = allocTokenName(el.literal, usedNames);
1339
+ literals.set(key, name);
1340
+ if (isEffectivelyCaseSensitive(el)) {
1341
+ fixedTokens[name] = el.literal;
1342
+ }
1343
+ else {
1344
+ const re = new RegExp('^' + escapeRegExp(el.literal), 'i');
1345
+ re.eager$ = true;
1346
+ matchTokens[name] = re;
1347
+ }
1348
+ }
1349
+ }
1350
+ else if (el.kind === 'regex') {
1351
+ const key = regexKey(el);
1352
+ if (!regexTokens.has(key)) {
1353
+ const name = allocTokenName('rx_' + el.pattern, usedNames);
1354
+ regexTokens.set(key, name);
1355
+ matchTokens[name] = new RegExp('^' + el.pattern, el.flags);
1356
+ }
1357
+ }
1358
+ }
1359
+ }
1360
+ }
1361
+ const knownRules = new Set(grammar.productions.map((p) => p.name));
1362
+ const { firstSets, nullable } = computeFirstSets(grammar, literals, regexTokens);
1363
+ const refs = new RefRegistry();
1364
+ refs.useBuiltins = !!opts?.builtins;
1365
+ refs.emitMarks = !!opts?.marks;
1366
+ const ruleSpec = {};
1367
+ for (const prod of grammar.productions) {
1368
+ if (prod.probeHelper) {
1369
+ emitProbeHelper(prod, tag, ruleSpec, literals, regexTokens);
1370
+ continue;
1371
+ }
1372
+ if (prod.probeDispatch) {
1373
+ emitProbeDispatch(prod, tag, ruleSpec, refs, literals, regexTokens, !!opts?.builtins);
1374
+ continue;
1375
+ }
1376
+ // Standard path: a (possibly single-segment) set of alternatives
1377
+ // compiled to tabnas alts. Simple alts collapse into `open` alts
1378
+ // directly; multi-segment alts emit a chain of aux rules.
1379
+ emitProduction(prod, grammar, literals, regexTokens, knownRules, tag, ruleSpec, firstSets, nullable, refs);
1380
+ }
1381
+ // Wrap the user-visible start rule in a synthetic rule that
1382
+ // explicitly consumes #ZZ. Without this, a user rule that pops
1383
+ // without matching the end-of-source token lets trailing content
1384
+ // slip past tabnas's post-loop endtkn check (the lookahead buffer
1385
+ // outlives the parse loop).
1386
+ const startWrapper = '__start__';
1387
+ ruleSpec[startWrapper] = {
1388
+ open: [{
1389
+ p: start,
1390
+ g: tag,
1391
+ }],
1392
+ close: [{
1393
+ s: '#ZZ',
1394
+ // Return the start rule's AST node directly — the `__start__`
1395
+ // wrapper exists only to ensure end-of-source gets consumed.
1396
+ // The caller of `tabnas(src)` receives the tagged user-rule
1397
+ // node (e.g. `{rule: 'URI', src, kids: [...]}`) unadorned.
1398
+ ...refs.bubble((r) => {
1399
+ if (r.child && r.child.node !== undefined) {
1400
+ r.node = r.child.node;
1401
+ }
1402
+ }),
1403
+ g: tag,
1404
+ }],
1405
+ };
1406
+ const options = {
1407
+ fixed: { token: fixedTokens },
1408
+ rule: { start: startWrapper },
1409
+ };
1410
+ if (Object.keys(matchTokens).length > 0) {
1411
+ options.match = { token: matchTokens };
1412
+ }
1413
+ const spec = {
1414
+ ref: refs.map,
1415
+ options,
1416
+ rule: ruleSpec,
1417
+ };
1418
+ return spec;
1419
+ }
1420
+ // Break an alternative into segments. Each segment is a (possibly
1421
+ // empty) run of terminal tokens followed by at most one rule
1422
+ // reference. A single-segment alt has at most one ref, located at the
1423
+ // very end; everything else has two or more segments.
1424
+ function segmentize(alt, literals, regexTokens) {
1425
+ const segs = [];
1426
+ let current = { terms: [], ref: null };
1427
+ for (const el of alt) {
1428
+ if (el.kind === 'term') {
1429
+ current.terms.push(literals.get(termKey(el)));
1430
+ }
1431
+ else if (el.kind === 'regex') {
1432
+ const key = regexKey(el);
1433
+ current.terms.push(regexTokens.get(key));
1434
+ }
1435
+ else if (el.kind === 'ref') {
1436
+ current.ref = el.name;
1437
+ segs.push(current);
1438
+ current = { terms: [], ref: null };
1439
+ }
1440
+ else {
1441
+ // `opt`, `star`, `plus`, `group` must have been desugared
1442
+ // before reaching the emitter.
1443
+ throw new Error(`abnf: internal — unexpected element kind '${el.kind}' in emitter`);
1444
+ }
1445
+ }
1446
+ if (current.terms.length > 0 || segs.length === 0) {
1447
+ segs.push(current);
1448
+ }
1449
+ return segs;
1450
+ }
1451
+ function regexKey(el) {
1452
+ return `/${el.pattern}/${el.flags}`;
1453
+ }
1454
+ function isSingleSegment(alt) {
1455
+ let sawRef = false;
1456
+ for (const el of alt) {
1457
+ if (el.kind === 'ref') {
1458
+ if (sawRef)
1459
+ return false;
1460
+ sawRef = true;
1461
+ }
1462
+ else if (el.kind === 'term' || el.kind === 'regex') {
1463
+ if (sawRef)
1464
+ return false; // terminal after a ref — multi-segment
1465
+ }
1466
+ else {
1467
+ // Desugar should have eliminated sugar kinds.
1468
+ return false;
1469
+ }
1470
+ }
1471
+ return true;
1472
+ }
1473
+ function validateRefs(alt, knownRules, ruleName) {
1474
+ for (const el of alt) {
1475
+ if (el.kind === 'ref' && !knownRules.has(el.name)) {
1476
+ throw new Error(`abnf: rule '${ruleName}' references unknown rule '${el.name}'`);
1477
+ }
1478
+ }
1479
+ }
1480
+ // Registry used by the emitter to allocate unique `@`-prefixed
1481
+ // FuncRef names for inline action functions. The resulting spec is
1482
+ // still declarative: every function appears once, keyed by name,
1483
+ // under the spec's `ref` map.
1484
+ class RefRegistry {
1485
+ constructor() {
1486
+ this.refs = {};
1487
+ this.counter = 0;
1488
+ // When set, tree-building actions are emitted as engine `$`-builtin
1489
+ // refs + `k` config (pure data) instead of registered closures. See
1490
+ // docs/design/alt-action-refs.md §6.4 and implementation-diary.md.
1491
+ this.useBuiltins = false;
1492
+ // When set, the emitter stamps user-rule alts with a `m` mark.
1493
+ this.emitMarks = false;
1494
+ }
1495
+ register(fn) {
1496
+ const name = `@abnf_a${this.counter++}`;
1497
+ this.refs[name] = fn;
1498
+ return name;
1499
+ }
1500
+ get map() {
1501
+ return this.refs;
1502
+ }
1503
+ // Tree-action emitters. Each returns the alt-spec fields to merge
1504
+ // (`{a}` in closure mode, `{a, k}` in builtins mode).
1505
+ node(cfg, closure) {
1506
+ return this.useBuiltins
1507
+ ? { a: '@node$', k: { node$: cfg } }
1508
+ : { a: this.register(closure) };
1509
+ }
1510
+ capture(cfg, closure) {
1511
+ return this.useBuiltins
1512
+ ? { a: '@capture$', k: { capture$: cfg } }
1513
+ : { a: this.register(closure) };
1514
+ }
1515
+ bubble(closure) {
1516
+ return this.useBuiltins ? { a: '@bubble$' } : { a: this.register(closure) };
1517
+ }
1518
+ }
1519
+ function mkAstNode(ruleName, nodeKind) {
1520
+ return nodeKind === 'user'
1521
+ ? { rule: ruleName, src: '', kids: [] }
1522
+ : { src: '', kids: [] };
1523
+ }
1524
+ // A stable, human-predictable "mark" for an alternative — its leading
1525
+ // discriminator: the first matched token name (sans `#`), the pushed
1526
+ // rule name, or `_` for the empty alt. Used for `@<rule>:o|c:<mark>`
1527
+ // user-action references. See docs/design/alt-action-refs.md §3.
1528
+ function altDiscriminator(alt, literals, regexTokens) {
1529
+ if (alt.length === 0)
1530
+ return '_';
1531
+ const el = alt[0];
1532
+ if (el.kind === 'term') {
1533
+ return (literals.get(termKey(el)) || '').replace(/^#/, '') || '_';
1534
+ }
1535
+ if (el.kind === 'regex') {
1536
+ return (regexTokens.get(regexKey(el)) || '').replace(/^#/, '') || '_';
1537
+ }
1538
+ if (el.kind === 'ref')
1539
+ return el.name;
1540
+ return '_';
1541
+ }
1542
+ // Assign a unique mark per source alternative (same alt object → same
1543
+ // mark, so fan-out copies share it). Collisions get a `~N` suffix.
1544
+ function assignMarks(alts, literals, regexTokens) {
1545
+ const marks = new Map();
1546
+ const seen = new Map();
1547
+ for (const alt of alts) {
1548
+ const base = altDiscriminator(alt, literals, regexTokens);
1549
+ const n = (seen.get(base) || 0) + 1;
1550
+ seen.set(base, n);
1551
+ marks.set(alt, n === 1 ? base : `${base}~${n}`);
1552
+ }
1553
+ return marks;
1554
+ }
1555
+ function segmentToAlt(seg, tag, refs, initNode, ruleName, nodeKind) {
1556
+ const spec = { g: tag };
1557
+ if (seg.terms.length > 0)
1558
+ spec.s = seg.terms.join(' ');
1559
+ if (seg.ref)
1560
+ spec.p = seg.ref;
1561
+ // Default tree-building: accumulate each matched terminal's source
1562
+ // text into `r.node.src`. Head alts also allocate a fresh AST node
1563
+ // so the child doesn't inherit (and then mutate) its parent's.
1564
+ const nterms = seg.terms.length;
1565
+ if (nterms > 0 || initNode) {
1566
+ Object.assign(spec, refs.node({ init: initNode, rule: ruleName, kind: nodeKind, nterms }, (r) => {
1567
+ if (initNode)
1568
+ r.node = mkAstNode(ruleName, nodeKind);
1569
+ const n = r.node;
1570
+ for (let i = 0; i < nterms; i++)
1571
+ n.src += r.o[i].src;
1572
+ }));
1573
+ }
1574
+ return spec;
1575
+ }
1576
+ // Close-state action: merge the just-returned child rule's AST node
1577
+ // into the current rule's. Tagged children (user rules) get pushed
1578
+ // verbatim into `kids`; untagged (helper / core) flatten — their
1579
+ // `src` appends and their `kids` extend. Either way `src`
1580
+ // concatenates so every ancestor's `.src` reflects everything it
1581
+ // matched.
1582
+ function captureChildFields(refs, ruleName, nodeKind) {
1583
+ return refs.capture({ rule: ruleName, kind: nodeKind }, (r) => {
1584
+ if (r.node == null)
1585
+ r.node = mkAstNode(ruleName, nodeKind);
1586
+ const n = r.node;
1587
+ const c = r.child && r.child.node;
1588
+ if (c == null)
1589
+ return;
1590
+ if (typeof c !== 'object' || !('src' in c)) {
1591
+ // Legacy shape — wrap as a leaf kid.
1592
+ n.kids.push(c);
1593
+ return;
1594
+ }
1595
+ // Defensive: if the child somehow shares this rule's node
1596
+ // object, skip the merge rather than push a self-reference. (A
1597
+ // properly-emitted grammar always allocates fresh child nodes.)
1598
+ if (c === n)
1599
+ return;
1600
+ n.src += c.src;
1601
+ if (c.rule)
1602
+ n.kids.push(c);
1603
+ else if (Array.isArray(c.kids))
1604
+ n.kids.push(...c.kids);
1605
+ });
1606
+ }
1607
+ function emitProduction(prod, grammar, literals, regexTokens, knownRules, tag, ruleSpec, firstSets, nullable, refs) {
1608
+ for (const alt of prod.alts) {
1609
+ validateRefs(alt, knownRules, prod.name);
1610
+ }
1611
+ const allSimple = prod.alts.every(isSingleSegment);
1612
+ if (allSimple) {
1613
+ // Every alternative collapses to one tabnas alt — emit them
1614
+ // directly into the production's open state. This is a head
1615
+ // rule, so each alt initialises its own node array. Empty alts
1616
+ // are sorted to the end so tabnas's first-match-wins doesn't let
1617
+ // them short-circuit non-empty alternatives.
1618
+ const ordered = [
1619
+ ...prod.alts.filter((alt) => alt.length > 0),
1620
+ ...prod.alts.filter((alt) => alt.length === 0),
1621
+ ];
1622
+ // Ref-only alternatives have no terminal to discriminate on, so
1623
+ // tabnas's first-match-wins would silently let them shadow any
1624
+ // later alternative. Guard them with FIRST-set peeks when the
1625
+ // production has more than one alt.
1626
+ const prodKind = prod.nodeKind ?? 'user';
1627
+ const marks = (prodKind === 'user' && refs.emitMarks)
1628
+ ? assignMarks(ordered, literals, regexTokens)
1629
+ : null;
1630
+ const needsPeek = ordered.length > 1;
1631
+ const opens = [];
1632
+ for (const alt of ordered) {
1633
+ const segs = segmentize(alt, literals, regexTokens);
1634
+ const seg = segs[0];
1635
+ const isRefOnly = alt.length >= 1 &&
1636
+ alt.every((el) => el.kind === 'ref') &&
1637
+ seg.terms.length === 0 &&
1638
+ seg.ref != null;
1639
+ const mark = marks ? marks.get(alt) : undefined;
1640
+ if (needsPeek && isRefOnly) {
1641
+ const firstTokens = firstOfAlt(alt, literals, regexTokens, firstSets, nullable);
1642
+ if (firstTokens) {
1643
+ for (const tok of firstTokens) {
1644
+ const o = {
1645
+ s: tok,
1646
+ b: 1,
1647
+ p: seg.ref,
1648
+ ...refs.node({ init: true, rule: prod.name, kind: prodKind, nterms: 0 }, (r) => { r.node = mkAstNode(prod.name, prodKind); }),
1649
+ g: tag,
1650
+ };
1651
+ if (mark)
1652
+ o.m = mark;
1653
+ opens.push(o);
1654
+ }
1655
+ continue;
1656
+ }
1657
+ }
1658
+ const o = segmentToAlt(seg, tag, refs, true, prod.name, prodKind);
1659
+ if (mark)
1660
+ o.m = mark;
1661
+ opens.push(o);
1662
+ }
1663
+ const rs = { open: opens };
1664
+ // If any alt has a push, the close state must capture the
1665
+ // returned child. Add a universal fallback close alt whose
1666
+ // action is a no-op when there was no push.
1667
+ if (prod.alts.some((alt) => alt.some((el) => el.kind === 'ref'))) {
1668
+ const close = {
1669
+ ...captureChildFields(refs, prod.name, prod.nodeKind ?? 'user'),
1670
+ g: tag,
1671
+ };
1672
+ if (marks)
1673
+ close.m = '_';
1674
+ rs.close = [close];
1675
+ }
1676
+ ruleSpec[prod.name] = rs;
1677
+ return;
1678
+ }
1679
+ if (prod.alts.length === 1) {
1680
+ // Single-alt, multi-segment: chain rules directly on the
1681
+ // production.
1682
+ emitChain(prod.name, prod.alts[0], literals, regexTokens, tag, ruleSpec, refs, prod.nodeKind ?? 'user');
1683
+ return;
1684
+ }
1685
+ // Multi-alt with at least one multi-segment alternative: emit a
1686
+ // dispatcher. Each alt becomes its own chained impl rule
1687
+ // (`<prodname>$alt<i>`); the main rule's open peeks the first token
1688
+ // and pushes the matching impl rule. Using `p:` (not `r:`) keeps
1689
+ // the parent's `child` pointer valid so the parent can read the
1690
+ // impl's node in its close-state action.
1691
+ const dispatchOpen = [];
1692
+ let emptyAltSeen = false;
1693
+ const dispatchMarks = ((prod.nodeKind ?? 'user') === 'user' && refs.emitMarks)
1694
+ ? assignMarks(prod.alts, literals, regexTokens)
1695
+ : null;
1696
+ for (let i = 0; i < prod.alts.length; i++) {
1697
+ const alt = prod.alts[i];
1698
+ const implName = `${prod.name}$alt${i}`;
1699
+ const mark = dispatchMarks ? dispatchMarks.get(alt) : undefined;
1700
+ if (alt.length === 0) {
1701
+ // Empty alt acts as fallback — handled after the loop.
1702
+ emptyAltSeen = true;
1703
+ continue;
1704
+ }
1705
+ emitChain(implName, alt, literals, regexTokens, tag, ruleSpec, refs, 'helper');
1706
+ // Fan out this alt into one dispatch entry per concrete token
1707
+ // sequence it can start with. Up to LOOKAHEAD_K tokens per
1708
+ // prefix is enough for the grammars this converter targets; a
1709
+ // ref with multiple alts produces one prefix per sub-alt so
1710
+ // overlapping FIRST sets between competing alts can still be
1711
+ // separated by their second (or later) token.
1712
+ // The dispatcher itself is a user (or helper) rule — it must
1713
+ // allocate its own AST node on every dispatch alt, otherwise the
1714
+ // node inherited from the parent via makeRule(ctx, rule.node)
1715
+ // would be shared and the dispatcher's captureChildRef would
1716
+ // mutate the parent's tree.
1717
+ const dispatchKind = prod.nodeKind ?? 'user';
1718
+ const initDispatchFields = refs.node({ init: true, rule: prod.name, kind: dispatchKind, nterms: 0 }, (r) => { r.node = mkAstNode(prod.name, dispatchKind); });
1719
+ const LOOKAHEAD_K = 4;
1720
+ const prefixes = altPrefixes(alt, grammar, literals, regexTokens, LOOKAHEAD_K);
1721
+ const usable = prefixes.filter((p) => p.length > 0);
1722
+ if (usable.length > 0) {
1723
+ for (const p of usable) {
1724
+ const o = {
1725
+ s: p.join(' '),
1726
+ b: p.length,
1727
+ p: implName,
1728
+ ...initDispatchFields,
1729
+ g: tag,
1730
+ };
1731
+ if (mark)
1732
+ o.m = mark;
1733
+ dispatchOpen.push(o);
1734
+ }
1735
+ }
1736
+ else {
1737
+ const firstTokens = firstOfAlt(alt, literals, regexTokens, firstSets, nullable);
1738
+ if (firstTokens === null) {
1739
+ throw new Error(`abnf: rule '${prod.name}' alternative ${i} is nullable ` +
1740
+ `but is not the only empty alt; FIRST set is ambiguous`);
1741
+ }
1742
+ for (const tok of firstTokens) {
1743
+ const o = {
1744
+ s: tok, b: 1, p: implName, ...initDispatchFields, g: tag,
1745
+ };
1746
+ if (mark)
1747
+ o.m = mark;
1748
+ dispatchOpen.push(o);
1749
+ }
1750
+ }
1751
+ }
1752
+ if (emptyAltSeen) {
1753
+ // Fallback: matches any token (or none), pops immediately with
1754
+ // an empty tree. Tagged with the user rule name so a consumer
1755
+ // walking the tree still gets a placeholder node for the empty
1756
+ // alternative.
1757
+ const fallbackKind = prod.nodeKind ?? 'user';
1758
+ const o = {
1759
+ ...refs.node({ init: true, rule: prod.name, kind: fallbackKind, nterms: 0 }, (r) => { r.node = mkAstNode(prod.name, fallbackKind); }),
1760
+ g: tag,
1761
+ };
1762
+ if (dispatchMarks)
1763
+ o.m = '_';
1764
+ dispatchOpen.push(o);
1765
+ }
1766
+ const dispClose = {
1767
+ // Merge the chosen impl's result up into the dispatcher's node,
1768
+ // tagged with the user rule name (so the enclosing rule sees a
1769
+ // `{rule, src, kids}` child, not the impl chain's transparent
1770
+ // `{src, kids}`).
1771
+ ...captureChildFields(refs, prod.name, prod.nodeKind ?? 'user'),
1772
+ g: tag,
1773
+ };
1774
+ if (dispatchMarks)
1775
+ dispClose.m = '_';
1776
+ ruleSpec[prod.name] = { open: dispatchOpen, close: [dispClose] };
1777
+ }
1778
+ // Emit a (possibly single-step) chain of rules for one alt under the
1779
+ // given head rule name. Segment 0 goes into `headName`; later
1780
+ // segments get synthetic `<headName>$stepN` continuations.
1781
+ //
1782
+ // `headKind` controls the head rule's AST node shape: 'user' tags
1783
+ // the head's node with the rule name; 'helper' leaves it untagged
1784
+ // (transparent to the enclosing user rule). Step rules are always
1785
+ // helpers — they inherit and accumulate into the head's node via
1786
+ // `r:` replacement.
1787
+ function emitChain(headName, alt, literals, regexTokens, tag, ruleSpec, refs, headKind = 'helper') {
1788
+ const segs = segmentize(alt, literals, regexTokens);
1789
+ const chainName = (i) => i === 0 ? headName : `${headName}$step${i}`;
1790
+ for (let i = 0; i < segs.length; i++) {
1791
+ const name = chainName(i);
1792
+ const seg = segs[i];
1793
+ const kind = i === 0 ? headKind : 'helper';
1794
+ // Only the head of the chain initialises the node object; later
1795
+ // steps inherit and continue to accumulate into it via `r:`.
1796
+ const headAlt = segmentToAlt(seg, tag, refs, i === 0, name, kind);
1797
+ // Single-alt user rule: the head alt is user-addressable.
1798
+ if (i === 0 && headKind === 'user' && refs.emitMarks) {
1799
+ headAlt.m = altDiscriminator(alt, literals, regexTokens);
1800
+ }
1801
+ const open = [headAlt];
1802
+ const rs = { open };
1803
+ const isLast = i === segs.length - 1;
1804
+ if (!isLast) {
1805
+ // Non-last step: after the push returns, capture the child's
1806
+ // node and replace with the next step rule.
1807
+ rs.close = [{
1808
+ r: chainName(i + 1),
1809
+ ...captureChildFields(refs, name, kind),
1810
+ g: tag,
1811
+ }];
1812
+ }
1813
+ else if (seg.ref) {
1814
+ // Last step, but it had a push — we still need to capture the
1815
+ // final child before popping.
1816
+ rs.close = [{ ...captureChildFields(refs, name, kind), g: tag }];
1817
+ }
1818
+ ruleSpec[name] = rs;
1819
+ }
1820
+ }
1821
+ // Compute FIRST(ref) for every production, plus which productions
1822
+ // are nullable (can derive the empty string). Iterates to a fixed
1823
+ // point. Terminals in FIRST sets are represented by their allocated
1824
+ // token names (e.g. `#X`).
1825
+ function computeFirstSets(grammar, literals, regexTokens) {
1826
+ const firstSets = new Map();
1827
+ const nullable = new Set();
1828
+ for (const p of grammar.productions)
1829
+ firstSets.set(p.name, new Set());
1830
+ let changed = true;
1831
+ while (changed) {
1832
+ changed = false;
1833
+ for (const prod of grammar.productions) {
1834
+ const first = firstSets.get(prod.name);
1835
+ for (const alt of prod.alts) {
1836
+ // Walk the alt, accumulating FIRST until a non-nullable
1837
+ // position is hit.
1838
+ let altNullable = true;
1839
+ for (const el of alt) {
1840
+ if (el.kind === 'term' || el.kind === 'regex') {
1841
+ const tok = el.kind === 'term'
1842
+ ? literals.get(termKey(el))
1843
+ : regexTokens.get(regexKey(el));
1844
+ if (!first.has(tok)) {
1845
+ first.add(tok);
1846
+ changed = true;
1847
+ }
1848
+ altNullable = false;
1849
+ break;
1850
+ }
1851
+ if (el.kind === 'ref') {
1852
+ const refFirst = firstSets.get(el.name) ?? new Set();
1853
+ for (const tok of refFirst) {
1854
+ if (!first.has(tok)) {
1855
+ first.add(tok);
1856
+ changed = true;
1857
+ }
1858
+ }
1859
+ if (!nullable.has(el.name)) {
1860
+ altNullable = false;
1861
+ break;
1862
+ }
1863
+ continue;
1864
+ }
1865
+ // Desugar should have eliminated other kinds.
1866
+ throw new Error(`abnf: internal — unexpected kind in FIRST: ${el.kind}`);
1867
+ }
1868
+ if (altNullable && !nullable.has(prod.name)) {
1869
+ nullable.add(prod.name);
1870
+ changed = true;
1871
+ }
1872
+ }
1873
+ }
1874
+ }
1875
+ return { firstSets, nullable };
1876
+ }
1877
+ // FIRST set for a specific alternative (not the whole production).
1878
+ // Returns null if the alt is nullable — the caller must treat that
1879
+ // case separately (typically as a fallback empty alt).
1880
+ function firstOfAlt(alt, literals, regexTokens, firstSets, nullable) {
1881
+ const out = new Set();
1882
+ for (const el of alt) {
1883
+ if (el.kind === 'term' || el.kind === 'regex') {
1884
+ const tok = el.kind === 'term'
1885
+ ? literals.get(termKey(el))
1886
+ : regexTokens.get(regexKey(el));
1887
+ out.add(tok);
1888
+ return out;
1889
+ }
1890
+ if (el.kind === 'ref') {
1891
+ const rf = firstSets.get(el.name) ?? new Set();
1892
+ for (const tok of rf)
1893
+ out.add(tok);
1894
+ if (!nullable.has(el.name))
1895
+ return out;
1896
+ // else keep walking into the next element
1897
+ continue;
1898
+ }
1899
+ throw new Error(`abnf: internal — unexpected kind in firstOfAlt: ${el.kind}`);
1900
+ }
1901
+ // Alt is nullable — no non-empty prefix.
1902
+ return null;
1903
+ }
1904
+ // Longest deterministic terminal prefix of a rule — the longest
1905
+ // sequence of tokens that every alternative of the rule starts
1906
+ // with. Refs are followed into their target rule, with a `visited`
1907
+ // set guarding cycles. An empty array means there's no confident
1908
+ // prefix (the rule either has divergent alts, starts with a multi-
1909
+ // alt ref, or hits a cycle), so the caller should fall back to a
1910
+ // single-token FIRST-set lookahead instead.
1911
+ function ruleLiteralPrefix(name, grammar, literals, regexTokens, visited) {
1912
+ if (visited.has(name))
1913
+ return [];
1914
+ const next = new Set(visited);
1915
+ next.add(name);
1916
+ const prod = grammar.productions.find((p) => p.name === name);
1917
+ if (!prod || prod.alts.length === 0)
1918
+ return [];
1919
+ const prefixes = prod.alts.map((alt) => altLiteralPrefix(alt, grammar, literals, regexTokens, next));
1920
+ if (prefixes.some((p) => p.length === 0))
1921
+ return [];
1922
+ const minLen = Math.min(...prefixes.map((p) => p.length));
1923
+ const common = [];
1924
+ for (let i = 0; i < minLen; i++) {
1925
+ const tok = prefixes[0][i];
1926
+ if (prefixes.every((p) => p[i] === tok))
1927
+ common.push(tok);
1928
+ else
1929
+ break;
1930
+ }
1931
+ return common;
1932
+ }
1933
+ function altLiteralPrefix(alt, grammar, literals, regexTokens, visited) {
1934
+ const out = [];
1935
+ for (const el of alt) {
1936
+ if (el.kind === 'term') {
1937
+ out.push(literals.get(termKey(el)));
1938
+ }
1939
+ else if (el.kind === 'regex') {
1940
+ out.push(regexTokens.get(regexKey(el)));
1941
+ }
1942
+ else if (el.kind === 'ref') {
1943
+ const sub = ruleLiteralPrefix(el.name, grammar, literals, regexTokens, visited);
1944
+ // Take the ref's literal prefix and stop — we can't see past
1945
+ // the ref without more expensive analysis.
1946
+ out.push(...sub);
1947
+ return out;
1948
+ }
1949
+ else {
1950
+ return out;
1951
+ }
1952
+ }
1953
+ return out;
1954
+ }
1955
+ // Enumerate concrete token-sequence prefixes an alternative can
1956
+ // start with, each at most `maxK` tokens long. Refs with multiple
1957
+ // alternatives fan out into one prefix per sub-alternative so the
1958
+ // caller can emit a dedicated dispatch alt for each path. When a
1959
+ // ref cycles back or exhausts depth, the path is *terminated* at
1960
+ // the tokens accumulated so far — the `done` flag is propagated
1961
+ // out of nested calls so a truncated sub-prefix is never extended
1962
+ // with tokens from elements the outer alt happens to list after the
1963
+ // cycled ref.
1964
+ function altPrefixesRaw(alt, grammar, literals, regexTokens, maxK, visited = new Set()) {
1965
+ let paths = [{ tokens: [], done: false }];
1966
+ for (const el of alt) {
1967
+ const next = [];
1968
+ for (const p of paths) {
1969
+ if (p.done || p.tokens.length >= maxK) {
1970
+ next.push(p);
1971
+ continue;
1972
+ }
1973
+ if (el.kind === 'term') {
1974
+ next.push({
1975
+ tokens: [...p.tokens, literals.get(termKey(el))],
1976
+ done: false,
1977
+ });
1978
+ }
1979
+ else if (el.kind === 'regex') {
1980
+ next.push({
1981
+ tokens: [...p.tokens, regexTokens.get(regexKey(el))],
1982
+ done: false,
1983
+ });
1984
+ }
1985
+ else if (el.kind === 'ref') {
1986
+ if (visited.has(el.name)) {
1987
+ next.push({ tokens: p.tokens, done: true });
1988
+ continue;
1989
+ }
1990
+ const childVisited = new Set(visited);
1991
+ childVisited.add(el.name);
1992
+ const target = grammar.productions.find((pr) => pr.name === el.name);
1993
+ if (!target || target.alts.length === 0) {
1994
+ next.push({ tokens: p.tokens, done: true });
1995
+ continue;
1996
+ }
1997
+ for (const sub of target.alts) {
1998
+ const subPaths = altPrefixesRaw(sub, grammar, literals, regexTokens, maxK - p.tokens.length, childVisited);
1999
+ for (const sp of subPaths) {
2000
+ next.push({
2001
+ tokens: [...p.tokens, ...sp.tokens],
2002
+ // Propagate `done` so the outer loop won't extend a
2003
+ // cycle-truncated sub-prefix.
2004
+ done: sp.done,
2005
+ });
2006
+ }
2007
+ }
2008
+ }
2009
+ else {
2010
+ // Desugar should have eliminated group/star/etc. at this point.
2011
+ next.push({ tokens: p.tokens, done: true });
2012
+ }
2013
+ }
2014
+ paths = next;
2015
+ if (paths.every((p) => p.done || p.tokens.length >= maxK))
2016
+ break;
2017
+ }
2018
+ return paths;
2019
+ }
2020
+ function altPrefixes(alt, grammar, literals, regexTokens, maxK) {
2021
+ const raw = altPrefixesRaw(alt, grammar, literals, regexTokens, maxK);
2022
+ const seen = new Set();
2023
+ const out = [];
2024
+ for (const p of raw) {
2025
+ const key = p.tokens.join(' ');
2026
+ if (!seen.has(key)) {
2027
+ seen.add(key);
2028
+ out.push(p.tokens);
2029
+ }
2030
+ }
2031
+ return out;
2032
+ }
2033
+ // A quoted-string literal is effectively case-sensitive either
2034
+ // when the user explicitly wrote `%s"…"` or when it contains no
2035
+ // ASCII letters (there's nothing to fold — `"+"` matches `+` in
2036
+ // any "case").
2037
+ function isEffectivelyCaseSensitive(el) {
2038
+ if (el.caseSensitive === true)
2039
+ return true;
2040
+ return !/[A-Za-z]/.test(el.literal);
2041
+ }
2042
+ // Map a term element to the key used to look up (or allocate) its
2043
+ // emitted token. The key folds together the literal and its
2044
+ // effective case-sensitivity so a sensitive and an insensitive
2045
+ // occurrence of the same string are distinct tokens.
2046
+ function termKey(el) {
2047
+ return (isEffectivelyCaseSensitive(el) ? 'cs:' : 'ci:') + el.literal;
2048
+ }
2049
+ function escapeRegExp(s) {
2050
+ return s.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
2051
+ }
2052
+ // Decode an ABNF numeric value (`%xNN`, `%dNN`, `%bNN`, or one of
2053
+ // the range/concatenation forms) into a `AbnfElement`.
2054
+ //
2055
+ // %x61 => single-char term "a"
2056
+ // %x66.6f.6f => concatenated term "foo"
2057
+ // %x30-39 => regex character class [\u0030-\u0039]
2058
+ //
2059
+ // Hex is case-insensitive; decimal and binary accept only digits
2060
+ // in their respective ranges. Range endpoints must be the same
2061
+ // base as the prefix (RFC 5234 doesn't allow mixing).
2062
+ function parseNumericValue(src) {
2063
+ const base = src[1].toLowerCase();
2064
+ const radix = base === 'x' ? 16 : base === 'd' ? 10 : 2;
2065
+ const body = src.slice(2);
2066
+ if (body.includes('-')) {
2067
+ const [loStr, hiStr] = body.split('-');
2068
+ const lo = parseInt(loStr, radix);
2069
+ const hi = parseInt(hiStr, radix);
2070
+ if (lo === hi) {
2071
+ return { kind: 'term', literal: String.fromCharCode(lo) };
2072
+ }
2073
+ const toEsc = (n) => '\\u' + n.toString(16).padStart(4, '0');
2074
+ return {
2075
+ kind: 'regex',
2076
+ pattern: '[' + toEsc(lo) + '-' + toEsc(hi) + ']',
2077
+ flags: '',
2078
+ };
2079
+ }
2080
+ const parts = body.split('.');
2081
+ const chars = parts.map((n) => String.fromCharCode(parseInt(n, radix)));
2082
+ return { kind: 'term', literal: chars.join('') };
2083
+ }
2084
+ function allocTokenName(literal, used) {
2085
+ const base = literal
2086
+ .replace(/[^A-Za-z0-9]/g, '_')
2087
+ .toUpperCase()
2088
+ .replace(/^_+|_+$/g, '');
2089
+ const candidate = base.length > 0 ? '#' + base : '#T';
2090
+ if (!used.has(candidate)) {
2091
+ used.add(candidate);
2092
+ return candidate;
2093
+ }
2094
+ let i = 1;
2095
+ while (used.has(candidate + i))
2096
+ i++;
2097
+ const chosen = candidate + i;
2098
+ used.add(chosen);
2099
+ return chosen;
2100
+ }
2101
+ // Public entry point: take ABNF source and return a tabnas GrammarSpec.
2102
+ function abnf(src, opts) {
2103
+ const grammar = parseAbnf(src);
2104
+ return emitGrammarSpec(grammar, opts);
2105
+ }
2106
+ //# sourceMappingURL=converter.js.map