grammatik 0.1.1 → 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.
@@ -70,6 +70,29 @@ export interface ParserOptions {
70
70
  positions?: PositionMode
71
71
  /** Packrat memoization on (rule, position). */
72
72
  memo?: boolean
73
+ /**
74
+ * Memoize only the rules that several colliding branches of an `alt` would
75
+ * otherwise each re-parse (default `true`; ignored when `memo` is on). Avoids
76
+ * the exponential blow-up of nested alternatives that share a leading rule,
77
+ * at no cost to a grammar with none. See `memoRules`.
78
+ */
79
+ autoMemo?: boolean
80
+ /**
81
+ * In generated code, write single-caller operator levels (`infixLeft`,
82
+ * `infixRight`, `prefix`, `postfix`) into their caller instead of calling them
83
+ * (default `true` for `createParser`, `false` for `emitModule`). Several levels of an
84
+ * expression grammar become one function: 2-3x faster on a bare precedence chain, about
85
+ * 1.02-1.04x on real grammars, at the cost of a bigger file, which is why an emitted
86
+ * parser leaves it off.
87
+ */
88
+ inlineRules?: boolean
89
+ /**
90
+ * Recognize common spellings of a grammar (`seq(open, label, close, action)`, a
91
+ * prefix plus one labelled result, a constant action, an `alt` of bare `consume`s,
92
+ * a shared leading run of `alt` branches) and run them as the cheaper combinator
93
+ * they are equivalent to. Default `true`; `false` runs the grammar exactly as written.
94
+ */
95
+ idioms?: boolean
73
96
  /** Require the whole token stream to be consumed. */
74
97
  strict?: boolean
75
98
  /** FIRST-set dispatch for `alt`. Off forces ordered backtracking. */
@@ -109,6 +132,8 @@ export interface JLParser {
109
132
  engine: any
110
133
  analysis: Analysis
111
134
  execution: ExecutionMode
135
+ /** The rules `autoMemo` chose to memoize, in declaration order. */
136
+ memoRules: string[]
112
137
  compiled: boolean
113
138
  readonly firsts: Record<string, Set<string>>
114
139
  }
@@ -126,6 +151,17 @@ export declare function analyze (
126
151
  ): Analysis
127
152
 
128
153
  export interface EmitOptions extends ParserOptions {
154
+ /**
155
+ * How the file lexes. `'generated'` (default) writes the lexer out as source, so the
156
+ * file imports only the parse state and error class from the runtime: a smaller
157
+ * bundle (JSON: 24 KB to 10 KB minified), a faster start, and faster lexing.
158
+ * `'runtime'` keeps the token table and builds the lexer from it when the file loads.
159
+ */
160
+ lexer?: 'generated' | 'runtime'
161
+ /** Scanner substitution for the lexer, as for `createLexer`. Default `true`. */
162
+ scanners?: boolean
163
+ /** Fusing runs of regex tokens in the lexer, as for `createLexer`. Default `true`. */
164
+ fuse?: boolean
129
165
  /** Module format of the generated file. */
130
166
  format?: 'esm' | 'cjs'
131
167
  /** Name recorded in the file header. Defaults to `spec.name`. */
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * `grammatik/runtime` — the surface a generated parser imports.
3
3
  *
4
- * Deliberately small: a generated file needs the lexer, the parse state and
5
- * error construction, and nothing else. The analyzer, planner and code
6
- * generator were already run when the file was written.
4
+ * Deliberately small: a generated file needs the parse state and error
5
+ * construction, and nothing else. The analyzer, planner, code generator and
6
+ * (by default) the lexer were already run when the file was written. `createLexer`
7
+ * is still here for files emitted with `lexer: 'runtime'`.
7
8
  */
8
9
 
9
10
  import type { Token, TokenDef, PositionMode, Lexer, Parser } from './index.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grammatik",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A parser toolkit whose grammars are data: tokens, rules and semantic actions all serializable, compiled ahead of time.",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",
package/src/analyze.js CHANGED
@@ -60,9 +60,9 @@ export function opOf (node) {
60
60
 
61
61
  /** The set of node shapes this module understands as *parsers*. */
62
62
  export const PARSER_OPS = new Set([
63
- 'consume', 'seq', 'alt', 'many', 'many1', 'option',
63
+ 'consume', 'seq', 'alt', 'many', 'many1', 'option', 'flag',
64
64
  'manySep', 'many1Sep', 'infixLeft', 'infixRight', 'postfix', 'prefix',
65
- 'between', 'as', 'oneOf', 'eof',
65
+ 'between', 'pick', 'yields', 'as', 'oneOf', 'eof',
66
66
  'subrule', 'label', 'action',
67
67
  'lookahead', 'negLookahead', 'text', 'epsilon'
68
68
  ])
@@ -122,7 +122,8 @@ function firstOf (node, ruleFirsts) {
122
122
  }
123
123
 
124
124
  case 'many':
125
- case 'option': {
125
+ case 'option':
126
+ case 'flag': {
126
127
  const inner = firstOf(arg, ruleFirsts)
127
128
  return { tokens: inner.tokens, nullable: true, unknown: inner.unknown }
128
129
  }
@@ -153,6 +154,11 @@ function firstOf (node, ruleFirsts) {
153
154
  // Exactly a sequence of open, body, close.
154
155
  return firstOf({ seq: [arg && arg.open, arg && arg.body, arg && arg.close] }, ruleFirsts)
155
156
 
157
+ case 'pick':
158
+ case 'yields':
159
+ // Exactly a sequence of its parsers.
160
+ return firstOf({ seq: arg && Array.isArray(arg.parsers) ? arg.parsers : [] }, ruleFirsts)
161
+
156
162
  case 'prefix': {
157
163
  // Zero operators are allowed, so the operand's first set counts too.
158
164
  const ops = firstOf(arg && arg.operator, ruleFirsts)
@@ -254,9 +260,61 @@ export function recognizeSeqIdiom (children) {
254
260
  }
255
261
  }
256
262
 
263
+ // The two idioms below drop the `seq`'s bindings object, so they decline when a
264
+ // child could reach into an enclosing scope: without that object an outward hop
265
+ // would land one scope further out than it did.
266
+ if (children.length >= 2) {
267
+ const last = children[children.length - 1]
268
+ const head = children.slice(0, -1)
269
+
270
+ // yields: parsers, then an action that is a constant — `seq(consume(True), action(true))`
271
+ if (opOf(last) === 'action' && isConstant(last.action) && head.every(isPlainParser) && !head.some(usesOuterScope)) {
272
+ return { yields: { parsers: head, value: last.action } }
273
+ }
274
+
275
+ // pick: any number of unlabelled parsers around exactly one labelled one, then
276
+ // an action that returns just that label — `seq(consume(Where), label(e, P), action({val: e}))`.
277
+ // `between` is the special case with one on each side.
278
+ if (opOf(last) === 'action' && opOf(last.action) === 'val' && typeof last.action.val === 'string') {
279
+ const name = last.action.val
280
+ const labelled = head.map((child, i) => (opOf(child) === 'label' ? i : -1)).filter((i) => i !== -1)
281
+ if (labelled.length === 1 && head[labelled[0]].label[0] === name &&
282
+ head.every((child, i) => i === labelled[0] || isPlainParser(child)) &&
283
+ !head.some(usesOuterScope)) {
284
+ return { pick: { parsers: head.map((child, i) => (i === labelled[0] ? child.label[1] : child)), index: labelled[0] } }
285
+ }
286
+ }
287
+ }
288
+
257
289
  return null
258
290
  }
259
291
 
292
+ /** A value that cannot be mutated or aliased: safe to hand out from every parse. */
293
+ const isConstant = (value) =>
294
+ value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
295
+
296
+ /**
297
+ * True when the logic reads from an outer scope (`{val: [[-1], ...]}` or
298
+ * `{var: "../x"}`). The engine's compiler does not thread `above` into built
299
+ * functions, so such actions must stay interpreted — and a rewrite that removes
300
+ * a `seq`'s bindings object would change what such a hop reaches.
301
+ */
302
+ export function usesOuterScope (logic) {
303
+ if (Array.isArray(logic)) return logic.some(usesOuterScope)
304
+ if (!logic || typeof logic !== 'object') return false
305
+
306
+ for (const key of Object.keys(logic)) {
307
+ const arg = logic[key]
308
+ if (key === 'val' && Array.isArray(arg) && Array.isArray(arg[0])) return true
309
+ if (key === 'var') {
310
+ if (typeof arg === 'string' && arg.includes('../')) return true
311
+ if (Array.isArray(arg) && typeof arg[0] === 'string' && arg[0].includes('../')) return true
312
+ }
313
+ if (usesOuterScope(arg)) return true
314
+ }
315
+ return false
316
+ }
317
+
260
318
  /** A node that is a parser and not one of `seq`'s own interpreted shapes. */
261
319
  function isPlainParser (node) {
262
320
  const op = opOf(node)
@@ -339,6 +397,32 @@ export function recognizeAltPrefixIdiom (branches) {
339
397
  }
340
398
  }
341
399
 
400
+ /**
401
+ * Rewrites an `alt` whose every branch is a bare `consume` into `oneOf`:
402
+ * `alt(consume(Plus), consume(Minus))` is `oneOf(Plus, Minus)`.
403
+ *
404
+ * Same language, same value (the matched token), same expected-token list on
405
+ * failure — a token has one type, so "first branch that matches" and "any of
406
+ * these" cannot differ. What differs is cost: `alt` builds a branch per token,
407
+ * `oneOf` is a single test. Measured at 1.3–1.8x for the 2–7 token operator sets
408
+ * grammars actually have, and still 1.2x at 24.
409
+ *
410
+ * @param {any[]} branches
411
+ * @returns {{ oneOf: string[] } | null}
412
+ */
413
+ export function recognizeAltConsumeIdiom (branches) {
414
+ if (!Array.isArray(branches) || branches.length < 2) return null
415
+ const names = []
416
+ for (const branch of branches) {
417
+ if (opOf(branch) !== 'consume') return null
418
+ const arg = branch.consume
419
+ const name = typeof arg === 'string' ? arg : (Array.isArray(arg) && arg.length === 1 ? arg[0] : null)
420
+ if (typeof name !== 'string') return null
421
+ names.push(name)
422
+ }
423
+ return { oneOf: names }
424
+ }
425
+
342
426
  const BAIL = Symbol('grammatik.bail')
343
427
 
344
428
  /**
@@ -422,7 +506,7 @@ function computeFirsts (grammar) {
422
506
  * @param {Map<string, First>} ruleFirsts
423
507
  * @param {Set<string>} out
424
508
  */
425
- function leftmostCalls (node, ruleFirsts, out) {
509
+ export function leftmostCalls (node, ruleFirsts, out) {
426
510
  const op = opOf(node)
427
511
  if (op === null) return
428
512
  const arg = node[op]
@@ -440,6 +524,7 @@ function leftmostCalls (node, ruleFirsts, out) {
440
524
  case 'many':
441
525
  case 'many1':
442
526
  case 'option':
527
+ case 'flag':
443
528
  case 'lookahead':
444
529
  case 'negLookahead':
445
530
  leftmostCalls(arg, ruleFirsts, out)
@@ -450,6 +535,10 @@ function leftmostCalls (node, ruleFirsts, out) {
450
535
  case 'between':
451
536
  if (arg) leftmostCalls({ seq: [arg.open, arg.body, arg.close] }, ruleFirsts, out)
452
537
  break
538
+ case 'pick':
539
+ case 'yields':
540
+ if (arg && Array.isArray(arg.parsers)) leftmostCalls({ seq: arg.parsers }, ruleFirsts, out)
541
+ break
453
542
  case 'prefix':
454
543
  if (arg) {
455
544
  leftmostCalls(arg.operator, ruleFirsts, out)
@@ -527,6 +616,7 @@ function validateNode (node, grammar, tokenNames, errors, path) {
527
616
  case 'many':
528
617
  case 'many1':
529
618
  case 'option':
619
+ case 'flag':
530
620
  case 'text':
531
621
  case 'lookahead':
532
622
  case 'negLookahead':
@@ -548,6 +638,26 @@ function validateNode (node, grammar, tokenNames, errors, path) {
548
638
  }
549
639
  break
550
640
  }
641
+ case 'pick': {
642
+ if (!arg || !Array.isArray(arg.parsers) || arg.parsers.length === 0) {
643
+ errors.push(`${path}: 'pick' expects { parsers: [parser, ...], index }`)
644
+ } else if (!Number.isInteger(arg.index) || arg.index < 0 || arg.index >= arg.parsers.length) {
645
+ errors.push(`${path}: 'pick' index ${JSON.stringify(arg.index)} is out of range for ${arg.parsers.length} parser(s)`)
646
+ } else {
647
+ arg.parsers.forEach((c, i) => validateNode(c, grammar, tokenNames, errors, `${path}.pick[${i}]`))
648
+ }
649
+ break
650
+ }
651
+ case 'yields': {
652
+ if (!arg || !Array.isArray(arg.parsers) || arg.parsers.length === 0 || !('value' in arg)) {
653
+ errors.push(`${path}: 'yields' expects { parsers: [parser, ...], value }`)
654
+ } else if (!isConstant(arg.value)) {
655
+ errors.push(`${path}: 'yields' value must be a string, number, boolean or null, got ${JSON.stringify(arg.value)}`)
656
+ } else {
657
+ arg.parsers.forEach((c, i) => validateNode(c, grammar, tokenNames, errors, `${path}.yields[${i}]`))
658
+ }
659
+ break
660
+ }
551
661
  case 'prefix': {
552
662
  if (!arg || typeof arg !== 'object' || !('operator' in arg) || !('operand' in arg) || !('combine' in arg)) {
553
663
  errors.push(`${path}: 'prefix' expects { operator, operand, combine }`)
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, recognizeAltPrefixIdiom } 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,11 @@ import { buildDispatch, opOf, recognizeSeqIdiom, recognizeAltPrefixIdiom } from
23
23
 
24
24
  /**
25
25
  * @param {{ planner: any, rules: Record<string, any>, firsts: Map<string, any>|null,
26
- * memo: boolean, maxSteps: number, tokenIds?: Map<string, number>|null }} 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, tokenIds = null } = config
30
+ const { planner, rules, firsts, memo, maxSteps, tokenIds = null, memoSlots = new Map(), inlineRules = true, idioms = true } = config
30
31
 
31
32
  /**
32
33
  * `token.id === <n>` is an integer compare instead of a string compare, and
@@ -74,6 +75,56 @@ export function createCodegen (config) {
74
75
  /** A literal value sitting in parser position. */
75
76
  const constantHelper = (node) => helper(planner.plan(node), { kind: 'constant', node })
76
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
+
77
128
  function indexOfRule (name) {
78
129
  let index = ruleIndex.get(name)
79
130
  if (index !== undefined) return index
@@ -153,6 +204,17 @@ export function createCodegen (config) {
153
204
  return result
154
205
  }
155
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
+
156
218
  case 'many':
157
219
  case 'many1': {
158
220
  const atLeastOne = op === 'many1'
@@ -172,7 +234,7 @@ export function createCodegen (config) {
172
234
  }
173
235
 
174
236
  out.push('for (;;) {', `const ${before} = c.idx;`)
175
- const value = gen(arg, out, bindingsVar)
237
+ const value = atLeastOne ? cold(() => gen(arg, out, bindingsVar)) : gen(arg, out, bindingsVar)
176
238
  out.push(
177
239
  `if (${value} === F) { c.idx = ${before}; break; }`,
178
240
  `if (c.idx === ${before}) P(${JSON.stringify(op)}, ${before});`,
@@ -209,7 +271,7 @@ export function createCodegen (config) {
209
271
  out.push(`if (${operatorVar} === F) { c.idx = ${mark}; break; }`)
210
272
  }
211
273
 
212
- const right = gen(arg.operand, out, bindingsVar)
274
+ const right = cold(() => gen(arg.operand, out, bindingsVar))
213
275
  out.push(
214
276
  `if (${right} === F) { c.idx = ${mark}; break; }`,
215
277
  `if (${bindings} === null) ${bindings} = { left: null, op: null, right: null };`,
@@ -225,12 +287,34 @@ export function createCodegen (config) {
225
287
  case 'oneOf': {
226
288
  const names = Array.isArray(arg) ? arg : [arg]
227
289
  const token = nextId()
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
+
228
312
  const test = names.map((n) => typeCheck(token, n)).join(' || ')
229
313
  out.push(
230
314
  `let ${result};`,
231
315
  `const ${token} = c.tokens[c.idx];`,
232
316
  `if (${token} !== undefined && (${test})) { c.idx++; ${result} = ${token}; }`,
233
- `else { ${names.map((n) => `E(c, ${JSON.stringify(n)});`).join(' ')} ${result} = F; }`
317
+ failed
234
318
  )
235
319
  return result
236
320
  }
@@ -266,7 +350,7 @@ export function createCodegen (config) {
266
350
  operatorVar = gen(arg.operator, out, bindingsVar)
267
351
  out.push(`if (${operatorVar} === F) { c.idx = ${mark}; break; }`)
268
352
  }
269
- const right = gen(arg.operand, out, bindingsVar)
353
+ const right = cold(() => gen(arg.operand, out, bindingsVar))
270
354
  out.push(
271
355
  `if (${right} === F) { c.idx = ${mark}; break; }`,
272
356
  `if (${items} === null) ${items} = [];`,
@@ -347,6 +431,22 @@ export function createCodegen (config) {
347
431
  return result
348
432
  }
349
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
+
350
450
  case 'prefix': {
351
451
  const label = 'L' + nextId()
352
452
  const start = nextId()
@@ -428,7 +528,7 @@ export function createCodegen (config) {
428
528
  )
429
529
  const sep = gen(sepNode, out, bindingsVar)
430
530
  out.push(`if (${sep} === F) { c.idx = ${beforeSep}; break; }`, `const ${beforeItem} = c.idx;`)
431
- const item = gen(arg.rule, out, bindingsVar)
531
+ const item = cold(() => gen(arg.rule, out, bindingsVar))
432
532
  out.push(
433
533
  `if (${item} === F) { c.idx = ${trailing ? beforeItem : beforeSep}; break; }`,
434
534
  `${result}.push(${item});`,
@@ -445,8 +545,20 @@ export function createCodegen (config) {
445
545
  out.push(`const ${result} = ${helper(planner.plan(node), { kind: 'unknownRule', name })}(c);`)
446
546
  return result
447
547
  }
448
- const index = indexOfRule(name)
449
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)
450
562
  out.push(tickSrc)
451
563
  if (memo) {
452
564
  const key = nextId()
@@ -467,6 +579,27 @@ export function createCodegen (config) {
467
579
  `c.memo.set(${key}, { value: ${result}, end: c.idx });`,
468
580
  '}'
469
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
+ )
470
603
  } else {
471
604
  out.push(
472
605
  `const ${parent} = c.ruleDepth;`,
@@ -489,7 +622,7 @@ export function createCodegen (config) {
489
622
  if (!Array.isArray(children)) throw new Error("'seq' expects an array of parsers")
490
623
 
491
624
  // Same idiom recognition as the planner, so both paths agree.
492
- const idiom = recognizeSeqIdiom(children)
625
+ const idiom = idioms ? recognizeSeqIdiom(children) : null
493
626
  if (idiom !== null) return gen(idiom, out, null)
494
627
 
495
628
  let hasLabels = false
@@ -556,7 +689,10 @@ export function createCodegen (config) {
556
689
  function genAlt (branches, out, result, bindingsVar) {
557
690
  if (!Array.isArray(branches)) throw new Error("'alt' expects an array of branches")
558
691
 
559
- const factored = recognizeAltPrefixIdiom(branches)
692
+ const oneOf = idioms ? recognizeAltConsumeIdiom(branches) : null
693
+ if (oneOf !== null) return gen(oneOf, out, bindingsVar)
694
+
695
+ const factored = idioms ? recognizeAltPrefixIdiom(branches) : null
560
696
  if (factored !== null) return gen(factored, out, bindingsVar)
561
697
 
562
698
  const table = firsts ? buildDispatch(branches, firsts) : null
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)
@@ -144,8 +160,12 @@ export function emitModule (spec, options = {}) {
144
160
  // comment in parser.js.
145
161
  const tokenIds = new Map(lexer.tokenNames.map((name, id) => [name, id]))
146
162
  const engine = { build: () => { throw new Error('not compiled during emit') }, run: () => {} }
147
- const planner = createPlanner({ engine, rules, firsts, memo, maxSteps, unsafeEval: false, tokenIds })
148
- const codegen = createCodegen({ planner, rules, firsts, memo, maxSteps, tokenIds })
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 })
149
169
 
150
170
  codegen.compile(start)
151
171
 
@@ -214,7 +234,9 @@ export function emitModule (spec, options = {}) {
214
234
  // actions may use the value methods, so it has to register them the way
215
235
  // `createParser` does. They come from the runtime rather than being written
216
236
  // out here, so there is one implementation rather than two.
217
- 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']
218
240
  if (needsEngine) runtimeImports.push('createValueMethods')
219
241
 
220
242
  const importRuntime = cjs
@@ -244,7 +266,7 @@ export function emitModule (spec, options = {}) {
244
266
  //
245
267
  // The grammar was analyzed when this file was written: FIRST sets, left-recursion
246
268
  // checks and rule specialization all happened then. Importing it does no grammar
247
- // 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.' : ''}
248
270
  ${engineFree
249
271
  ? '// Every semantic action was compiled to source too, so this file has no\n// dependency on json-logic-engine at all.'
250
272
  : interpretedActions
@@ -258,7 +280,7 @@ const tokens = ${literal(tokenDefs)}
258
280
 
259
281
  const start = ${quote(start)}
260
282
 
261
- 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} })`}
262
284
  const tokenize = lexer.tokenize
263
285
 
264
286
  ${engineFree
@@ -357,7 +379,7 @@ const RULES = ${literal(Object.fromEntries(reached.map(([name, index]) => [name,
357
379
  * @param {string} [source]
358
380
  */
359
381
  function parseTokens (tokenList, source, track = false) {
360
- const state = createState(tokenList, {}, { memo: ${memo}, maxSteps: ${maxSteps}, track })
382
+ const state = createState(tokenList, {}, { memo: ${memo}, maxSteps: ${maxSteps}, track, memoSlots: ${memoRules.length} })
361
383
  state.source = source
362
384
  state.positions = ${quote(positions)}
363
385
  state.ruleNames[0] = start