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/lexer.js CHANGED
@@ -2,6 +2,8 @@
2
2
  'use strict'
3
3
 
4
4
  import { firstCharSet } from './firstchars.js'
5
+ import { buildScanner, scanEnd, DEFER } from './scan.js'
6
+ import { LexError } from './state.js'
5
7
 
6
8
  /**
7
9
  * A Chevrotain-style lexer. Token definitions are plain JSON, so a whole
@@ -24,26 +26,22 @@ import { firstCharSet } from './firstchars.js'
24
26
  /**
25
27
  * @typedef {object} Token
26
28
  * @property {string} type
29
+ * @property {number} [id] The token definition's declaration index, present
30
+ * in `offset` position mode only — see the note on
31
+ * `positions` below. Not part of the public contract
32
+ * of a token — internal combinators use it as a
33
+ * faster stand-in for `type` (an integer compare and
34
+ * an array index instead of a string compare and a
35
+ * property lookup); `type` is what a grammar
36
+ * author's code should read.
27
37
  * @property {string} image
28
38
  * @property {number} start
29
39
  * @property {number} end
30
- * @property {number} line
31
- * @property {number} col
40
+ * @property {number} [line] Present in `full` position mode only.
41
+ * @property {number} [col] Present in `full` position mode only.
32
42
  */
33
43
 
34
- export class LexError extends Error {
35
- /**
36
- * @param {string} message
37
- * @param {{ offset: number, line: number, col: number }} position
38
- */
39
- constructor (message, position) {
40
- super(message)
41
- this.name = 'LexError'
42
- this.offset = position.offset
43
- this.line = position.line
44
- this.col = position.col
45
- }
46
- }
44
+ export { LexError }
47
45
 
48
46
  const DEFAULT_MODE = 'default'
49
47
 
