grammatik 0.1.0 → 0.1.2

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.
package/src/codegen.js CHANGED
@@ -2,7 +2,7 @@
2
2
  'use strict'
3
3
 
4
4
  import { FAIL, expect } from './state.js'
5
- import { buildDispatch, opOf, recognizeSeqIdiom } from './analyze.js'
5
+ import { buildDispatch, opOf, recognizeSeqIdiom, recognizeAltPrefixIdiom, recognizeAltConsumeIdiom } from './analyze.js'
6
6
 
7
7
  /**
8
8
  * A real source generator for grammars.
@@ -23,10 +23,24 @@ import { buildDispatch, opOf, recognizeSeqIdiom } from './analyze.js'
23
23
 
24
24
  /**
25
25
  * @param {{ planner: any, rules: Record<string, any>, firsts: Map<string, any>|null,
26
- * memo: boolean, maxSteps: number }} config
26
+ * memo: boolean, maxSteps: number, tokenIds?: Map<string, number>|null,
27
+ * memoSlots?: Map<string, number>, inlineRules?: boolean, idioms?: boolean }} config
27
28
  */
28
29
  export function createCodegen (config) {
29
- const { planner, rules, firsts, memo, maxSteps } = config
30
+ const { planner, rules, firsts, memo, maxSteps, tokenIds = null, memoSlots = new Map(), inlineRules = true, idioms = true } = config
31
+
32
+ /**
33
+ * `token.id === <n>` is an integer compare instead of a string compare, and
34
+ * is what generated code prefers wherever a token's declared type matters.
35
+ * Falls back to `token.type === <name>` only for a hand-built token that
36
+ * bypassed this parser's own lexer (`parseTokens` accepts anything shaped
37
+ * like a Token) — every token the bundled lexer produces has an id.
38
+ */
39
+ const typeCheck = (tokenVar, name) => {
40
+ const id = tokenIds ? tokenIds.get(name) : undefined
41
+ if (id === undefined) return `${tokenVar}.type === ${JSON.stringify(name)}`
42
+ return `(${tokenVar}.id === ${id} || (${tokenVar}.id === undefined && ${tokenVar}.type === ${JSON.stringify(name)}))`
43
+ }
30
44
 
31
45
  /** Runtime values the generated code closes over. */
32
46
  const HELPERS = []
@@ -61,6 +75,56 @@ export function createCodegen (config) {
61
75
  /** A literal value sitting in parser position. */
62
76
  const constantHelper = (node) => helper(planner.plan(node), { kind: 'constant', node })
63
77
 
78
+ /**
79
+ * Operator-precedence levels (`Additive = infixLeft(subrule(Multiplicative), ...)`)
80
+ * are written as a chain of rules, one per level, and each level costs a call, a
81
+ * loop and a token check even when no operator of that level follows: measured at
82
+ * ~10 ns per level per atom, so an 8-level chain costs several times the atom
83
+ * itself. A level with exactly one caller can be written into that caller's body
84
+ * instead, which keeps the loop and drops the call.
85
+ *
86
+ * Only these four shapes, and only a single call site: nothing is duplicated, and
87
+ * the function that results stays small enough for V8 to optimize (a rule like
88
+ * `Primary`, with an `alt` of many branches, is not a level and is left a call).
89
+ */
90
+ // How many levels go into one function. Unbounded, a long chain makes a function
91
+ // larger than V8 will optimize at all (its `max-optimized-bytecode-size`), and the
92
+ // gain turns back into a loss: measured on a synthetic chain, 8 levels in one
93
+ // function ran at 66 ns per atom against 33 ns for 7. Six per function was the best
94
+ // of the caps tried (2, 3, 4, 5, 6, 8) across chains of 3 to 16 levels.
95
+ const MAX_INLINE_DEPTH = 6
96
+ /** `oneOf` with more names than this is generated as a `switch` on the token id. */
97
+ const ONE_OF_SWITCH_AT = 12
98
+ const INLINE_OPS = new Set(['infixLeft', 'infixRight', 'prefix', 'postfix'])
99
+ const callSites = new Map()
100
+ ;(function count (node) {
101
+ if (Array.isArray(node)) { for (const child of node) count(child); return }
102
+ if (!node || typeof node !== 'object') return
103
+ if ('subrule' in node) {
104
+ const target = Array.isArray(node.subrule) ? node.subrule[0] : node.subrule
105
+ if (typeof target === 'string') callSites.set(target, (callSites.get(target) || 0) + 1)
106
+ }
107
+ for (const key of Object.keys(node)) count(node[key])
108
+ })(rules)
109
+ /** Rules being written into a caller right now, so a cycle cannot recurse forever. */
110
+ const inlining = new Set()
111
+ /**
112
+ * Some constructs write a child twice: `infixLeft` and `infixRight` their operand (once for the
113
+ * first, once inside the loop), `many1` and `many1Sep` their item. Inline a rule into *both*
114
+ * copies and a chain of such rules multiplies out — one copy per level at the first, two at the
115
+ * next, four at the next: measured, the formula grammar's module nearly doubled and a six-level
116
+ * chain grew fivefold. The second copy is the loop body, which only runs when more input follows,
117
+ * so it stays a call; only the first copy, the one every parse goes through, is inlined.
118
+ */
119
+ let coldDepth = 0
120
+ const cold = (write) => {
121
+ coldDepth++
122
+ try { return write() } finally { coldDepth-- }
123
+ }
124
+ const canInline = (name) =>
125
+ inlineRules && coldDepth === 0 && !memo && !memoSlots.has(name) && !inlining.has(name) && inlining.size < MAX_INLINE_DEPTH &&
126
+ callSites.get(name) === 1 && INLINE_OPS.has(opOf(rules[name]))
127
+
64
128
  function indexOfRule (name) {
65
129
  let index = ruleIndex.get(name)
66
130
  if (index !== undefined) return index
@@ -99,7 +163,7 @@ export function createCodegen (config) {
99
163
  out.push(
100
164
  `let ${result};`,
101
165
  `const ${token} = c.tokens[c.idx];`,
102
- `if (${token} !== undefined && ${token}.type === ${JSON.stringify(name)}) { c.idx++; ${result} = ${token}; }`,
166
+ `if (${token} !== undefined && ${typeCheck(token, name)}) { c.idx++; ${result} = ${token}; }`,
103
167
  `else { E(c, ${JSON.stringify(name)}); ${result} = F; }`
104
168
  )
105
169
  return result
@@ -140,6 +204,17 @@ export function createCodegen (config) {
140
204
  return result
141
205
  }
142
206
 
207
+ case 'flag': {
208
+ const start = nextId()
209
+ out.push(`let ${result};`, `const ${start} = c.idx;`, '{')
210
+ const value = gen(arg, out, bindingsVar)
211
+ out.push(
212
+ `if (${value} === F) { c.idx = ${start}; ${result} = false; } else ${result} = true;`,
213
+ '}'
214
+ )
215
+ return result
216
+ }
217
+
143
218
  case 'many':
144
219
  case 'many1': {
145
220
  const atLeastOne = op === 'many1'
@@ -159,7 +234,7 @@ export function createCodegen (config) {
159
234
  }
160
235
 
161
236
  out.push('for (;;) {', `const ${before} = c.idx;`)
162
- const value = gen(arg, out, bindingsVar)
237
+ const value = atLeastOne ? cold(() => gen(arg, out, bindingsVar)) : gen(arg, out, bindingsVar)
163
238
  out.push(
164
239
  `if (${value} === F) { c.idx = ${before}; break; }`,
165
240
  `if (c.idx === ${before}) P(${JSON.stringify(op)}, ${before});`,
@@ -196,7 +271,7 @@ export function createCodegen (config) {
196
271
  out.push(`if (${operatorVar} === F) { c.idx = ${mark}; break; }`)
197
272
  }
198
273
 
199
- const right = gen(arg.operand, out, bindingsVar)
274
+ const right = cold(() => gen(arg.operand, out, bindingsVar))
200
275
  out.push(
201
276
  `if (${right} === F) { c.idx = ${mark}; break; }`,
202
277
  `if (${bindings} === null) ${bindings} = { left: null, op: null, right: null };`,
@@ -212,12 +287,34 @@ export function createCodegen (config) {
212
287
  case 'oneOf': {
213
288
  const names = Array.isArray(arg) ? arg : [arg]
214
289
  const token = nextId()
215
- const test = names.map((n) => `${token}.type === ${JSON.stringify(n)}`).join(' || ')
290
+ const ids = tokenIds ? names.map((n) => tokenIds.get(n)) : []
291
+ const failed = `else { ${names.map((n) => `E(c, ${JSON.stringify(n)});`).join(' ')} ${result} = F; }`
292
+
293
+ if (names.length > ONE_OF_SWITCH_AT && ids.every((id) => id !== undefined)) {
294
+ // Past a handful of names a chain of comparisons costs more than a jump
295
+ // table: measured, the chain matched `alt`'s switch at 12 names and lost
296
+ // beyond. Integer cases only, as `alt` does it — a `case undefined` for a
297
+ // hand-built token would stop V8 building the table — so the id is
298
+ // normalised first, from the type name when the token has none.
299
+ const hit = nextId()
300
+ const cases = [...new Set(ids)].map((id) => `case ${id}:`).join(' ')
301
+ out.push(
302
+ `let ${result};`,
303
+ `const ${token} = c.tokens[c.idx];`,
304
+ `let ${hit} = false;`,
305
+ `if (${token} !== undefined) { switch (${token}.id !== undefined ? ${token}.id : I[${token}.type]) { ${cases} ${hit} = true; } }`,
306
+ `if (${hit}) { c.idx++; ${result} = ${token}; }`,
307
+ failed
308
+ )
309
+ return result
310
+ }
311
+
312
+ const test = names.map((n) => typeCheck(token, n)).join(' || ')
216
313
  out.push(
217
314
  `let ${result};`,
218
315
  `const ${token} = c.tokens[c.idx];`,
219
316
  `if (${token} !== undefined && (${test})) { c.idx++; ${result} = ${token}; }`,
220
- `else { ${names.map((n) => `E(c, ${JSON.stringify(n)});`).join(' ')} ${result} = F; }`
317
+ failed
221
318
  )
222
319
  return result
223
320
  }
@@ -253,7 +350,7 @@ export function createCodegen (config) {
253
350
  operatorVar = gen(arg.operator, out, bindingsVar)
254
351
  out.push(`if (${operatorVar} === F) { c.idx = ${mark}; break; }`)
255
352
  }
256
- const right = gen(arg.operand, out, bindingsVar)
353
+ const right = cold(() => gen(arg.operand, out, bindingsVar))
257
354
  out.push(
258
355
  `if (${right} === F) { c.idx = ${mark}; break; }`,
259
356
  `if (${items} === null) ${items} = [];`,
@@ -334,6 +431,22 @@ export function createCodegen (config) {
334
431
  return result
335
432
  }
336
433
 
434
+ case 'pick':
435
+ case 'yields': {
436
+ // The parsers in order, then one of their results or a constant; no
437
+ // bindings object, which is the point (see `recognizeSeqIdiom`).
438
+ const label = 'L' + nextId()
439
+ const start = nextId()
440
+ out.push(tickSrc, `let ${result};`, `${label}: {`, `const ${start} = c.idx;`)
441
+ const values = arg.parsers.map((parser) => {
442
+ const value = gen(parser, out, bindingsVar)
443
+ out.push(`if (${value} === F) { c.idx = ${start}; ${result} = F; break ${label}; }`)
444
+ return value
445
+ })
446
+ out.push(`${result} = ${op === 'pick' ? values[arg.index] : JSON.stringify(arg.value)};`, '}')
447
+ return result
448
+ }
449
+
337
450
  case 'prefix': {
338
451
  const label = 'L' + nextId()
339
452
  const start = nextId()
@@ -415,7 +528,7 @@ export function createCodegen (config) {
415
528
  )
416
529
  const sep = gen(sepNode, out, bindingsVar)
417
530
  out.push(`if (${sep} === F) { c.idx = ${beforeSep}; break; }`, `const ${beforeItem} = c.idx;`)
418
- const item = gen(arg.rule, out, bindingsVar)
531
+ const item = cold(() => gen(arg.rule, out, bindingsVar))
419
532
  out.push(
420
533
  `if (${item} === F) { c.idx = ${trailing ? beforeItem : beforeSep}; break; }`,
421
534
  `${result}.push(${item});`,
@@ -432,8 +545,20 @@ export function createCodegen (config) {
432
545
  out.push(`const ${result} = ${helper(planner.plan(node), { kind: 'unknownRule', name })}(c);`)
433
546
  return result
434
547
  }
435
- const index = indexOfRule(name)
436
548
  const parent = nextId()
549
+ if (canInline(name)) {
550
+ out.push(tickSrc)
551
+ out.push(
552
+ `const ${parent} = c.ruleDepth;`,
553
+ `if (c.track) { c.ruleNames[${parent}] = ${JSON.stringify(name)}; c.ruleDepth = ${parent} + 1; }`
554
+ )
555
+ inlining.add(name)
556
+ const inner = gen(rules[name], out, null)
557
+ inlining.delete(name)
558
+ out.push(`if (c.track) c.ruleDepth = ${parent};`)
559
+ return inner
560
+ }
561
+ const index = indexOfRule(name)
437
562
  out.push(tickSrc)
438
563
  if (memo) {
439
564
  const key = nextId()
@@ -454,6 +579,27 @@ export function createCodegen (config) {
454
579
  `c.memo.set(${key}, { value: ${result}, end: c.idx });`,
455
580
  '}'
456
581
  )
582
+ } else if (memoSlots.has(name)) {
583
+ // Chosen by `selectMemoRules`: remember the result per position.
584
+ const table = nextId()
585
+ const at = nextId()
586
+ const end = nextId()
587
+ out.push(
588
+ `let ${result};`,
589
+ `const ${table} = c.rm[${memoSlots.get(name)}];`,
590
+ `const ${at} = c.idx;`,
591
+ `const ${end} = ${table}.ends[${at}];`,
592
+ `if (${end} !== 0) { c.idx = ${end} - 1; ${result} = ${table}.vals[${at}]; }`,
593
+ 'else {',
594
+ `const ${parent} = c.ruleDepth;`,
595
+ `if (c.track) { c.ruleNames[${parent}] = ${JSON.stringify(name)}; c.ruleDepth = ${parent} + 1; }`,
596
+ `${result} = R[${index}](c);`,
597
+ `if (c.track) c.ruleDepth = ${parent};`,
598
+ `if (${result} === F) c.idx = ${at};`,
599
+ `${table}.ends[${at}] = c.idx + 1;`,
600
+ `${table}.vals[${at}] = ${result};`,
601
+ '}'
602
+ )
457
603
  } else {
458
604
  out.push(
459
605
  `const ${parent} = c.ruleDepth;`,
@@ -476,7 +622,7 @@ export function createCodegen (config) {
476
622
  if (!Array.isArray(children)) throw new Error("'seq' expects an array of parsers")
477
623
 
478
624
  // Same idiom recognition as the planner, so both paths agree.
479
- const idiom = recognizeSeqIdiom(children)
625
+ const idiom = idioms ? recognizeSeqIdiom(children) : null
480
626
  if (idiom !== null) return gen(idiom, out, null)
481
627
 
482
628
  let hasLabels = false
@@ -520,29 +666,78 @@ export function createCodegen (config) {
520
666
  return result
521
667
  }
522
668
 
669
+ /**
670
+ * Emits an inline ordered-backtracking block over `list`, into a fresh
671
+ * variable. Used both for a whole `alt` with no dispatch table, and for the
672
+ * residual subset of branches a dispatch table cannot separate by one
673
+ * token.
674
+ */
675
+ function genOrderedBacktrack (list, out, bindingsVar) {
676
+ const result = nextId()
677
+ const label = 'L' + nextId()
678
+ const start = nextId()
679
+ out.push(`let ${result};`, `${label}: {`, `const ${start} = c.idx;`)
680
+ for (const branch of list) {
681
+ out.push('{')
682
+ const value = gen(branch, out, bindingsVar)
683
+ out.push(`if (${value} !== F) { ${result} = ${value}; break ${label}; }`, `c.idx = ${start};`, '}')
684
+ }
685
+ out.push(`${result} = F;`, '}')
686
+ return result
687
+ }
688
+
523
689
  function genAlt (branches, out, result, bindingsVar) {
524
690
  if (!Array.isArray(branches)) throw new Error("'alt' expects an array of branches")
525
691
 
692
+ const oneOf = idioms ? recognizeAltConsumeIdiom(branches) : null
693
+ if (oneOf !== null) return gen(oneOf, out, bindingsVar)
694
+
695
+ const factored = idioms ? recognizeAltPrefixIdiom(branches) : null
696
+ if (factored !== null) return gen(factored, out, bindingsVar)
697
+
526
698
  const table = firsts ? buildDispatch(branches, firsts) : null
527
699
  const label = 'L' + nextId()
528
700
 
529
701
  if (table !== null) {
530
702
  // A switch on the token type: V8 turns a dense string switch into a
531
703
  // hash lookup with inlined bodies, and every branch is inlined here.
704
+ // A table value that is an array is a residual group — tokens shared by
705
+ // more than one branch — resolved by ordered backtracking over just
706
+ // that subset instead of a single inlined branch.
707
+ //
708
+ // Switching on token.id (an integer) instead of token.type (a string)
709
+ // when every expected token resolves to one — a numeric switch is a jump
710
+ // table without even the string-hashing step a dense string switch still
711
+ // does. `I[token.type]` is the same fallback typeCheck uses: a hand-built
712
+ // token from parseTokens has no id, and I is a name -> id table built
713
+ // once from the same tokenIds this parser resolved every other id from.
532
714
  const expected = [...table.keys()]
715
+ const expectedIds = tokenIds ? expected.map((t) => tokenIds.get(t)) : null
716
+ const numeric = expectedIds !== null && expectedIds.every((id) => id !== undefined)
533
717
  const token = nextId()
534
718
  out.push(tickSrc, `let ${result};`, `${label}: {`, `const ${token} = c.tokens[c.idx];`)
535
719
  out.push(`if (${token} === undefined) { ${expected.map((t) => `E(c, ${JSON.stringify(t)});`).join(' ')} ${result} = F; break ${label}; }`)
536
- out.push(`switch (${token}.type) {`)
720
+
721
+ let discriminant
722
+ if (numeric) {
723
+ discriminant = nextId()
724
+ out.push(`const ${discriminant} = ${token}.id !== undefined ? ${token}.id : I[${token}.type];`)
725
+ out.push(`switch (${discriminant}) {`)
726
+ } else {
727
+ discriminant = `${token}.type`
728
+ out.push(`switch (${discriminant}) {`)
729
+ }
537
730
 
538
731
  const byBranch = new Map()
539
- for (const [tokenName, branch] of table) {
732
+ for (const tokenName of expected) {
733
+ const branch = table.get(tokenName)
540
734
  if (!byBranch.has(branch)) byBranch.set(branch, [])
541
735
  byBranch.get(branch).push(tokenName)
542
736
  }
737
+ const caseLabel = (t) => numeric ? tokenIds.get(t) : JSON.stringify(t)
543
738
  for (const [branch, tokenNames] of byBranch) {
544
- out.push(tokenNames.map((t) => `case ${JSON.stringify(t)}:`).join(' ') + ' {')
545
- const value = gen(branch, out, bindingsVar)
739
+ out.push(tokenNames.map((t) => `case ${caseLabel(t)}:`).join(' ') + ' {')
740
+ const value = Array.isArray(branch) ? genOrderedBacktrack(branch, out, bindingsVar) : gen(branch, out, bindingsVar)
546
741
  out.push(`${result} = ${value}; break ${label};`, '}')
547
742
  }
548
743
  out.push(
@@ -554,17 +749,13 @@ export function createCodegen (config) {
554
749
  }
555
750
 
556
751
  // Ordered backtracking, still fully inlined.
557
- const start = nextId()
558
- out.push(tickSrc, `let ${result};`, `${label}: {`, `const ${start} = c.idx;`)
559
- for (const branch of branches) {
560
- out.push('{')
561
- const value = gen(branch, out, bindingsVar)
562
- out.push(`if (${value} !== F) { ${result} = ${value}; break ${label}; }`, `c.idx = ${start};`, '}')
563
- }
564
- out.push(`${result} = F;`, '}')
565
- return result
752
+ out.push(tickSrc)
753
+ return genOrderedBacktrack(branches, out, bindingsVar)
566
754
  }
567
755
 
756
+ /** Name -> id, for a generated switch's runtime fallback when token.id is missing. */
757
+ const idByName = tokenIds ? Object.fromEntries(tokenIds) : null
758
+
568
759
  /** Generates and installs the function for one rule. */
569
760
  function buildRule (name) {
570
761
  const out = []
@@ -576,8 +767,8 @@ export function createCodegen (config) {
576
767
  SOURCES[ruleIndex.get(name)] = body
577
768
  const source = `return ${body}`
578
769
  // eslint-disable-next-line no-new-func
579
- const factory = new Function('F', 'E', 'P', 'T', 'R', 'H', source)
580
- return factory(FAIL, expect, progressError, stepsError, RULES, HELPERS)
770
+ const factory = new Function('F', 'E', 'P', 'T', 'R', 'H', 'I', source)
771
+ return factory(FAIL, expect, progressError, stepsError, RULES, HELPERS, idByName)
581
772
  }
582
773
 
583
774
  function progressError (op, at) {
package/src/definition.js CHANGED
@@ -227,6 +227,7 @@ const CONSTRUCTORS = {
227
227
  many: { arity: [1, 1], of: ([a]) => ({ many: a }) },
228
228
  many1: { arity: [1, 1], of: ([a]) => ({ many1: a }) },
229
229
  option: { arity: [1, 1], of: ([a]) => ({ option: a }) },
230
+ flag: { arity: [1, 1], of: ([a]) => ({ flag: a }) },
230
231
  action: { arity: [1, 1], of: ([a]) => ({ action: a }) },
231
232
  lookahead: { arity: [1, 1], of: ([a]) => ({ lookahead: a }) },
232
233
  negLookahead: { arity: [1, 1], of: ([a]) => ({ negLookahead: a }) },
@@ -241,6 +242,10 @@ const CONSTRUCTORS = {
241
242
  many1Sep: { arity: [2, 2], of: ([a, b]) => ({ many1Sep: { rule: a, sep: b } }) },
242
243
 
243
244
  between: { arity: [3, 3], of: ([a, b, c]) => ({ between: { open: a, body: b, close: c } }) },
245
+ // pick(1, consume(Open), subrule(Body), consume(Close)) — the result at that index.
246
+ pick: { arity: [2, Infinity], of: ([index, ...parsers]) => ({ pick: { parsers, index } }) },
247
+ // yields(consume(True), true) — run the parsers, give back the constant.
248
+ yields: { arity: [2, Infinity], of: (args) => ({ yields: { parsers: args.slice(0, -1), value: args[args.length - 1] } }) },
244
249
  infixLeft: { arity: [3, 3], of: ([a, b, c]) => ({ infixLeft: { operand: a, operator: b, combine: logicOf(c) } }) },
245
250
  infixRight: { arity: [3, 3], of: ([a, b, c]) => ({ infixRight: { operand: a, operator: b, combine: logicOf(c) } }) },
246
251
  prefix: { arity: [3, 3], of: ([a, b, c]) => ({ prefix: { operator: a, operand: b, combine: logicOf(c) } }) },
package/src/emit.js CHANGED
@@ -2,9 +2,11 @@
2
2
  'use strict'
3
3
 
4
4
  import { createLexer } from './lexer.js'
5
+ import { generateLexer } from './lexgen.js'
5
6
  import { analyze } from './analyze.js'
6
7
  import { createPlanner } from './plan.js'
7
8
  import { createCodegen } from './codegen.js'
9
+ import { selectMemoRules } from './memoplan.js'
8
10
  import { usesOuterScope } from './plan.js'
9
11
  import { emitMethodsBlock, evaluateMethodsBlock } from './methodsblock.js'
10
12
  import { createValueMethods } from './values.js'
@@ -100,8 +102,9 @@ const literal = (value, indent = 0) => JSON.stringify(value, null, 2)
100
102
 
101
103
  /**
102
104
  * @param {import('./parser.js').GrammarSpec} spec
103
- * @param {{ memo?: boolean, strict?: boolean, ll1?: boolean, maxSteps?: number,
105
+ * @param {{ memo?: boolean, autoMemo?: boolean, inlineRules?: boolean, idioms?: boolean, strict?: boolean, ll1?: boolean, maxSteps?: number,
104
106
  * positions?: 'full'|'offset', execution?: 'generated'|'interpreted',
107
+ * lexer?: 'generated'|'runtime', scanners?: boolean, fuse?: boolean,
105
108
  * format?: 'esm'|'cjs', validate?: boolean, moduleName?: string,
106
109
  * runtimeSpecifier?: string, engineSpecifier?: string,
107
110
  * methods?: Record<string, any> }} [options]
@@ -111,11 +114,21 @@ export function emitModule (spec, options = {}) {
111
114
  const { tokens: tokenDefs, rules, start } = spec
112
115
  const {
113
116
  memo = false,
117
+ autoMemo = true,
118
+ // Off here, on in `createParser`. Inlining a precedence chain is worth 2-3x on a bare chain but only
119
+ // 1.02-1.04x on the formula and SQL examples, and it makes the file bigger: the formula module grows
120
+ // 32% minified (38 to 50 KB) even after the second copy of each operand is kept as a call. A parser
121
+ // built at run time pays nothing for that; a file someone ships does.
122
+ inlineRules = false,
123
+ idioms = true,
114
124
  strict = true,
115
125
  ll1 = true,
116
126
  maxSteps = 0,
117
127
  positions = 'full',
118
128
  execution = 'generated',
129
+ lexer: lexerMode = 'generated',
130
+ scanners = true,
131
+ fuse = true,
119
132
  format = 'esm',
120
133
  validate = true,
121
134
  moduleName = spec.name || 'parser',
@@ -125,6 +138,7 @@ export function emitModule (spec, options = {}) {
125
138
  } = options
126
139
 
127
140
  if (format !== 'esm' && format !== 'cjs') throw new Error(`emitModule: format must be 'esm' or 'cjs', got ${quote(format)}`)
141
+ if (lexerMode !== 'generated' && lexerMode !== 'runtime') throw new Error(`emitModule: lexer must be 'generated' or 'runtime', got ${quote(lexerMode)}`)
128
142
 
129
143
  // A `methods { ... }` block travels with the grammar and is written into the
130
144
  // output as source. Nothing evaluates it here, so this path stays eval-free.
@@ -132,6 +146,8 @@ export function emitModule (spec, options = {}) {
132
146
  const block = methodsBlock ? emitMethodsBlock(methodsBlock, format) : null
133
147
  if (!start || !(start in rules)) throw new Error(`emitModule: start rule '${start}' is not defined in rules`)
134
148
 
149
+ // Built here even when the file will carry its own lexer: constructing it is what validates the token
150
+ // definitions, and it fixes the token ids the rules are compiled against.
135
151
  const lexer = createLexer(tokenDefs, { positions })
136
152
  const analysis = analyze(rules, { tokenNames: lexer.tokenNames })
137
153
  if (validate && analysis.errors.length > 0) throw new GrammarError(analysis.errors)
@@ -139,9 +155,17 @@ export function emitModule (spec, options = {}) {
139
155
  // The planner is needed only so codegen can record what each helper is; the
140
156
  // closures it makes are thrown away.
141
157
  const firsts = ll1 ? analysis.firsts : null
158
+ // The generated file's own `createLexer(tokenDefs, ...)` call assigns the same
159
+ // ids, from the same `tokenDefs` array in the same order — see the matching
160
+ // comment in parser.js.
161
+ const tokenIds = new Map(lexer.tokenNames.map((name, id) => [name, id]))
142
162
  const engine = { build: () => { throw new Error('not compiled during emit') }, run: () => {} }
143
- const planner = createPlanner({ engine, rules, firsts, memo, maxSteps, unsafeEval: false })
144
- const codegen = createCodegen({ planner, rules, firsts, memo, maxSteps })
163
+ const memoRules = autoMemo && !memo && analysis.errors.length === 0
164
+ ? selectMemoRules(rules, analysis.firsts, { ll1 })
165
+ : []
166
+ const memoSlots = new Map(memoRules.map((name, slot) => [name, slot]))
167
+ const planner = createPlanner({ engine, rules, firsts, memo, maxSteps, unsafeEval: false, tokenIds, memoSlots, idioms })
168
+ const codegen = createCodegen({ planner, rules, firsts, memo, maxSteps, tokenIds, memoSlots, inlineRules, idioms })
145
169
 
146
170
  codegen.compile(start)
147
171
 
@@ -210,7 +234,9 @@ export function emitModule (spec, options = {}) {
210
234
  // actions may use the value methods, so it has to register them the way
211
235
  // `createParser` does. They come from the runtime rather than being written
212
236
  // out here, so there is one implementation rather than two.
213
- const runtimeImports = ['FAIL as F', 'expect as E', 'createState', 'buildParseError', 'createLexer']
237
+ // A lexer written out as source needs only the error class from the runtime; the engine stays out of the file.
238
+ const generatedLexer = lexerMode === 'generated'
239
+ const runtimeImports = ['FAIL as F', 'expect as E', 'createState', 'buildParseError', generatedLexer ? 'LexError' : 'createLexer']
214
240
  if (needsEngine) runtimeImports.push('createValueMethods')
215
241
 
216
242
  const importRuntime = cjs
@@ -240,7 +266,7 @@ export function emitModule (spec, options = {}) {
240
266
  //
241
267
  // The grammar was analyzed when this file was written: FIRST sets, left-recursion
242
268
  // checks and rule specialization all happened then. Importing it does no grammar
243
- // work and generates no code.
269
+ // work and generates no code.${generatedLexer ? '\n// The lexer was written out as source too, so there is no lexer engine to load.' : ''}
244
270
  ${engineFree
245
271
  ? '// Every semantic action was compiled to source too, so this file has no\n// dependency on json-logic-engine at all.'
246
272
  : interpretedActions
@@ -254,7 +280,7 @@ const tokens = ${literal(tokenDefs)}
254
280
 
255
281
  const start = ${quote(start)}
256
282
 
257
- const lexer = createLexer(tokens, { positions: ${quote(positions)} })
283
+ const lexer = ${generatedLexer ? generateLexer(tokenDefs, { positions, scanners, fuse }) : `createLexer(tokens, { positions: ${quote(positions)}, scanners: ${scanners}, fuse: ${fuse} })`}
258
284
  const tokenize = lexer.tokenize
259
285
 
260
286
  ${engineFree
@@ -335,6 +361,14 @@ const H = [
335
361
  ${helpers.map((source, index) => ` /* ${index} */ ${source}`).join(',\n')}
336
362
  ]
337
363
 
364
+ /**
365
+ * Name -> id, the same numbering \`lexer\` assigns. A generated \`alt\`'s switch
366
+ * dispatches on a token's id when it has one; this is only read as a fallback,
367
+ * for a hand-built token from \`parseTokens\` that bypassed this file's own
368
+ * lexer and so has no id of its own.
369
+ */
370
+ const I = ${literal(Object.fromEntries(tokenIds))}
371
+
338
372
  ${ruleSources}
339
373
 
340
374
  const RULES = ${literal(Object.fromEntries(reached.map(([name, index]) => [name, index])))}
@@ -345,7 +379,7 @@ const RULES = ${literal(Object.fromEntries(reached.map(([name, index]) => [name,
345
379
  * @param {string} [source]
346
380
  */
347
381
  function parseTokens (tokenList, source, track = false) {
348
- const state = createState(tokenList, {}, { memo: ${memo}, maxSteps: ${maxSteps}, track })
382
+ const state = createState(tokenList, {}, { memo: ${memo}, maxSteps: ${maxSteps}, track, memoSlots: ${memoRules.length} })
349
383
  state.source = source
350
384
  state.positions = ${quote(positions)}
351
385
  state.ruleNames[0] = start
package/src/lexer.d.ts CHANGED
@@ -5,19 +5,60 @@
5
5
  * large win for punctuation-heavy grammars (JSON is ~60% punctuation by token count).
6
6
  *
7
7
  * @param {TokenDef[]} defs
8
- * @param {{ positions?: 'full' | 'offset' }} [options]
8
+ * @param {{ positions?: 'full' | 'offset', scanners?: boolean, fuse?: boolean, incremental?: boolean, recover?: boolean, errorToken?: string }} [options]
9
9
  * `full` (default) records line and column on every token. `offset` records
10
10
  * only `start`/`end`, which skips a scan of the entire input for newlines —
11
11
  * worth taking when errors do not need to name a line, or when positions are
12
12
  * resolved lazily from the offset afterwards.
13
- * @returns {{ tokenize: (text: string) => Token[], tokenNames: string[], positions: string }}
13
+ *
14
+ * `incremental` (default false) is opt-in: when set, the returned lexer also
15
+ * exposes `tokenizeIncremental` and `relex`, for editors that want to re-lex
16
+ * only the region touched by a single edit instead of the whole document.
17
+ * A lexer created without it is bit-for-bit the plain lexer — no overhead.
18
+ *
19
+ * `scanners` (default true) runs patterns of a few common shapes as
20
+ * table-driven loops instead of regex calls. The tokens are identical;
21
+ * `false` forces every pattern through its regex.
22
+ *
23
+ * `fuse` (default true) joins runs of three or more consecutive regex candidates
24
+ * into one alternation, so a position where several tokens could start costs one
25
+ * regex call rather than one per rejected token. Same tokens; `false` tries them
26
+ * one at a time.
27
+ *
28
+ * `recover` (default false, only meaningful with `incremental`) makes an
29
+ * unmatched character produce a single-character token of type `errorToken`
30
+ * (default `'Error'`) instead of throwing, so highlighting survives invalid
31
+ * or half-typed input.
32
+ * @returns {{ tokenize: (text: string) => Token[], tokenizeIncremental?: (text: string) => IncrementalResult, relex?: (text: string, prev: IncrementalResult, edit: { start: number, oldEnd: number, newEnd: number }) => IncrementalResult, tokenNames: string[], positions: string, modeNames: string[] }}
14
33
  */
15
34
  export function createLexer(defs: TokenDef[], options?: {
16
35
  positions?: "full" | "offset";
36
+ scanners?: boolean;
37
+ fuse?: boolean;
38
+ incremental?: boolean;
39
+ recover?: boolean;
40
+ errorToken?: string;
17
41
  }): {
18
42
  tokenize: (text: string) => Token[];
43
+ tokenizeIncremental?: (text: string) => IncrementalResult;
44
+ relex?: (text: string, prev: IncrementalResult, edit: {
45
+ start: number;
46
+ oldEnd: number;
47
+ newEnd: number;
48
+ }) => IncrementalResult;
19
49
  tokenNames: string[];
20
50
  positions: string;
51
+ modeNames: string[];
52
+ };
53
+ /**
54
+ * Opaque state returned by `tokenizeIncremental`/`relex` — pass it back into
55
+ * `relex` unmodified; only `.tokens` is meant to be read directly.
56
+ */
57
+ export type IncrementalResult = {
58
+ text: string;
59
+ tokens: Token[];
60
+ checkpoints: object[];
61
+ eof: object;
21
62
  };
22
63
  /**
23
64
  * A Chevrotain-style lexer. Token definitions are plain JSON, so a whole