grammatik 0.1.0 → 0.1.1

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 } from './analyze.js'
6
6
 
7
7
  /**
8
8
  * A real source generator for grammars.
@@ -23,10 +23,23 @@ 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 }} config
27
27
  */
28
28
  export function createCodegen (config) {
29
- const { planner, rules, firsts, memo, maxSteps } = config
29
+ const { planner, rules, firsts, memo, maxSteps, tokenIds = null } = config
30
+
31
+ /**
32
+ * `token.id === <n>` is an integer compare instead of a string compare, and
33
+ * is what generated code prefers wherever a token's declared type matters.
34
+ * Falls back to `token.type === <name>` only for a hand-built token that
35
+ * bypassed this parser's own lexer (`parseTokens` accepts anything shaped
36
+ * like a Token) — every token the bundled lexer produces has an id.
37
+ */
38
+ const typeCheck = (tokenVar, name) => {
39
+ const id = tokenIds ? tokenIds.get(name) : undefined
40
+ if (id === undefined) return `${tokenVar}.type === ${JSON.stringify(name)}`
41
+ return `(${tokenVar}.id === ${id} || (${tokenVar}.id === undefined && ${tokenVar}.type === ${JSON.stringify(name)}))`
42
+ }
30
43
 
31
44
  /** Runtime values the generated code closes over. */
32
45
  const HELPERS = []
@@ -99,7 +112,7 @@ export function createCodegen (config) {
99
112
  out.push(
100
113
  `let ${result};`,
101
114
  `const ${token} = c.tokens[c.idx];`,
102
- `if (${token} !== undefined && ${token}.type === ${JSON.stringify(name)}) { c.idx++; ${result} = ${token}; }`,
115
+ `if (${token} !== undefined && ${typeCheck(token, name)}) { c.idx++; ${result} = ${token}; }`,
103
116
  `else { E(c, ${JSON.stringify(name)}); ${result} = F; }`
104
117
  )
105
118
  return result
@@ -212,7 +225,7 @@ export function createCodegen (config) {
212
225
  case 'oneOf': {
213
226
  const names = Array.isArray(arg) ? arg : [arg]
214
227
  const token = nextId()
215
- const test = names.map((n) => `${token}.type === ${JSON.stringify(n)}`).join(' || ')
228
+ const test = names.map((n) => typeCheck(token, n)).join(' || ')
216
229
  out.push(
217
230
  `let ${result};`,
218
231
  `const ${token} = c.tokens[c.idx];`,
@@ -520,29 +533,75 @@ export function createCodegen (config) {
520
533
  return result
521
534
  }
522
535
 
536
+ /**
537
+ * Emits an inline ordered-backtracking block over `list`, into a fresh
538
+ * variable. Used both for a whole `alt` with no dispatch table, and for the
539
+ * residual subset of branches a dispatch table cannot separate by one
540
+ * token.
541
+ */
542
+ function genOrderedBacktrack (list, out, bindingsVar) {
543
+ const result = nextId()
544
+ const label = 'L' + nextId()
545
+ const start = nextId()
546
+ out.push(`let ${result};`, `${label}: {`, `const ${start} = c.idx;`)
547
+ for (const branch of list) {
548
+ out.push('{')
549
+ const value = gen(branch, out, bindingsVar)
550
+ out.push(`if (${value} !== F) { ${result} = ${value}; break ${label}; }`, `c.idx = ${start};`, '}')
551
+ }
552
+ out.push(`${result} = F;`, '}')
553
+ return result
554
+ }
555
+
523
556
  function genAlt (branches, out, result, bindingsVar) {
524
557
  if (!Array.isArray(branches)) throw new Error("'alt' expects an array of branches")
525
558
 
559
+ const factored = recognizeAltPrefixIdiom(branches)
560
+ if (factored !== null) return gen(factored, out, bindingsVar)
561
+
526
562
  const table = firsts ? buildDispatch(branches, firsts) : null
527
563
  const label = 'L' + nextId()
528
564
 
529
565
  if (table !== null) {
530
566
  // A switch on the token type: V8 turns a dense string switch into a
531
567
  // hash lookup with inlined bodies, and every branch is inlined here.
568
+ // A table value that is an array is a residual group — tokens shared by
569
+ // more than one branch — resolved by ordered backtracking over just
570
+ // that subset instead of a single inlined branch.
571
+ //
572
+ // Switching on token.id (an integer) instead of token.type (a string)
573
+ // when every expected token resolves to one — a numeric switch is a jump
574
+ // table without even the string-hashing step a dense string switch still
575
+ // does. `I[token.type]` is the same fallback typeCheck uses: a hand-built
576
+ // token from parseTokens has no id, and I is a name -> id table built
577
+ // once from the same tokenIds this parser resolved every other id from.
532
578
  const expected = [...table.keys()]
579
+ const expectedIds = tokenIds ? expected.map((t) => tokenIds.get(t)) : null
580
+ const numeric = expectedIds !== null && expectedIds.every((id) => id !== undefined)
533
581
  const token = nextId()
534
582
  out.push(tickSrc, `let ${result};`, `${label}: {`, `const ${token} = c.tokens[c.idx];`)
535
583
  out.push(`if (${token} === undefined) { ${expected.map((t) => `E(c, ${JSON.stringify(t)});`).join(' ')} ${result} = F; break ${label}; }`)
536
- out.push(`switch (${token}.type) {`)
584
+
585
+ let discriminant
586
+ if (numeric) {
587
+ discriminant = nextId()
588
+ out.push(`const ${discriminant} = ${token}.id !== undefined ? ${token}.id : I[${token}.type];`)
589
+ out.push(`switch (${discriminant}) {`)
590
+ } else {
591
+ discriminant = `${token}.type`
592
+ out.push(`switch (${discriminant}) {`)
593
+ }
537
594
 
538
595
  const byBranch = new Map()
539
- for (const [tokenName, branch] of table) {
596
+ for (const tokenName of expected) {
597
+ const branch = table.get(tokenName)
540
598
  if (!byBranch.has(branch)) byBranch.set(branch, [])
541
599
  byBranch.get(branch).push(tokenName)
542
600
  }
601
+ const caseLabel = (t) => numeric ? tokenIds.get(t) : JSON.stringify(t)
543
602
  for (const [branch, tokenNames] of byBranch) {
544
- out.push(tokenNames.map((t) => `case ${JSON.stringify(t)}:`).join(' ') + ' {')
545
- const value = gen(branch, out, bindingsVar)
603
+ out.push(tokenNames.map((t) => `case ${caseLabel(t)}:`).join(' ') + ' {')
604
+ const value = Array.isArray(branch) ? genOrderedBacktrack(branch, out, bindingsVar) : gen(branch, out, bindingsVar)
546
605
  out.push(`${result} = ${value}; break ${label};`, '}')
547
606
  }
548
607
  out.push(
@@ -554,17 +613,13 @@ export function createCodegen (config) {
554
613
  }
555
614
 
556
615
  // 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
616
+ out.push(tickSrc)
617
+ return genOrderedBacktrack(branches, out, bindingsVar)
566
618
  }
567
619
 
620
+ /** Name -> id, for a generated switch's runtime fallback when token.id is missing. */
621
+ const idByName = tokenIds ? Object.fromEntries(tokenIds) : null
622
+
568
623
  /** Generates and installs the function for one rule. */
569
624
  function buildRule (name) {
570
625
  const out = []
@@ -576,8 +631,8 @@ export function createCodegen (config) {
576
631
  SOURCES[ruleIndex.get(name)] = body
577
632
  const source = `return ${body}`
578
633
  // 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)
634
+ const factory = new Function('F', 'E', 'P', 'T', 'R', 'H', 'I', source)
635
+ return factory(FAIL, expect, progressError, stepsError, RULES, HELPERS, idByName)
581
636
  }
582
637
 
583
638
  function progressError (op, at) {
package/src/emit.js CHANGED
@@ -139,9 +139,13 @@ export function emitModule (spec, options = {}) {
139
139
  // The planner is needed only so codegen can record what each helper is; the
140
140
  // closures it makes are thrown away.
141
141
  const firsts = ll1 ? analysis.firsts : null
142
+ // The generated file's own `createLexer(tokenDefs, ...)` call assigns the same
143
+ // ids, from the same `tokenDefs` array in the same order — see the matching
144
+ // comment in parser.js.
145
+ const tokenIds = new Map(lexer.tokenNames.map((name, id) => [name, id]))
142
146
  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 })
147
+ const planner = createPlanner({ engine, rules, firsts, memo, maxSteps, unsafeEval: false, tokenIds })
148
+ const codegen = createCodegen({ planner, rules, firsts, memo, maxSteps, tokenIds })
145
149
 
146
150
  codegen.compile(start)
147
151
 
@@ -335,6 +339,14 @@ const H = [
335
339
  ${helpers.map((source, index) => ` /* ${index} */ ${source}`).join(',\n')}
336
340
  ]
337
341
 
342
+ /**
343
+ * Name -> id, the same numbering \`lexer\` assigns. A generated \`alt\`'s switch
344
+ * dispatches on a token's id when it has one; this is only read as a fallback,
345
+ * for a hand-built token from \`parseTokens\` that bypassed this file's own
346
+ * lexer and so has no id of its own.
347
+ */
348
+ const I = ${literal(Object.fromEntries(tokenIds))}
349
+
338
350
  ${ruleSources}
339
351
 
340
352
  const RULES = ${literal(Object.fromEntries(reached.map(([name, index]) => [name, index])))}
package/src/lexer.js CHANGED
@@ -24,11 +24,19 @@ import { firstCharSet } from './firstchars.js'
24
24
  /**
25
25
  * @typedef {object} Token
26
26
  * @property {string} type
27
+ * @property {number} [id] The token definition's declaration index, present
28
+ * in `offset` position mode only — see the note on
29
+ * `positions` below. Not part of the public contract
30
+ * of a token — internal combinators use it as a
31
+ * faster stand-in for `type` (an integer compare and
32
+ * an array index instead of a string compare and a
33
+ * property lookup); `type` is what a grammar
34
+ * author's code should read.
27
35
  * @property {string} image
28
36
  * @property {number} start
29
37
  * @property {number} end
30
- * @property {number} line
31
- * @property {number} col
38
+ * @property {number} [line] Present in `full` position mode only.
39
+ * @property {number} [col] Present in `full` position mode only.
32
40
  */
33
41
 
34
42
  export class LexError extends Error {
@@ -76,9 +84,13 @@ function modesOf (def) {
76
84
  * @param {TokenDef[]} defs
77
85
  * @param {{ positions?: 'full' | 'offset' }} [options]
78
86
  * `full` (default) records line and column on every token. `offset` records
79
- * only `start`/`end`, which skips a scan of the entire input for newlines —
80
- * worth taking when errors do not need to name a line, or when positions are
81
- * resolved lazily from the offset afterwards.
87
+ * only `start`/`end` plus `id`, which skips a scan of the entire input for
88
+ * newlines — worth taking when errors do not need to name a line, or when
89
+ * positions are resolved lazily from the offset afterwards. The two modes
90
+ * deliberately differ in which extra field they carry rather than both
91
+ * carrying every field: a 7th own property on the token object, in either
92
+ * mode, measured at a real ~30% tokenize regression (see the comment at the
93
+ * push site) that a 6-field object does not pay.
82
94
  * @returns {{ tokenize: (text: string) => Token[], tokenNames: string[], positions: string, modeNames: string[] }}
83
95
  */
84
96
  export function createLexer (defs, options = {}) {
@@ -109,7 +121,7 @@ export function createLexer (defs, options = {}) {
109
121
  }
110
122
  for (const def of defs) for (const mode of modesOf(def)) idOf(mode)
111
123
 
112
- const compiled = defs.map((def) => {
124
+ const compiled = defs.map((def, id) => {
113
125
  if (!def.name) throw new Error('createLexer: every token definition needs a name')
114
126
  if (!def.pattern && !def.literal) throw new Error(`createLexer: token '${def.name}' needs a pattern or a literal`)
115
127
  if (def.pattern && def.literal) throw new Error(`createLexer: token '${def.name}' has both a pattern and a literal; pick one`)
@@ -128,6 +140,11 @@ export function createLexer (defs, options = {}) {
128
140
 
129
141
  return {
130
142
  name: def.name,
143
+ // The declaration index. `createParser` derives the identical mapping
144
+ // from `tokenNames` independently (same array, same order), so a
145
+ // combinator that resolves a `consume` target's id at build time agrees
146
+ // with what a token actually carries without the two ever comparing notes.
147
+ id,
131
148
  skip: !!def.skip,
132
149
  literal: def.literal,
133
150
  longerAlt: def.longerAlt || null,
@@ -283,16 +300,21 @@ export function createLexer (defs, options = {}) {
283
300
  const image = winner.literal !== undefined
284
301
  ? winner.literal
285
302
  : (winner === def && matched !== null ? matched : text.slice(offset, end))
286
- // One object shape either way, so the parser's property loads stay
287
- // monomorphic regardless of the position mode.
288
- tokens.push({
289
- type: winner.name,
290
- image,
291
- start: offset,
292
- end,
293
- line: trackLines ? line : 0,
294
- col: trackLines ? offset - lineStart + 1 : 0
295
- })
303
+ // Two shapes, not one: a 7th own property on this literal — in
304
+ // either position mode — measured at a genuine ~30% tokenize
305
+ // regression in this exact function, reproducible and isolated to
306
+ // the field count alone (a constant 7th field regresses exactly as
307
+ // much as a computed one), but one that does not reduce to a
308
+ // minimal repro elsewhere. Each mode instead stays at 6 own
309
+ // properties: `id` only in offset mode, where line/col would have
310
+ // been dead weight (always 0) anyway; line/col only in full mode,
311
+ // where `id` is what gets dropped. `token.id === undefined` is
312
+ // already how consume/oneOf/alt's dispatch fall back to comparing
313
+ // `type` for a hand-built token from parseTokens, so a full-mode
314
+ // token needs no special casing there — it takes the same fallback.
315
+ tokens.push(trackLines
316
+ ? { type: winner.name, image, start: offset, end, line, col: offset - lineStart + 1 }
317
+ : { type: winner.name, id: winner.id, image, start: offset, end })
296
318
  }
297
319
 
298
320
  // A bounded scan over this token's own span, so the whole tokenize costs
package/src/parser.js CHANGED
@@ -114,9 +114,16 @@ export function createParser (spec, options = {}) {
114
114
  const startRule = rules[start]
115
115
  const firsts = ll1 ? analysis.firsts : null
116
116
 
117
+ // Declaration index of each token name, the same numbering `createLexer`
118
+ // assigns to the tokens it produces (both derive it from the same array in
119
+ // the same order). `consume` compares this instead of the string `type`
120
+ // where it can — an integer compare instead of a string compare, on the
121
+ // hottest path in the parser.
122
+ const tokenIds = new Map(lexer.tokenNames.map((name, id) => [name, id]))
123
+
117
124
  // The planner specializes the grammar into closures; the combinators'
118
125
  // `compile` hooks reach it through the engine.
119
- const planner = createPlanner({ engine, rules, firsts, memo, maxSteps, unsafeEval })
126
+ const planner = createPlanner({ engine, rules, firsts, memo, maxSteps, unsafeEval, tokenIds })
120
127
  engine.__jlGrammar = { planner }
121
128
 
122
129
  // Source generation emits one JS function per rule with every node inlined,
@@ -125,7 +132,7 @@ export function createParser (spec, options = {}) {
125
132
  // lands on the generated code rather than a method dispatch.
126
133
  // Code generation needs `new Function`, so it is off whenever eval is.
127
134
  const codegen = compile && unsafeEval
128
- ? createCodegen({ planner, rules, firsts, memo, maxSteps })
135
+ ? createCodegen({ planner, rules, firsts, memo, maxSteps, tokenIds })
129
136
  : null
130
137
  const compiledStart = codegen && codegen.compile(start)
131
138
 
package/src/plan.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 } from './analyze.js'
6
6
 
7
7
  /**
8
8
  * The specializing planner.
@@ -76,10 +76,11 @@ function mayReturnContext (logic) {
76
76
 
77
77
  /**
78
78
  * @param {{ engine: any, rules: Record<string, any>, firsts: Map<string, any>|null,
79
- * memo: boolean, maxSteps: number, unsafeEval?: boolean }} config
79
+ * memo: boolean, maxSteps: number, unsafeEval?: boolean,
80
+ * tokenIds?: Map<string, number>|null }} config
80
81
  */
81
82
  export function createPlanner (config) {
82
- const { engine, rules, firsts, memo, maxSteps, unsafeEval = true } = config
83
+ const { engine, rules, firsts, memo, maxSteps, unsafeEval = true, tokenIds = null } = config
83
84
 
84
85
  /** @type {Map<string, { fn: ((state: any) => any) | null }>} */
85
86
  const slots = new Map()
@@ -227,9 +228,14 @@ export function createPlanner (config) {
227
228
  /** @returns {(state: any) => any} */
228
229
  const buildConsume = (arg) => {
229
230
  const name = typeof arg === 'string' ? arg : arg[0]
231
+ const id = tokenIds ? tokenIds.get(name) : undefined
232
+ // `token.id` is undefined only for a hand-built token that bypassed
233
+ // `createLexer` (parseTokens accepts any array shaped like a Token), in
234
+ // which case falling back to the string compare keeps that documented
235
+ // path working; every token this parser's own lexer produces has an id.
230
236
  return (state) => {
231
237
  const token = state.tokens[state.idx]
232
- if (token !== undefined && token.type === name) {
238
+ if (token !== undefined && (token.id === id || (token.id === undefined && token.type === name))) {
233
239
  state.idx++
234
240
  return token
235
241
  }
@@ -348,10 +354,11 @@ export function createPlanner (config) {
348
354
  // Terser than an `alt` of `consume`s, and a single set lookup.
349
355
  const names = Array.isArray(arg) ? arg : [arg]
350
356
  const set = new Set(names)
357
+ const idSet = tokenIds ? new Set(names.map((n) => tokenIds.get(n))) : null
351
358
  const count = names.length
352
359
  return (state) => {
353
360
  const token = state.tokens[state.idx]
354
- if (token !== undefined && set.has(token.type)) {
361
+ if (token !== undefined && (idSet ? (token.id !== undefined ? idSet.has(token.id) : set.has(token.type)) : set.has(token.type))) {
355
362
  state.idx++
356
363
  return token
357
364
  }
@@ -700,6 +707,7 @@ export function createPlanner (config) {
700
707
  const kinds = []
701
708
  const names = [] // binding name, for the labelled kinds
702
709
  const tokens = [] // token type, for the terminal kinds
710
+ const tokenIdList = [] // token.id equivalent of `tokens`, for the terminal kinds
703
711
  const fns = []
704
712
  const actionLogic = [] // raw logic of each action child, for the reuse check
705
713
  let hasAction = false
@@ -724,11 +732,13 @@ export function createPlanner (config) {
724
732
  kinds.push(LABELLED_TERMINAL)
725
733
  names.push(name)
726
734
  tokens.push(terminal)
735
+ tokenIdList.push(tokenIds ? tokenIds.get(terminal) : undefined)
727
736
  fns.push(null)
728
737
  } else {
729
738
  kinds.push(LABELLED)
730
739
  names.push(name)
731
740
  tokens.push(null)
741
+ tokenIdList.push(undefined)
732
742
  fns.push(plan(inner))
733
743
  }
734
744
  continue
@@ -738,6 +748,7 @@ export function createPlanner (config) {
738
748
  kinds.push(ACTION)
739
749
  names.push(null)
740
750
  tokens.push(null)
751
+ tokenIdList.push(undefined)
741
752
  fns.push(planAction(child.action))
742
753
  actionLogic.push(child.action)
743
754
  hasAction = true
@@ -749,11 +760,13 @@ export function createPlanner (config) {
749
760
  kinds.push(TERMINAL)
750
761
  names.push(null)
751
762
  tokens.push(terminal)
763
+ tokenIdList.push(tokenIds ? tokenIds.get(terminal) : undefined)
752
764
  fns.push(null)
753
765
  } else {
754
766
  kinds.push(PARSER)
755
767
  names.push(null)
756
768
  tokens.push(null)
769
+ tokenIdList.push(undefined)
757
770
  fns.push(plan(child))
758
771
  }
759
772
  }
@@ -803,7 +816,7 @@ export function createPlanner (config) {
803
816
 
804
817
  if (kind === TERMINAL || kind === LABELLED_TERMINAL) {
805
818
  const token = state.tokens[state.idx]
806
- if (token !== undefined && token.type === tokens[i]) {
819
+ if (token !== undefined && (token.id === tokenIdList[i] || (token.id === undefined && token.type === tokens[i]))) {
807
820
  state.idx++
808
821
  value = token
809
822
  } else {
@@ -837,6 +850,12 @@ export function createPlanner (config) {
837
850
  function planAlt (branches) {
838
851
  if (!Array.isArray(branches)) throw new Error("'alt' expects an array of branches")
839
852
 
853
+ // A shared, possibly multi-token prefix across every branch is matched
854
+ // once here rather than once per branch that has it — see the comment on
855
+ // recognizeAltPrefixIdiom for the safety conditions.
856
+ const factored = recognizeAltPrefixIdiom(branches)
857
+ if (factored !== null) return planUncached(factored)
858
+
840
859
  const planned = branches.map(plan)
841
860
  const table = firsts ? buildDispatch(branches, firsts) : null
842
861
 
@@ -850,8 +869,39 @@ export function createPlanner (config) {
850
869
  // internalized string is an inline-cached property load instead. The null
851
870
  // prototype also means a token type of `toString` or `constructor` cannot
852
871
  // find an inherited value.
872
+ //
873
+ // byId is the same table again, indexed by token.id instead of
874
+ // token.type — an array index instead of a property load. Used whenever
875
+ // a token has an id; byToken stays as the fallback for a hand-built
876
+ // token from parseTokens, which has none.
853
877
  const byToken = Object.create(null)
854
- for (const [token, branch] of table) byToken[token] = planned[branches.indexOf(branch)]
878
+ const byId = tokenIds ? [] : null
879
+ const groupClosures = new Map()
880
+ for (const [token, branch] of table) {
881
+ let closure = groupClosures.get(branch)
882
+ if (closure === undefined) {
883
+ closure = Array.isArray(branch)
884
+ ? (() => {
885
+ const group = branch.map((b) => planned[branches.indexOf(b)])
886
+ return (state) => {
887
+ const start = state.idx
888
+ for (let i = 0; i < group.length; i++) {
889
+ const value = group[i](state)
890
+ if (value !== FAIL) return value
891
+ state.idx = start
892
+ }
893
+ return FAIL
894
+ }
895
+ })()
896
+ : planned[branches.indexOf(branch)]
897
+ groupClosures.set(branch, closure)
898
+ }
899
+ byToken[token] = closure
900
+ if (byId) {
901
+ const id = tokenIds.get(token)
902
+ if (id !== undefined) byId[id] = closure
903
+ }
904
+ }
855
905
  const expectedTokens = [...table.keys()]
856
906
  const expectedCount = expectedTokens.length
857
907
 
@@ -859,7 +909,7 @@ export function createPlanner (config) {
859
909
  tick(state)
860
910
  const token = state.tokens[state.idx]
861
911
  if (token !== undefined) {
862
- const branch = byToken[token.type]
912
+ const branch = byId && token.id !== undefined ? byId[token.id] : byToken[token.type]
863
913
  if (branch !== undefined) return branch(state)
864
914
  }
865
915
  for (let i = 0; i < expectedCount; i++) expect(state, expectedTokens[i])