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.
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
@@ -39,19 +41,7 @@ import { firstCharSet } from './firstchars.js'
39
41
  * @property {number} [col] Present in `full` position mode only.
40
42
  */
41
43
 
42
- export class LexError extends Error {
43
- /**
44
- * @param {string} message
45
- * @param {{ offset: number, line: number, col: number }} position
46
- */
47
- constructor (message, position) {
48
- super(message)
49
- this.name = 'LexError'
50
- this.offset = position.offset
51
- this.line = position.line
52
- this.col = position.col
53
- }
54
- }
44
+ export { LexError }
55
45
 
56
46
  const DEFAULT_MODE = 'default'
57
47
 
@@ -82,7 +72,7 @@ function modesOf (def) {
82
72
  * with a single mode never touches a mode stack at all.
83
73
  *
84
74
  * @param {TokenDef[]} defs
85
- * @param {{ positions?: 'full' | 'offset' }} [options]
75
+ * @param {{ positions?: 'full' | 'offset', scanners?: boolean, fuse?: boolean }} [options]
86
76
  * `full` (default) records line and column on every token. `offset` records
87
77
  * only `start`/`end` plus `id`, which skips a scan of the entire input for
88
78
  * newlines — worth taking when errors do not need to name a line, or when
@@ -91,11 +81,24 @@ function modesOf (def) {
91
81
  * carrying every field: a 7th own property on the token object, in either
92
82
  * mode, measured at a real ~30% tokenize regression (see the comment at the
93
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.
94
95
  * @returns {{ tokenize: (text: string) => Token[], tokenNames: string[], positions: string, modeNames: string[] }}
95
96
  */
96
97
  export function createLexer (defs, options = {}) {
97
98
  const positions = options.positions === 'offset' ? 'offset' : 'full'
98
99
  const trackLines = positions === 'full'
100
+ const useScanners = options.scanners !== false
101
+ const useFuse = options.fuse !== false
99
102
  if (!Array.isArray(defs) || defs.length === 0) throw new Error('createLexer: expected a non-empty array of token definitions')
100
103
 
101
104
  const tokenNames = defs.map((d) => d.name)
@@ -149,9 +152,15 @@ export function createLexer (defs, options = {}) {
149
152
  literal: def.literal,
150
153
  longerAlt: def.longerAlt || null,
151
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,
152
159
  modes: modesOf(def),
153
160
  pushMode: def.pushMode ? idOf(def.pushMode) : -1,
154
- 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
155
164
  }
156
165
  })
157
166
 
@@ -183,18 +192,79 @@ export function createLexer (defs, options = {}) {
183
192
  : firstCharSet(c.regex.source, !!c.regex.ignoreCase)
184
193
  }
185
194
 
186
- 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) => {
187
256
  const members = compiled.filter((c) => c.modes.includes(name))
188
257
  // Definitions whose first characters could not be determined must be tried
189
258
  // at every offset, so they belong in every bucket.
190
259
  const alwaysTry = members.filter((c) => c.firstChars === null)
191
- 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)))
192
261
 
193
262
  const ascii = new Array(128)
194
263
  for (let code = 0; code < 128; code++) ascii[code] = candidatesFor(code)
195
264
 
196
265
  return {
197
266
  name,
267
+ id,
198
268
  members,
199
269
  ascii,
200
270
  wide: new Map(),
@@ -227,6 +297,39 @@ export function createLexer (defs, options = {}) {
227
297
  const namesModes = modes.length > 1
228
298
  const initial = modes[0]
229
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
+
230
333
  /** The `longerAlt` match end, or -1. Only reached when a `longerAlt` is set. */
231
334
  function longerAltEnd (def, text, offset) {
232
335
  const alt = def.longerAltDef
@@ -262,7 +365,7 @@ export function createLexer (defs, options = {}) {
262
365
  const applicable = mode.dispatch ? candidates(mode, text.charCodeAt(offset)) : mode.members
263
366
 
264
367
  for (let i = 0; i < applicable.length; i++) {
265
- const def = applicable[i]
368
+ let def = applicable[i]
266
369
 
267
370
  // Find where the match ends without necessarily building it. `test` on a
268
371
  // sticky regex advances `lastIndex` just like `exec` but skips
@@ -270,17 +373,28 @@ export function createLexer (defs, options = {}) {
270
373
  // read at all, so nothing is allocated for it.
271
374
  let end = -1
272
375
  let matched = null
273
- 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) {
274
382
  if (text.startsWith(def.literal, offset)) end = offset + def.literal.length
275
- } else if (def.skip) {
276
- def.regex.lastIndex = offset
277
- // A zero-length match would spin forever; treat it as no match.
278
- if (def.regex.test(text) && def.regex.lastIndex > offset) end = def.regex.lastIndex
279
383
  } else {
280
- def.regex.lastIndex = offset
281
- const match = def.regex.exec(text)
282
- if (match !== null && match[0].length > 0) matched = match[0]
283
- 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
+ }
284
398
  }
285
399
 
286
400
  if (end === -1) continue
@@ -376,5 +490,398 @@ export function createLexer (defs, options = {}) {
376
490
  const describe = (offset, line, lineStart) =>
377
491
  trackLines ? `line ${line} column ${offset - lineStart + 1}` : `offset ${offset}`
378
492
 
379
- 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 }
380
887
  }