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.
@@ -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.0",
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",
@@ -38,7 +38,8 @@
38
38
  "bench:compare": "node bench/compare-codex.js",
39
39
  "bench:formula": "node bench/peggy/build.js && node bench/formula.js",
40
40
  "bench:generated": "node bench/peggy/build.js && node bench/generate-formula.js && node bench/generated.js",
41
- "bench:json-parsers": "node bench/jsonParsers/run.js"
41
+ "bench:json-parsers": "node bench/jsonParsers/run.js",
42
+ "build:site": "node site/build.js"
42
43
  },
43
44
  "keywords": [
44
45
  "parser",
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,15 +260,169 @@ 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)
263
321
  return op !== null && op !== 'label' && op !== 'action'
264
322
  }
265
323
 
324
+ /** Structural equality over plain JSON — every shape a grammar node can be. */
325
+ function deepEqual (a, b) {
326
+ if (a === b) return true
327
+ if (Array.isArray(a) || Array.isArray(b)) {
328
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
329
+ for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false
330
+ return true
331
+ }
332
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false
333
+ const keysA = Object.keys(a)
334
+ const keysB = Object.keys(b)
335
+ if (keysA.length !== keysB.length) return false
336
+ for (const key of keysA) {
337
+ if (!Object.prototype.hasOwnProperty.call(b, key) || !deepEqual(a[key], b[key])) return false
338
+ }
339
+ return true
340
+ }
341
+
342
+ /**
343
+ * Left-factors an `alt` whose branches are all `seq` and share a literal
344
+ * leading run of nodes — `alt(seq(P, X, actionX), seq(P, Y, actionY))`
345
+ * becomes `seq(P, alt(seq(X, actionX), seq(Y, actionY)))`, so a shared,
346
+ * possibly multi-token prefix is matched once per attempt instead of once per
347
+ * colliding branch.
348
+ *
349
+ * Two things have to hold for the rewrite to be safe, not just structurally
350
+ * possible:
351
+ *
352
+ * - The shared prefix cannot contain a `label` or an `action`. A `seq`'s
353
+ * bindings are scoped to itself; hoisting a labelled node out from under
354
+ * the branch that reads it would strand the binding in a different scope
355
+ * than the action that expects to see it.
356
+ * - Every branch must already end its own decision with an `action`
357
+ * somewhere in its remainder (`seq` yields that action's value instead of
358
+ * the flat array of its children). That is what lets the rewritten form
359
+ * reproduce the original value exactly, by wrapping the factored `alt` in
360
+ * one more `label`/`action` pair rather than redesigning what the branch
361
+ * returns.
362
+ *
363
+ * A branch that is a strict prefix of another (nothing left after the shared
364
+ * run) fails the second check on its own — its remainder is empty, so it has
365
+ * no action left to find — and the whole rewrite backs off rather than guess
366
+ * what an author meant by writing one alternative as a prefix of another.
367
+ *
368
+ * @param {any[]} branches
369
+ * @returns {any|null}
370
+ */
371
+ export function recognizeAltPrefixIdiom (branches) {
372
+ if (!Array.isArray(branches) || branches.length < 2) return null
373
+ if (!branches.every((b) => opOf(b) === 'seq' && Array.isArray(b.seq))) return null
374
+
375
+ const seqs = branches.map((b) => b.seq)
376
+ const minLen = Math.min(...seqs.map((s) => s.length))
377
+
378
+ let n = 0
379
+ while (n < minLen) {
380
+ const candidate = seqs[0][n]
381
+ const op = opOf(candidate)
382
+ if (op === 'label' || op === 'action') break
383
+ if (!seqs.every((s) => deepEqual(s[n], candidate))) break
384
+ n++
385
+ }
386
+ if (n === 0) return null
387
+ if (!seqs.every((s) => s.slice(n).some((child) => opOf(child) === 'action'))) return null
388
+
389
+ const prefix = seqs[0].slice(0, n)
390
+ const remainders = seqs.map((s) => ({ seq: s.slice(n) }))
391
+ return {
392
+ seq: [
393
+ ...prefix,
394
+ { label: ['__grammatik_rest', { alt: remainders }] },
395
+ { action: { val: '__grammatik_rest' } }
396
+ ]
397
+ }
398
+ }
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
+
266
426
  const BAIL = Symbol('grammatik.bail')
267
427
 
268
428
  /**
@@ -346,7 +506,7 @@ function computeFirsts (grammar) {
346
506
  * @param {Map<string, First>} ruleFirsts
347
507
  * @param {Set<string>} out
348
508
  */
