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/README.md +16 -0
- package/bin/grammatik.js +8 -0
- package/dist/cjs/index.js +1471 -104
- package/dist/cjs/runtime.js +635 -25
- package/dist/esm/index.js +1471 -104
- package/dist/esm/runtime.js +635 -25
- package/dist/types/index.d.ts +36 -0
- package/dist/types/runtime.d.ts +4 -3
- package/package.json +1 -1
- package/src/analyze.js +114 -4
- package/src/codegen.js +147 -11
- package/src/definition.js +5 -0
- package/src/emit.js +29 -7
- package/src/lexer.d.ts +43 -2
- package/src/lexer.js +535 -28
- package/src/lexgen.js +451 -0
- package/src/memoplan.js +220 -0
- package/src/methods.js +1 -1
- package/src/parser.js +31 -3
- package/src/plan.js +88 -26
- package/src/runtime.js +8 -6
- package/src/scan.js +422 -0
- package/src/state.js +33 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
}
|