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/README.md +16 -0
- package/bin/grammatik.js +8 -0
- package/dist/cjs/index.js +1637 -139
- package/dist/cjs/runtime.js +642 -34
- package/dist/esm/index.js +1637 -139
- package/dist/esm/runtime.js +642 -34
- package/dist/types/index.d.ts +36 -0
- package/dist/types/runtime.d.ts +4 -3
- package/package.json +3 -2
- package/src/analyze.js +225 -18
- package/src/codegen.js +218 -27
- package/src/definition.js +5 -0
- package/src/emit.js +41 -7
- package/src/lexer.d.ts +43 -2
- package/src/lexer.js +573 -44
- package/src/lexgen.js +451 -0
- package/src/memoplan.js +220 -0
- package/src/methods.js +1 -1
- package/src/parser.js +38 -3
- package/src/plan.js +142 -30
- 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
|
|
@@ -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
|
|
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
|
|
80
|
-
* worth taking when errors do not need to name a line, or when
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
}
|