@@ -74,16 +72,33 @@ function modesOf (def) {
74
72
  * with a single mode never touches a mode stack at all.
75
73
  *
76
74
  * @param {TokenDef[]} defs
77
- * @param {{ positions?: 'full' | 'offset' }} [options]
75
+ * @param {{ positions?: 'full' | 'offset', scanners?: boolean, fuse?: boolean }} [options]
78
76
  * `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.
77
+ * only `start`/`end` plus `id`, which skips a scan of the entire input for
78
+ * newlines — worth taking when errors do not need to name a line, or when
79
+ * positions are resolved lazily from the offset afterwards. The two modes
80
+ * deliberately differ in which extra field they carry rather than both
81
+ * carrying every field: a 7th own property on the token object, in either
82
+ * mode, measured at a real ~30% tokenize regression (see the comment at the
83
+ * push site) that a 6-field object does not pay.
84
+ *
85
+ * `scanners` (default true) lets patterns of a few common shapes — identifier
86
+ * and digit runs, `KEYWORD(?![A-Za-z0-9_])`, line comments, doubled-quote
87
+ * strings — run as table-driven loops rather than regex calls (see `scan.js`).
88
+ * The tokens produced are identical; `false` forces every pattern through its
89
+ * regex, which is for debugging and for benchmarking the difference.
90
+ *
91
+ * `fuse` (default true) joins runs of three or more consecutive regex
92
+ * candidates into one alternation, so a position where several tokens could
93
+ * start (a template language's `{{` openers) costs one regex call rather than
94
+ * one per rejected token. Same tokens; `false` tries them one at a time.
82
95
  * @returns {{ tokenize: (text: string) => Token[], tokenNames: string[], positions: string, modeNames: string[] }}
83
96
  */
84
97
  export function createLexer (defs, options = {}) {
85
98
  const positions = options.positions === 'offset' ? 'offset' : 'full'
86
99
  const trackLines = positions === 'full'
100
+ const useScanners = options.scanners !== false
101
+ const useFuse = options.fuse !== false
87
102
  if (!Array.isArray(defs) || defs.length === 0) throw new Error('createLexer: expected a non-empty array of token definitions')
88
103
 
89
104
  const tokenNames = defs.map((d) => d.name)
@@ -109,7 +124,7 @@ export function createLexer (defs, options = {}) {
109
124
  }
110
125
  for (const def of defs) for (const mode of modesOf(def)) idOf(mode)
111
126
 
112
- const compiled = defs.map((def) => {
127
+ const compiled = defs.map((def, id) => {
113
128
  if (!def.name) throw new Error('createLexer: every token definition needs a name')
114
129
  if (!def.pattern && !def.literal) throw new Error(`createLexer: token '${def.name}' needs a pattern or a literal`)
115
130
  if (def.pattern && def.literal) throw new Error(`createLexer: token '${def.name}' has both a pattern and a literal; pick one`)
@@ -128,13 +143,24 @@ export function createLexer (defs, options = {}) {
128
143
 
129
144
  return {
130
145
  name: def.name,
146
+ // The declaration index. `createParser` derives the identical mapping
147
+ // from `tokenNames` independently (same array, same order), so a
148
+ // combinator that resolves a `consume` target's id at build time agrees
149
+ // with what a token actually carries without the two ever comparing notes.
150
+ id,
131
151
  skip: !!def.skip,
132
152
  literal: def.literal,
133
153
  longerAlt: def.longerAlt || null,
134
154
  regex: def.pattern ? new RegExp(def.pattern, def.ignoreCase ? 'iy' : 'y') : null,
155
+ // A table-driven stand-in for the regex where its shape is recognised;
156
+ // null otherwise (and always, with `scanners: false`). It is a field on
157
+ // every definition so they all keep one shape. See scan.js.
158
+ scanner: useScanners && def.pattern ? buildScanner(def.pattern, !!def.ignoreCase) : null,
135
159
  modes: modesOf(def),
136
160
  pushMode: def.pushMode ? idOf(def.pushMode) : -1,
137
- popMode: !!def.popMode
161
+ popMode: !!def.popMode,
162
+ // Set only on the stand-in `fuse` builds for a run of regexes; see there.
163
+ members: null
138
164
  }
139
165
  })
140
166
 
@@ -166,18 +192,79 @@ export function createLexer (defs, options = {}) {
166
192
  : firstCharSet(c.regex.source, !!c.regex.ignoreCase)
167
193
  }
168
194
 
169
- const modes = modeNames.map((name) => {
195
+ // ---- fused regex runs ----
196
+ // Dispatch narrows candidates by first character, but several tokens can share
197
+ // one: a template language's `{{` openers (`{{#`, `{{/`, `{{>`, `{{^`, `{{else`,
198
+ // plus a `~` variant and a standalone-line variant of each) all start with `{`,
199
+ // and the plain `{{name}}` tag matches only after ten of them have been tried
200
+ // and rejected. Each rejection is a regex call — a trip into the engine and a
201
+ // `lastIndex` write — that costs more than the few characters it examines.
202
+ //
203
+ // So consecutive plain-regex candidates are joined into one alternation, a capture
204
+ // group per member: one call, and the engine tries the members left to right
205
+ // exactly as the loop would, so the first one that matches still wins. Tokens are
206
+ // identical. Measured, one lexer per process: 1.11x lexing on a Handlebars grammar
207
+ // (1.13x through its own benchmark; ten of its twelve attempts per tag were
208
+ // failures), and no change on the JSON, SQL and formula examples, which have no
209
+ // position where three regexes compete. A perfect dispatch would be 1.67x on the
210
+ // Handlebars grammar; this gets about a fifth of that gap without analysing any
211
+ // regex. (Narrowing candidates by more than the first character would get more,
212
+ // and needs a prefix analysis of the patterns.)
213
+ //
214
+ // Only runs of three or more are worth the match array `exec` allocates, and only
215
+ // members that cannot disturb the numbering or meaning of the groups: no capture
216
+ // groups of their own, no backreferences, and the same flags.
217
+ const FUSE_MIN = 3
218
+ const fusible = (def) =>
219
+ def.literal === undefined && def.scanner === null && def.regex !== null &&
220
+ !/\\[1-9]|\\k</.test(def.regex.source) &&
221
+ // `exec` of an always-matching `|` alternative returns one slot per capture group.
222
+ new RegExp(def.regex.source + '|').exec('').length === 1
223
+
224
+ function fuse (list) {
225
+ if (!useFuse) return list
226
+ const out = []
227
+ for (let i = 0; i < list.length;) {
228
+ let j = i
229
+ while (j < list.length && fusible(list[j]) && list[j].regex.flags === list[i].regex.flags) j++
230
+ if (j - i < FUSE_MIN) {
231
+ out.push(list[i++])
232
+ continue
233
+ }
234
+ const run = list.slice(i, j)
235
+ // A stand-in with every field a definition has, so they all share one shape.
236
+ out.push({
237
+ name: run.map((d) => d.name).join('|'),
238
+ id: -1,
239
+ skip: false,
240
+ literal: undefined,
241
+ longerAlt: null,
242
+ longerAltDef: undefined,
243
+ regex: new RegExp(run.map((d) => `(${d.regex.source})`).join('|'), run[0].regex.flags),
244
+ scanner: null,
245
+ modes: run[0].modes,
246
+ pushMode: -1,
247
+ popMode: false,
248
+ members: run
249
+ })
250
+ i = j
251
+ }
252
+ return out
253
+ }
254
+
255
+ const modes = modeNames.map((name, id) => {
170
256
  const members = compiled.filter((c) => c.modes.includes(name))
171
257
  // Definitions whose first characters could not be determined must be tried
172
258
  // at every offset, so they belong in every bucket.
173
259
  const alwaysTry = members.filter((c) => c.firstChars === null)
174
- const candidatesFor = (code) => members.filter((c) => c.firstChars === null || c.firstChars.has(code))
260
+ const candidatesFor = (code) => fuse(members.filter((c) => c.firstChars === null || c.firstChars.has(code)))
175
261
 
176
262
  const ascii = new Array(128)
177
263
  for (let code = 0; code < 128; code++) ascii[code] = candidatesFor(code)
178
264
 
179
265
  return {
180
266
  name,
267
+ id,
181
268
  members,
182
269
  ascii,
183
270
  wide: new Map(),
@@ -210,6 +297,39 @@ export function createLexer (defs, options = {}) {
210
297
  const namesModes = modes.length > 1
211
298
  const initial = modes[0]
212
299
 
300
+ /** The member of a fused run that matched, and its text, set by `fusedEnd`. */
301
+ let fusedMember = null
302
+ let fusedText = ''
303
+
304
+ /**
305
+ * Where a fused run matches at `offset`, or -1, leaving the winning member and
306
+ * its text in `fusedMember`/`fusedText`. A member that matched nothing counts as
307
+ * not matching (an empty token would never advance), so when the alternation's
308
+ * winner is empty the members are tried one at a time, as the loop would have.
309
+ */
310
+ function fusedEnd (run, text, offset) {
311
+ run.regex.lastIndex = offset
312
+ const match = run.regex.exec(text)
313
+ if (match === null) return -1
314
+ if (match[0].length === 0) {
315
+ for (const member of run.members) {
316
+ member.regex.lastIndex = offset
317
+ const one = member.regex.exec(text)
318
+ if (one !== null && one[0].length > 0) {
319
+ fusedMember = member
320
+ fusedText = one[0]
321
+ return offset + fusedText.length
322
+ }
323
+ }
324
+ return -1
325
+ }
326
+ let group = 1
327
+ while (match[group] === undefined) group++
328
+ fusedMember = run.members[group - 1]
329
+ fusedText = match[0]
330
+ return offset + fusedText.length
331
+ }
332
+
213
333
  /** The `longerAlt` match end, or -1. Only reached when a `longerAlt` is set. */
214
334
  function longerAltEnd (def, text, offset) {
215
335
  const alt = def.longerAltDef
@@ -245,7 +365,7 @@ export function createLexer (defs, options = {}) {
245
365
  const applicable = mode.dispatch ? candidates(mode, text.charCodeAt(offset)) : mode.members
246
366
 
247
367
  for (let i = 0; i < applicable.length; i++) {
248
- const def = applicable[i]
368
+ let def = applicable[i]
249
369
 
250
370
  // Find where the match ends without necessarily building it. `test` on a
251
371
  // sticky regex advances `lastIndex` just like `exec` but skips
@@ -253,17 +373,28 @@ export function createLexer (defs, options = {}) {
253
373
  // read at all, so nothing is allocated for it.
254
374
  let end = -1
255
375
  let matched = null
256
- if (def.literal !== undefined) {
376
+ if (def.members !== null) {
377
+ end = fusedEnd(def, text, offset)
378
+ if (end === -1) continue
379
+ def = fusedMember
380
+ matched = fusedText
381
+ } else if (def.literal !== undefined) {
257
382
  if (text.startsWith(def.literal, offset)) end = offset + def.literal.length
258
- } else if (def.skip) {
259
- def.regex.lastIndex = offset
260
- // A zero-length match would spin forever; treat it as no match.
261
- if (def.regex.test(text) && def.regex.lastIndex > offset) end = def.regex.lastIndex
262
383
  } else {
263
- def.regex.lastIndex = offset
264
- const match = def.regex.exec(text)
265
- if (match !== null && match[0].length > 0) matched = match[0]
266
- if (matched !== null) end = offset + matched.length
384
+ end = def.scanner === null ? DEFER : scanEnd(def.scanner, text, offset, length)
385
+ if (end === DEFER) {
386
+ end = -1
387
+ if (def.skip) {
388
+ def.regex.lastIndex = offset
389
+ // A zero-length match would spin forever; treat it as no match.
390
+ if (def.regex.test(text) && def.regex.lastIndex > offset) end = def.regex.lastIndex
391
+ } else {
392
+ def.regex.lastIndex = offset
393
+ const match = def.regex.exec(text)
394
+ if (match !== null && match[0].length > 0) matched = match[0]
395
+ if (matched !== null) end = offset + matched.length
396
+ }
397
+ }
267
398
  }
268
399
 
269
400
  if (end === -1) continue
@@ -283,16 +414,21 @@ export function createLexer (defs, options = {}) {
283
414
  const image = winner.literal !== undefined
284
415
  ? winner.literal
285
416
  : (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
- })
417
+ // Two shapes, not one: a 7th own property on this literal — in
418
+ // either position mode — measured at a genuine ~30% tokenize
419
+ // regression in this exact function, reproducible and isolated to
420
+ // the field count alone (a constant 7th field regresses exactly as
421
+ // much as a computed one), but one that does not reduce to a
422
+ // minimal repro elsewhere. Each mode instead stays at 6 own
423
+ // properties: `id` only in offset mode, where line/col would have
424
+ // been dead weight (always 0) anyway; line/col only in full mode,
425
+ // where `id` is what gets dropped. `token.id === undefined` is
426
+ // already how consume/oneOf/alt's dispatch fall back to comparing
427
+ // `type` for a hand-built token from parseTokens, so a full-mode
428
+ // token needs no special casing there — it takes the same fallback.
429
+ tokens.push(trackLines
430
+ ? { type: winner.name, image, start: offset, end, line, col: offset - lineStart + 1 }
431
+ : { type: winner.name, id: winner.id, image, start: offset, end })
296
432
  }
297
433
 
298
434
  // A bounded scan over this token's own span, so the whole tokenize costs
@@ -354,5 +490,398 @@ export function createLexer (defs, options = {}) {
354
490
  const describe = (offset, line, lineStart) =>
355
491
  trackLines ? `line ${line} column ${offset - lineStart + 1}` : `offset ${offset}`
356
492
 
357
- return { tokenize, tokenNames, positions, modeNames }
493
+ if (!options.incremental) return { tokenize, tokenNames, positions, modeNames }
494
+
495
+ // ---- incremental mode (opt-in) ----
496
+ // Everything above — matching, dispatch, `tokenize` itself — is untouched by
497
+ // this. A lexer created without `incremental: true` never allocates or runs
498
+ // any of the following, so the benchmarked default path pays nothing for it.
499
+ const recover = !!options.recover
500
+ const errorTokenName = options.errorToken || 'Error'
501
+
502
+ /** A mode-stack snapshot as plain mode ids, comparable with `===`-free equality. */
503
+ function snapshotStack (stack, depth) {
504
+ const ids = new Array(depth)
505
+ for (let i = 0; i < depth; i++) ids[i] = stack[i].id
506
+ return ids
507
+ }
508
+
509
+ function sameStack (a, b) {
510
+ if (a.length !== b.length) return false
511
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
512
+ return true
513
+ }
514
+
515
+ /**
516
+ * Same matching loop as `tokenize`, but resumable: starts from an arbitrary
517
+ * offset and lexer state instead of the beginning, and — the one thing that
518
+ * makes re-lexing possible at all — records the mode/stack/line state
519
+ * *before* every token, so a later edit can resume from the token nearest
520
+ * it instead of from offset 0.
521
+ *
522
+ * With `recover: true`, an unmatched character no longer throws: it becomes
523
+ * a single-character token of type `errorTokenName` and scanning continues,
524
+ * which is what lets a syntax highlighter survive invalid/half-typed input.
525
+ *
526
+ * @param {string} text
527
+ * @param {number} offset
528
+ * @param {*} mode
529
+ * @param {*[]} stack
530
+ * @param {number} depth
531
+ * @param {number} line
532
+ * @param {number} lineStart
533
+ * @returns {{ tokens: Token[], checkpoints: {modeId: number, stack: number[], line: number, lineStart: number, offset: number}[], mode: *, stack: *[], depth: number, line: number, lineStart: number }}
534
+ */
535
+ function scan (text, offset, mode, stack, depth, line, lineStart) {
536
+ const tokens = []
537
+ const checkpoints = []
538
+ const length = text.length
539
+
540
+ outer: while (offset < length) {
541
+ checkpoints.push({ modeId: mode.id, stack: snapshotStack(stack, depth), line, lineStart, offset })
542
+
543
+ const applicable = mode.dispatch ? candidates(mode, text.charCodeAt(offset)) : mode.members
544
+
545
+ for (let i = 0; i < applicable.length; i++) {
546
+ let def = applicable[i]
547
+
548
+ let end = -1
549
+ let matched = null
550
+ if (def.members !== null) {
551
+ end = fusedEnd(def, text, offset)
552
+ if (end === -1) continue
553
+ def = fusedMember
554
+ matched = fusedText
555
+ } else if (def.literal !== undefined) {
556
+ if (text.startsWith(def.literal, offset)) end = offset + def.literal.length
557
+ } else {
558
+ end = def.scanner === null ? DEFER : scanEnd(def.scanner, text, offset, length)
559
+ if (end === DEFER) {
560
+ end = -1
561
+ if (def.skip) {
562
+ def.regex.lastIndex = offset
563
+ if (def.regex.test(text) && def.regex.lastIndex > offset) end = def.regex.lastIndex
564
+ } else {
565
+ def.regex.lastIndex = offset
566
+ const match = def.regex.exec(text)
567
+ if (match !== null && match[0].length > 0) matched = match[0]
568
+ if (matched !== null) end = offset + matched.length
569
+ }
570
+ }
571
+ }
572
+
573
+ if (end === -1) continue
574
+
575
+ let winner = def
576
+ if (def.longerAltDef) {
577
+ const altEnd = longerAltEnd(def, text, offset)
578
+ if (altEnd > end) {
579
+ end = altEnd
580
+ winner = def.longerAltDef
581
+ }
582
+ }
583
+
584
+ if (!winner.skip) {
585
+ const image = winner.literal !== undefined
586
+ ? winner.literal
587
+ : (winner === def && matched !== null ? matched : text.slice(offset, end))
588
+ tokens.push(trackLines
589
+ ? { type: winner.name, image, start: offset, end, line, col: offset - lineStart + 1 }
590
+ : { type: winner.name, id: winner.id, image, start: offset, end })
591
+ }
592
+
593
+ if (trackLines) {
594
+ for (let j = offset; j < end; j++) {
595
+ if (text.charCodeAt(j) !== 10) continue
596
+ line++
597
+ lineStart = j + 1
598
+ }
599
+ }
600
+
601
+ if (multiMode) {
602
+ if (winner.popMode) {
603
+ if (depth === 0) {
604
+ if (!recover) {
605
+ throw new LexError(
606
+ `Token '${winner.name}' popped the initial lexer mode at ${describe(offset, line, lineStart)}`,
607
+ { offset, line: trackLines ? line : 0, col: trackLines ? offset - lineStart + 1 : 0 }
608
+ )
609
+ }
610
+ // Under recovery, an underflowing pop just stays in the current mode.
611
+ } else {
612
+ mode = stack[--depth]
613
+ }
614
+ } else if (winner.pushMode !== -1) {
615
+ stack[depth++] = mode
616
+ mode = modes[winner.pushMode]
617
+ }
618
+ }
619
+
620
+ offset = end
621
+ continue outer
622
+ }
623
+
624
+ if (recover) {
625
+ tokens.push(trackLines
626
+ ? { type: errorTokenName, image: text[offset], start: offset, end: offset + 1, line, col: offset - lineStart + 1 }
627
+ : { type: errorTokenName, id: -1, image: text[offset], start: offset, end: offset + 1 })
628
+ if (trackLines && text.charCodeAt(offset) === 10) {
629
+ line++
630
+ lineStart = offset + 1
631
+ }
632
+ offset++
633
+ continue outer
634
+ }
635
+
636
+ const column = offset - lineStart + 1
637
+ throw new LexError(
638
+ trackLines
639
+ ? `Unexpected character '${text[offset]}' at line ${line} column ${column}${inMode(mode)}`
640
+ : `Unexpected character '${text[offset]}' at offset ${offset}${inMode(mode)}`,
641
+ { offset, line: trackLines ? line : 0, col: trackLines ? column : 0 }
642
+ )
643
+ }
644
+
645
+ return { tokens, checkpoints, mode, stack, depth, line, lineStart }
646
+ }
647
+
648
+ /**
649
+ * Full tokenize, but retaining the checkpoints `relex` needs to resume.
650
+ * The result is opaque state to pass back into `relex`; only `.tokens` is
651
+ * meant to be read directly.
652
+ *
653
+ * @param {string} text
654
+ */
655
+ function tokenizeIncremental (text) {
656
+ if (typeof text !== 'string') throw new TypeError('tokenizeIncremental: expected a string')
657
+ const result = scan(text, 0, initial, multiMode ? [] : [], 0, 1, 0)
658
+ return { text, tokens: result.tokens, checkpoints: result.checkpoints, eof: { modeId: result.mode.id, stack: snapshotStack(result.stack, result.depth), line: result.line, lineStart: result.lineStart, offset: text.length } }
659
+ }
660
+
661
+ /**
662
+ * Index of the first token with `.start >= offset` (tokens are produced in
663
+ * ascending offset order, so this is a binary search, not a scan).
664
+ */
665
+ function tokenFloor (tokens, offset) {
666
+ let lo = 0
667
+ let hi = tokens.length
668
+ while (lo < hi) {
669
+ const mid = (lo + hi) >>> 1
670
+ if (tokens[mid].start < offset) lo = mid + 1
671
+ else hi = mid
672
+ }
673
+ return lo
674
+ }
675
+
676
+ /**
677
+ * Index of the last checkpoint with `.offset <= offset`, treating `eof` as
678
+ * one checkpoint past the end of `checkpoints` — so the result ranges over
679
+ * `-1 .. checkpoints.length` inclusive, `checkpoints.length` meaning "eof".
680
+ */
681
+ function checkpointFloor (checkpoints, eof, offset) {
682
+ let lo = 0
683
+ let hi = checkpoints.length + 1
684
+ while (lo < hi) {
685
+ const mid = (lo + hi) >>> 1
686
+ const at = mid < checkpoints.length ? checkpoints[mid].offset : eof.offset
687
+ if (at <= offset) lo = mid + 1
688
+ else hi = mid
689
+ }
690
+ return lo - 1
691
+ }
692
+
693
+ /** Binary search for a checkpoint at an exact offset, or null. Also checks `eof`. */
694
+ function checkpointAt (checkpoints, eof, offset) {
695
+ if (eof.offset === offset) return eof
696
+ let lo = 0
697
+ let hi = checkpoints.length
698
+ while (lo < hi) {
699
+ const mid = (lo + hi) >>> 1
700
+ if (checkpoints[mid].offset < offset) lo = mid + 1
701
+ else hi = mid
702
+ }
703
+ return lo < checkpoints.length && checkpoints[lo].offset === offset ? checkpoints[lo] : null
704
+ }
705
+
706
+ /**
707
+ * Re-lexes `newText` given the previous `tokenizeIncremental`/`relex` result
708
+ * and a description of what changed, touching only the tokens an editor
709
+ * would actually need to redraw: unaffected tokens are neither copied nor
710
+ * reallocated, and only the region between the edit and the next point
711
+ * where the lexer state provably reconverges is re-scanned. Everything
712
+ * downstream of that point keeps its own object identity — it is mutated
713
+ * in place (offsets shifted by a constant) rather than rebuilt, the same
714
+ * way a real editor's buffer amends positions after an edit instead of
715
+ * reallocating the whole document's worth of them. `prev` is invalidated
716
+ * by this call — it *is* the returned value, mutated; do not keep using
717
+ * a `prev` object after passing it to `relex`.
718
+ *
719
+ * A grammar requirement this depends on: any token whose pattern needs a
720
+ * closing delimiter that can be arbitrarily far away — a block comment or
721
+ * string written as one `/\*[\s\S]*?\*\/`-style regex, rather than a
722
+ * pushMode/popMode pair — has genuinely unbounded lookahead. Typing the
723
+ * missing closer somewhere later can retroactively fold an unbounded
724
+ * amount of intervening text into one token, and no bounded backward walk
725
+ * can see that coming (a checkpoint only records mode + mode-stack; a
726
+ * single-regex delimited token leaves nothing there to notice). Express
727
+ * such tokens as modes — the same reason the library's mode support
728
+ * exists in the first place (see the Handlebars example above) — and this
729
+ * stays correct; a monolithic delimiter regex can produce a wrong result
730
+ * under `relex` even though `tokenize` handles it fine standalone.
731
+ *
732
+ * @param {string} newText
733
+ * @param {{ text: string, tokens: Token[], checkpoints: object[], eof: object }} prev
734
+ * @param {{ start: number, oldEnd: number, newEnd: number }} edit
735
+ * `start`/`oldEnd` describe the replaced span in the *old* text, `newEnd`
736
+ * where that span ends in `newText`.
737
+ */
738
+ function relex (newText, prev, edit) {
739
+ if (typeof newText !== 'string') throw new TypeError('relex: expected a string')
740
+ const tokens = prev.tokens
741
+ const checkpoints = prev.checkpoints
742
+ const delta = edit.newEnd - edit.oldEnd
743
+
744
+ // The last old checkpoint at or before the edit start is where the old
745
+ // lexer state is still known-good — but two different things can still
746
+ // reach back further than that:
747
+ //
748
+ // 1. Maximal munch cascading through more than one *token*: `2` `.` next
749
+ // to an edit can become `2.3`, reaching back through two previous
750
+ // tokens (`\d+(?:\.\d+)?`), not just the one touching the edit. A gap
751
+ // bounds this — tokens can only merge by touching — so walking back
752
+ // across the whole contiguous, gap-free run of tokens ending at the
753
+ // resume point is always enough.
754
+ //
755
+ // 2. A *skip* token's own extent depending on what follows it — a line
756
+ // comment matched `//[^\n]*` up to a newline that the edit is about
757
+ // to delete. Skip tokens never appear in `tokens`, so (1) can't see
758
+ // them; back up one checkpoint first; checkpoints record every
759
+ // iteration, skip or not, and are always contiguous with each other
760
+ // (unlike tokens, which skip over the gaps skip-tokens fill in), so
761
+ // exactly one step is enough to reconsider whichever iteration —
762
+ // token or skip — actually touches the resume point.
763
+ let resumeIndex = checkpointFloor(checkpoints, prev.eof, edit.start)
764
+ if (resumeIndex > 0) resumeIndex--
765
+ let boundary = resumeIndex < checkpoints.length ? checkpoints[resumeIndex].offset : prev.eof.offset
766
+ let ti = tokenFloor(tokens, boundary)
767
+ while (ti > 0 && tokens[ti - 1].end === boundary) {
768
+ ti--
769
+ boundary = tokens[ti].start
770
+ }
771
+ if (boundary < (resumeIndex < checkpoints.length ? checkpoints[resumeIndex].offset : prev.eof.offset)) {
772
+ resumeIndex = checkpointFloor(checkpoints, prev.eof, boundary)
773
+ }
774
+ const resume = resumeIndex < checkpoints.length ? checkpoints[resumeIndex] : prev.eof
775
+ const resumeMode = modes[resume.modeId]
776
+ const resumeStack = resume.stack.map((id) => modes[id])
777
+
778
+ const scanned = scan(newText, resume.offset, resumeMode, resumeStack, resumeStack.length, resume.line, resume.lineStart)
779
+ const scannedEof = { modeId: scanned.mode.id, stack: snapshotStack(scanned.stack, scanned.depth), line: scanned.line, lineStart: scanned.lineStart, offset: newText.length }
780
+
781
+ // A checkpoint's mode + stack matching is necessary but not sufficient: a
782
+ // pattern with a lookbehind (`(?<!...)`, needed e.g. to tell `.5` apart
783
+ // from a path segment written `@.a.5`) reads characters *before* its own
784
+ // start, which a checkpoint carries no record of. Trusting old tail
785
+ // tokens on state equality alone can silently keep a token the old lexer
786
+ // only produced because of text that no longer sits where it looked.
787
+ // `scanned` is already a fully correct scan to EOF regardless of where
788
+ // convergence is found, so checking a small window of it against the
789
+ // corresponding old tokens costs nothing extra — a candidate is only
790
+ // accepted once actual tokens agree, not just lexer state.
791
+ const VERIFY_WINDOW = 4
792
+ function convergenceVerified (oldOffset, newOffset) {
793
+ const oldStart = tokenFloor(tokens, oldOffset)
794
+ const freshStart = tokenFloor(scanned.tokens, newOffset)
795
+ for (let k = 0; k < VERIFY_WINDOW; k++) {
796
+ const oldTok = tokens[oldStart + k]
797
+ const freshTok = scanned.tokens[freshStart + k]
798
+ if (oldTok === undefined && freshTok === undefined) return true
799
+ if (oldTok === undefined || freshTok === undefined) return false
800
+ if (oldTok.type !== freshTok.type || oldTok.image !== freshTok.image) return false
801
+ }
802
+ return true
803
+ }
804
+
805
+ // Look for a point past the edit where the freshly-scanned lexer state
806
+ // matches what the old lexer had at the corresponding old offset — that's
807
+ // where the untouched old token stream can be kept, past this point,
808
+ // instead of re-scanned.
809
+ let convergeNewOffset = -1
810
+ let convergeOldOffset = -1
811
+ let convergeOldCheckpoint = null
812
+ let freshCheckpointCount = 0
813
+ for (let s = 0; s <= scanned.checkpoints.length; s++) {
814
+ const cp = s < scanned.checkpoints.length ? scanned.checkpoints[s] : scannedEof
815
+ if (cp.offset < edit.newEnd) continue
816
+ const oldOffset = cp.offset - delta
817
+ const oldCp = checkpointAt(checkpoints, prev.eof, oldOffset)
818
+ if (oldCp && oldCp.modeId === cp.modeId && sameStack(oldCp.stack, cp.stack) && convergenceVerified(oldOffset, cp.offset)) {
819
+ convergeNewOffset = cp.offset
820
+ convergeOldOffset = oldOffset
821
+ convergeOldCheckpoint = oldCp
822
+ freshCheckpointCount = s
823
+ break
824
+ }
825
+ }
826
+
827
+ const tokenFrom = tokenFloor(tokens, resume.offset)
828
+ const checkpointFrom = resumeIndex
829
+
830
+ if (convergeNewOffset === -1) {
831
+ // No reconvergence found (e.g. the edit permanently changed the mode
832
+ // stack, such as opening an unterminated string) — the full re-scan
833
+ // `scan` already did stands as the rest of the result; nothing after
834
+ // it survives to be spliced back in.
835
+ tokens.splice(tokenFrom, tokens.length - tokenFrom, ...scanned.tokens)
836
+ checkpoints.splice(checkpointFrom, checkpoints.length - checkpointFrom, ...scanned.checkpoints)
837
+ prev.text = newText
838
+ prev.eof = scannedEof
839
+ return prev
840
+ }
841
+
842
+ const freshTokenCount = tokenFloor(scanned.tokens, convergeNewOffset)
843
+ const lineDelta = trackLines ? scanned.checkpoints.length > freshCheckpointCount
844
+ ? scanned.checkpoints[freshCheckpointCount].line - convergeOldCheckpoint.line
845
+ : scannedEof.line - convergeOldCheckpoint.line
846
+ : 0
847
+
848
+ // The line *containing* the convergence point can't be shifted by a
849
+ // formula — the edit may have inserted or removed newlines inside it, so
850
+ // its true start has to come from the real scan. Every later line is
851
+ // untouched original text reused verbatim past that point, so its start
852
+ // shifts by the plain character delta.
853
+ const boundaryOldLineStart = convergeOldCheckpoint.lineStart
854
+ const boundaryNewLineStart = (freshCheckpointCount < scanned.checkpoints.length ? scanned.checkpoints[freshCheckpointCount] : scannedEof).lineStart
855
+ const shiftedLineStart = (oldLineStart) => oldLineStart === boundaryOldLineStart ? boundaryNewLineStart : oldLineStart + delta
856
+
857
+ const tailTokenFrom = tokenFloor(tokens, convergeOldOffset)
858
+ for (let i = tailTokenFrom; i < tokens.length; i++) {
859
+ const t = tokens[i]
860
+ if (trackLines) {
861
+ const oldLineStart = t.start - (t.col - 1)
862
+ t.col = (t.start + delta) - shiftedLineStart(oldLineStart) + 1
863
+ t.line += lineDelta
864
+ }
865
+ t.start += delta
866
+ t.end += delta
867
+ }
868
+ tokens.splice(tokenFrom, tailTokenFrom - tokenFrom, ...scanned.tokens.slice(0, freshTokenCount))
869
+
870
+ const tailCheckpointFrom = checkpointFloor(checkpoints, prev.eof, convergeOldOffset - 1) + 1
871
+ for (let i = tailCheckpointFrom; i < checkpoints.length; i++) {
872
+ const cp = checkpoints[i]
873
+ cp.lineStart = shiftedLineStart(cp.lineStart)
874
+ cp.line += lineDelta
875
+ cp.offset += delta
876
+ }
877
+ checkpoints.splice(checkpointFrom, tailCheckpointFrom - checkpointFrom, ...scanned.checkpoints.slice(0, freshCheckpointCount))
878
+
879
+ prev.text = newText
880
+ prev.eof.lineStart = shiftedLineStart(prev.eof.lineStart)
881
+ prev.eof.line += lineDelta
882
+ prev.eof.offset += delta
883
+ return prev
884
+ }
885
+
886
+ return { tokenize, tokenizeIncremental, relex, tokenNames, positions, modeNames }
358
887
  }