349
- function leftmostCalls (node, ruleFirsts, out) {
509
+ export function leftmostCalls (node, ruleFirsts, out) {
350
510
  const op = opOf(node)
351
511
  if (op === null) return
352
512
  const arg = node[op]
@@ -364,6 +524,7 @@ function leftmostCalls (node, ruleFirsts, out) {
364
524
  case 'many':
365
525
  case 'many1':
366
526
  case 'option':
527
+ case 'flag':
367
528
  case 'lookahead':
368
529
  case 'negLookahead':
369
530
  leftmostCalls(arg, ruleFirsts, out)
@@ -374,6 +535,10 @@ function leftmostCalls (node, ruleFirsts, out) {
374
535
  case 'between':
375
536
  if (arg) leftmostCalls({ seq: [arg.open, arg.body, arg.close] }, ruleFirsts, out)
376
537
  break
538
+ case 'pick':
539
+ case 'yields':
540
+ if (arg && Array.isArray(arg.parsers)) leftmostCalls({ seq: arg.parsers }, ruleFirsts, out)
541
+ break
377
542
  case 'prefix':
378
543
  if (arg) {
379
544
  leftmostCalls(arg.operator, ruleFirsts, out)
@@ -451,6 +616,7 @@ function validateNode (node, grammar, tokenNames, errors, path) {
451
616
  case 'many':
452
617
  case 'many1':
453
618
  case 'option':
619
+ case 'flag':
454
620
  case 'text':
455
621
  case 'lookahead':
456
622
  case 'negLookahead':
@@ -472,6 +638,26 @@ function validateNode (node, grammar, tokenNames, errors, path) {
472
638
  }
473
639
  break
474
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
+ }
475
661
  case 'prefix': {
476
662
  if (!arg || typeof arg !== 'object' || !('operator' in arg) || !('operand' in arg) || !('combine' in arg)) {
477
663
  errors.push(`${path}: 'prefix' expects { operator, operand, combine }`)
@@ -573,30 +759,51 @@ export function analyze (grammar, options = {}) {
573
759
  }
574
760
 
575
761
  /**
576
- * Builds an O(1) dispatch table for an `alt`, or null when the branches are not
577
- * LL(1)-separable and we must fall back to ordered backtracking.
762
+ * Builds an O(1) dispatch table for an `alt`, or null when no branch has a
763
+ * known, non-nullable FIRST set (a nullable/opaque branch could match
764
+ * anything, so no table is safe at all).
765
+ *
766
+ * A token whose FIRST set membership is unique to one branch maps straight to
767
+ * that branch. A token shared by more than one branch — the choice needs more
768
+ * than one token of lookahead there — maps to an array of the colliding
769
+ * branches, in their original `alt` order; a caller resolves that case with
770
+ * ordered backtracking over just that subset, rather than over every branch.
771
+ * Equal-content collision groups are the same array instance, so a caller
772
+ * that dedupes on the table's values (e.g. to share generated code between
773
+ * case labels) gets that for free.
578
774
  *
579
775
  * @param {any[]} branches
580
776
  * @param {Map<string, First>} firsts
581
777
  * @returns {Map<string, any>|null}
582
778
  */
583
779
  export function buildDispatch (branches, firsts) {
584
- /** @type {Map<string, any>} */
585
- const table = new Map()
586
-
587
- for (const branch of branches) {
588
- const first = firstOf(branch, firsts)
589
- // A nullable or opaque branch could match anything, so no table is safe.
780
+ const perBranch = branches.map((branch) => firstOf(branch, firsts))
781
+ for (const first of perBranch) {
590
782
  if (first.nullable || first.unknown || first.tokens.size === 0) return null
591
- for (const token of first.tokens) {
592
- // Overlapping FIRST sets mean the choice needs more than one token of
593
- // lookahead; ordered backtracking still handles it correctly.
594
- if (table.has(token)) return null
595
- table.set(token, branch)
783
+ }
784
+
785
+ /** @type {Map<string, any[]>} */
786
+ const byToken = new Map()
787
+ for (let i = 0; i < branches.length; i++) {
788
+ for (const token of perBranch[i].tokens) {
789
+ let list = byToken.get(token)
790
+ if (list === undefined) { list = []; byToken.set(token, list) }
791
+ list.push(branches[i])
596
792
  }
597
793
  }
794
+ if (byToken.size === 0) return null
795
+
796
+ const canonical = new Map()
797
+ const table = new Map()
798
+ for (const [token, list] of byToken) {
799
+ if (list.length === 1) { table.set(token, list[0]); continue }
800
+ const key = list.map((branch) => branches.indexOf(branch)).join(',')
801
+ let group = canonical.get(key)
802
+ if (group === undefined) { group = list; canonical.set(key, group) }
803
+ table.set(token, group)
804
+ }
598
805
 
599
- return table.size > 0 ? table : null
806
+ return table
600
807
  }
601
808
 
602
809
  export { firstOf }