grammatik 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lexgen.js ADDED
@@ -0,0 +1,451 @@
1
+ // @ts-check
2
+ 'use strict'
3
+
4
+ import { firstCharSet } from './firstchars.js'
5
+ import { buildScanner, SCANNER_KINDS } from './scan.js'
6
+
7
+ /**
8
+ * Writes a grammar's lexer out as JavaScript source.
9
+ *
10
+ * `createLexer` is an engine: handed the token table, it compiles regexes,
11
+ * analyses them for first-character dispatch, picks hand-written scanners, builds
12
+ * per-mode tables and fuses runs of regexes — at load time, in every process, and
13
+ * it ships all of that to every consumer. None of it depends on anything but the
14
+ * token table, so for an emitted parser it can happen once, when the file is
15
+ * written. What is left to ship is the code that grammar needs.
16
+ *
17
+ * The generated `tokenize` means exactly what `createLexer(defs, options).tokenize`
18
+ * means: for each offset the candidates are the tokens of the current mode whose
19
+ * first character allows it, in declaration order, and the first that matches
20
+ * wins. Same tokens, same fields, same errors. `test/lexgen.test.js` holds it to
21
+ * that across every example grammar.
22
+ *
23
+ * Measured against the engine, for the emitted parser:
24
+ * - bundle: JSON 24.0 -> 9.6 KB minified (8.4 -> 3.1 KB gzipped); a seven-mode
25
+ * Handlebars grammar 44.6 -> 40.2 KB (12.1 -> 7.3 KB gzipped);
26
+ * - load: 2.5x quicker (nothing is analysed on import);
27
+ * - lexing: 1.4x on JSON (`offset` positions), 1.2x on Handlebars.
28
+ *
29
+ * The code is laid out around two decisions that came out of measuring:
30
+ *
31
+ * - **Dispatch is a table, not nested switches.** A table maps (mode, first
32
+ * character) to a candidate-list id, and each *distinct* list is written once.
33
+ * A switch per mode repeated the same bodies in every mode that shares most of
34
+ * its tokens (a template language's five expression modes), and made that
35
+ * grammar's lexer 38 KB instead of 14.
36
+ * - **Patterns are read from the module's own token table**, not repeated, so a
37
+ * long lookaround pattern is in the file once.
38
+ *
39
+ * @param {any[]} defs Token definitions, already validated (by `createLexer`).
40
+ * @param {{ positions?: 'full' | 'offset', scanners?: boolean, fuse?: boolean }} [options]
41
+ * @returns {string} A JavaScript expression that evaluates to the lexer object, which
42
+ * needs `tokens` (the definitions) and `LexError` in scope.
43
+ */
44
+ export function generateLexer (defs, options = {}) {
45
+ const positions = options.positions === 'offset' ? 'offset' : 'full'
46
+ const trackLines = positions === 'full'
47
+ const useScanners = options.scanners !== false
48
+ const useFuse = options.fuse !== false
49
+
50
+ // ---- modes: numbered the way createLexer numbers them ----
51
+ const modeIds = new Map([[DEFAULT_MODE, 0]])
52
+ const modeNames = [DEFAULT_MODE]
53
+ const idOf = (name) => {
54
+ let id = modeIds.get(name)
55
+ if (id === undefined) {
56
+ id = modeNames.length
57
+ modeIds.set(name, id)
58
+ modeNames.push(name)
59
+ }
60
+ return id
61
+ }
62
+ for (const def of defs) for (const mode of modesOf(def)) idOf(mode)
63
+
64
+ const compiled = defs.map((def, id) => {
65
+ const flags = def.ignoreCase ? 'iy' : 'y'
66
+ return {
67
+ id,
68
+ name: def.name,
69
+ skip: !!def.skip,
70
+ literal: def.literal,
71
+ pattern: def.pattern,
72
+ flags,
73
+ longerAlt: def.longerAlt || null,
74
+ scanner: useScanners && def.pattern ? buildScanner(def.pattern, !!def.ignoreCase) : null,
75
+ modes: modesOf(def),
76
+ pushMode: def.pushMode ? idOf(def.pushMode) : -1,
77
+ popMode: !!def.popMode,
78
+ firstChars: def.literal !== undefined
79
+ ? (def.literal.length > 0 ? new Set([def.literal.charCodeAt(0)]) : null)
80
+ : firstCharSet(new RegExp(def.pattern, flags).source, !!def.ignoreCase)
81
+ }
82
+ })
83
+ const byName = new Map(compiled.map((c) => [c.name, c]))
84
+ const multiMode = modeNames.length > 1 || compiled.some((c) => c.popMode || c.pushMode !== -1)
85
+
86
+ // ---- what the generated file needs besides the loop itself ----
87
+ const regexes = [] // [token id, flags]: every token matched by a regex, read from `tokens`
88
+ const regexIndex = new Map()
89
+ const rxFor = (c) => {
90
+ if (!regexIndex.has(c.id)) {
91
+ regexIndex.set(c.id, regexes.length)
92
+ regexes.push([c.id, c.flags])
93
+ }
94
+ return /** @type {number} */ (regexIndex.get(c.id))
95
+ }
96
+ const fusedRuns = [] // { flags, ids, rx }
97
+ const helpers = [] // function source, one per scanner that needs a function
98
+ const keywords = [] // [lower codes, upper codes] per KEYWORD token
99
+ const lookFns = new Map() // condition source -> name, for KEYWORD's trailing lookahead
100
+ const need = { digits: false, hex: false, keyword: false }
101
+
102
+ /** Statements that run a token's regex and, on a match, set id/end/matched and leave `found`. */
103
+ function emitRegex (c) {
104
+ const rx = rxFor(c)
105
+ if (c.skip) {
106
+ // A skipped token's text is never read, so `test` avoids building a match.
107
+ return `RX[${rx}].lastIndex = offset; if (RX[${rx}].test(text) && RX[${rx}].lastIndex > offset) { id = ${c.id}; end = RX[${rx}].lastIndex; matched = null; break found }`
108
+ }
109
+ return `RX[${rx}].lastIndex = offset; m = RX[${rx}].exec(text); if (m !== null && m[0].length > 0) { id = ${c.id}; matched = m[0]; end = offset + matched.length; break found }`
110
+ }
111
+
112
+ /**
113
+ * Statements that try one definition at `offset`. A token appears in many candidate lists but its code is
114
+ * the same in each, and writing it records helpers and tables, so it is produced once.
115
+ */
116
+ const emitted = new Map()
117
+ function emitDef (c) {
118
+ let code = emitted.get(c.id)
119
+ if (code === undefined) {
120
+ code = emitDefOnce(c)
121
+ emitted.set(c.id, code)
122
+ }
123
+ return code
124
+ }
125
+
126
+ function emitDefOnce (c) {
127
+ if (c.literal !== undefined) {
128
+ const lit = c.literal
129
+ const test = lit.length === 1 ? `code === ${lit.charCodeAt(0)}` : `text.startsWith(${q(lit)}, offset)`
130
+ return `if (${test}) { id = ${c.id}; end = offset + ${lit.length}; matched = ${q(lit)}; break found }`
131
+ }
132
+ const s = c.scanner
133
+ if (s === null) return emitRegex(c)
134
+ const hit = `{ id = ${c.id}; end = e; matched = null; break found }`
135
+
136
+ switch (s.kind) {
137
+ case SCANNER_KINDS.CLASS: {
138
+ if (s.wide) {
139
+ // `\s`: a non-ASCII character is not ours to judge, so the regex decides there.
140
+ helpers.push(wideClassHelper(c.id, s))
141
+ return `{ const e = scan${c.id}(text, offset, length); if (e === -2) { ${emitRegex(c)} } else if (e !== -1) ${hit} }`
142
+ }
143
+ return `if (${condition(s.first, 'code')}) { let j = offset + 1; while (j < length) { const c = text.charCodeAt(j); if (!(${condition(s.rest, 'c')})) break; j++ } id = ${c.id}; end = j; matched = null; break found }`
144
+ }
145
+ case SCANNER_KINDS.SEQ2:
146
+ return `if (${condition(s.first, 'code')}) { let j = offset + 1; while (j < length) { const c = text.charCodeAt(j); if (${condition(s.first, 'c')}) j++; else break } const mid = j; while (j < length) { const c = text.charCodeAt(j); if (${condition(s.second, 'c')}) j++; else break } if (j > mid) { id = ${c.id}; end = j; matched = null; break found } }`
147
+ case SCANNER_KINDS.KEYWORD: {
148
+ need.keyword = true
149
+ const cond = condition(s.look, 'c')
150
+ let look = lookFns.get(cond)
151
+ if (look === undefined) {
152
+ look = `look${lookFns.size}`
153
+ lookFns.set(cond, look)
154
+ }
155
+ keywords.push([s.lower, s.upper])
156
+ return `{ const e = scanKeyword(text, offset, length, KW[${keywords.length - 1}][0], KW[${keywords.length - 1}][1], ${look}); if (e !== -1) ${hit} }`
157
+ }
158
+ case SCANNER_KINDS.LINE:
159
+ return `if (text.startsWith(${q(s.prefix)}, offset)) { let j = offset + ${s.prefix.length}; while (j < length) { const c = text.charCodeAt(j); if (${condition(s.look, 'c')}) break; j++ } id = ${c.id}; end = j; matched = null; break found }`
160
+ case SCANNER_KINDS.QUOTED:
161
+ helpers.push(quotedHelper(c.id, s))
162
+ return `{ const e = scan${c.id}(text, offset); if (e !== -1) ${hit} }`
163
+ case SCANNER_KINDS.NUMBER:
164
+ need.digits = true
165
+ helpers.push(numberHelper(c.id, s))
166
+ return `{ const e = scan${c.id}(text, offset, length); if (e !== -1) ${hit} }`
167
+ case SCANNER_KINDS.STRING:
168
+ if (s.uni) need.hex = true
169
+ helpers.push(stringHelper(c.id, s))
170
+ return `{ const e = scan${c.id}(text, offset, length); if (e !== -1) ${hit} }`
171
+ default:
172
+ return emitRegex(c)
173
+ }
174
+ }
175
+
176
+ /** Two definitions can share one fused alternation only if their patterns cannot disturb each other's groups. */
177
+ const fusible = (c) =>
178
+ c.literal === undefined && c.scanner === null &&
179
+ !/\\[1-9]|\\k</.test(c.pattern) &&
180
+ new RegExp(c.pattern + '|').exec('').length === 1
181
+
182
+ /**
183
+ * The code for a whole candidate list. Runs of three or more consecutive regexes are fused into one
184
+ * alternation (see `fuse` in lexer.js for why and for the measurements): one regex call rather than one
185
+ * per rejected token, and the engine tries the members left to right exactly as this loop would.
186
+ */
187
+ function emitList (list) {
188
+ const out = []
189
+ for (let i = 0; i < list.length;) {
190
+ let j = i
191
+ while (useFuse && j < list.length && fusible(list[j]) && list[j].flags === list[i].flags) j++
192
+ if (j - i < 3) {
193
+ out.push(emitDef(list[i]))
194
+ i++
195
+ continue
196
+ }
197
+ const run = list.slice(i, j)
198
+ run.forEach(rxFor)
199
+ const k = fusedRuns.length
200
+ fusedRuns.push({ flags: run[0].flags, ids: run.map((d) => d.id), rx: run.map((d) => /** @type {number} */ (regexIndex.get(d.id))) })
201
+ out.push(
202
+ `FX[${k}].lastIndex = offset; m = FX[${k}].exec(text)
203
+ if (m !== null) {
204
+ if (m[0].length > 0) { let g = 1; while (m[g] === undefined) g++; id = FIDS[${k}][g - 1]; matched = m[0]; end = offset + matched.length; break found }
205
+ // An empty match is "no match" to a lexer, so the members after it still get their turn.
206
+ for (let k = 0; k < FRX[${k}].length; k++) { const r = RX[FRX[${k}][k]]; r.lastIndex = offset; const mm = r.exec(text); if (mm !== null && mm[0].length > 0) { id = FIDS[${k}][k]; matched = mm[0]; end = offset + matched.length; break found } }
207
+ }`)
208
+ i = j
209
+ }
210
+ return out.join('\n ')
211
+ }
212
+
213
+ // ---- dispatch: (mode, first character) -> candidate list, each distinct list written once ----
214
+ const lists = [{ list: /** @type {any[]} */ ([]) }] // list 0 is "no candidates"
215
+ const listIds = new Map([['', 0]])
216
+ const listFor = (list) => {
217
+ const key = list.map((c) => c.id).join(',')
218
+ let id = listIds.get(key)
219
+ if (id === undefined) {
220
+ id = lists.length
221
+ listIds.set(key, id)
222
+ lists.push({ list })
223
+ }
224
+ return id
225
+ }
226
+ const WIDTH = 129 // 128 ASCII codes, then "anything else"
227
+ const table = []
228
+ for (const name of modeNames) {
229
+ const members = compiled.filter((c) => c.modes.includes(name))
230
+ if (members.length === 0) throw new Error(`generateLexer: mode '${name}' has no tokens`)
231
+ // Definitions whose first characters could not be determined must be tried everywhere.
232
+ const dispatch = members.some((c) => c.firstChars !== null)
233
+ for (let code = 0; code < 128; code++) {
234
+ table.push(listFor(dispatch ? members.filter((c) => c.firstChars === null || c.firstChars.has(code)) : members))
235
+ }
236
+ // Any non-ASCII character: every token that could begin with one. A superset is safe, because
237
+ // each candidate still has to match.
238
+ table.push(listFor(dispatch ? members.filter((c) => c.firstChars === null || [...c.firstChars].some((x) => x >= 128)) : members))
239
+ }
240
+ if (lists.length > 60000) throw new Error('generateLexer: too many distinct candidate lists')
241
+ const dispatchTable = String.fromCharCode(...table.map((id) => 256 + id))
242
+ const listCases = lists.slice(1).map(({ list }, i) => `case ${i + 1}: {\n ${emitList(list)}\n break\n }`)
243
+
244
+ // ---- longerAlt: after a token matches, a longer match of its alternative wins ----
245
+ const longer = compiled.filter((c) => c.longerAlt)
246
+ const longerCode = longer.length === 0
247
+ ? ''
248
+ : `
249
+ switch (id) {
250
+ ${longer.map((c) => {
251
+ const alt = /** @type {any} */ (byName.get(/** @type {string} */ (c.longerAlt)))
252
+ const altEnd = alt.literal !== undefined
253
+ ? `text.startsWith(${q(alt.literal)}, offset) ? offset + ${alt.literal.length} : -1`
254
+ : `(RX[${rxFor(alt)}].lastIndex = offset, RX[${rxFor(alt)}].test(text) ? RX[${rxFor(alt)}].lastIndex : -1)`
255
+ return ` case ${c.id}: { const altEnd = ${altEnd}; if (altEnd > end) { end = altEnd; id = ${alt.id}; matched = ${alt.literal !== undefined ? q(alt.literal) : 'null'} } break }`
256
+ }).join('\n')}
257
+ }`
258
+
259
+ // ---- mode transitions belong to the token that finally won ----
260
+ const transitions = compiled.filter((c) => c.popMode || c.pushMode !== -1)
261
+ const describe = trackLines ? "'line ' + line + ' column ' + (offset - lineStart + 1)" : "'offset ' + offset"
262
+ const transitionCode = transitions.length === 0
263
+ ? ''
264
+ : `
265
+ switch (id) {
266
+ ${transitions.map((c) => c.popMode
267
+ ? ` case ${c.id}:
268
+ if (depth === 0) throw new LexError('Token \\'' + NAMES[id] + '\\' popped the initial lexer mode at ' + ${describe}, { offset, line: ${trackLines ? 'line' : '0'}, col: ${trackLines ? 'offset - lineStart + 1' : '0'} })
269
+ mode = stack[--depth]
270
+ break`
271
+ : ` case ${c.id}: stack[depth++] = mode; mode = ${c.pushMode}; break`).join('\n')}
272
+ }`
273
+
274
+ const where = trackLines ? "'line ' + line + ' column ' + column" : "'offset ' + offset"
275
+ const inMode = modeNames.length > 1 ? " + ' (in lexer mode \\'' + MODE_NAMES[mode] + '\\')'" : ''
276
+
277
+ // Everything below is assembled last: emitting the lists above is what discovers which helpers are needed.
278
+ return `(() => {
279
+ const NAMES = tokens.map((t) => t.name)
280
+ const SKIP = tokens.map((t) => (t.skip ? 1 : 0))
281
+ const DISPATCH = ${q(dispatchTable)}
282
+ ${modeNames.length > 1 ? `const MODE_NAMES = ${q(modeNames)}` : ''}
283
+ // Patterns are read from the token table above rather than repeated here.
284
+ const RX = ${q(regexes)}.map(([i, flags]) => new RegExp(tokens[i].pattern, flags))
285
+ ${fusedRuns.length ? `const FX = ${q(fusedRuns.map((f) => [f.ids, f.flags]))}.map(([ids, flags]) => new RegExp(ids.map((i) => '(' + tokens[i].pattern + ')').join('|'), flags))
286
+ const FIDS = ${q(fusedRuns.map((f) => f.ids))}
287
+ const FRX = ${q(fusedRuns.map((f) => f.rx))}` : ''}
288
+ ${need.digits ? 'const skipDigits = (text, i, length) => { while (i < length) { const c = text.charCodeAt(i); if (c < 48 || c > 57) break; i++ } return i }' : ''}
289
+ ${need.hex ? 'const isHex = (c) => (c >= 48 && c <= 57) || (c >= 65 && c <= 70) || (c >= 97 && c <= 102)' : ''}
290
+ ${need.keyword ? `const KW = ${q(keywords)}\n ${[...lookFns].map(([cond, name]) => `const ${name} = (c) => ${cond}`).join('\n ')}\n ${KEYWORD_HELPER}` : ''}
291
+ ${helpers.join('\n')}
292
+
293
+ function tokenize (text) {
294
+ if (typeof text !== 'string') throw new TypeError('tokenize: expected a string')
295
+ const tokens_ = []
296
+ let offset = 0
297
+ let line = 1
298
+ let lineStart = 0
299
+ const length = text.length
300
+ ${multiMode ? 'let mode = 0\n const stack = []\n let depth = 0' : ''}
301
+ let m = null
302
+ while (offset < length) {
303
+ const code = text.charCodeAt(offset)
304
+ let id = -1
305
+ let end = -1
306
+ let matched = null
307
+ found: {
308
+ switch (DISPATCH.charCodeAt(${multiMode ? `mode * ${WIDTH} + ` : ''}(code < 128 ? code : 128)) - 256) {
309
+ ${listCases.join('\n ')}
310
+ }
311
+ }
312
+ if (id === -1) {
313
+ const column = offset - lineStart + 1
314
+ throw new LexError('Unexpected character \\'' + text[offset] + '\\' at ' + ${where}${inMode}, { offset, line: ${trackLines ? 'line' : '0'}, col: ${trackLines ? 'column' : '0'} })
315
+ }${longerCode}
316
+ if (SKIP[id] === 0) {
317
+ const image = matched !== null ? matched : text.slice(offset, end)
318
+ tokens_.push(${trackLines
319
+ ? '{ type: NAMES[id], image, start: offset, end, line, col: offset - lineStart + 1 }'
320
+ : '{ type: NAMES[id], id, image, start: offset, end }'})
321
+ }
322
+ ${trackLines ? 'for (let j = offset; j < end; j++) { if (text.charCodeAt(j) !== 10) continue; line++; lineStart = j + 1 }' : ''}${transitionCode}
323
+ offset = end
324
+ }
325
+ return tokens_
326
+ }
327
+ return { tokenize, tokenNames: NAMES, positions: ${q(positions)}, modeNames: ${q(modeNames)} }
328
+ })()`
329
+ }
330
+
331
+ // ---------------------------------------------------------------------------------------------
332
+ // helpers shared by the generator
333
+ // ---------------------------------------------------------------------------------------------
334
+
335
+ const DEFAULT_MODE = 'default'
336
+ const q = JSON.stringify
337
+
338
+ /** The modes one definition belongs to, however it spelled them (as lexer.js reads them). */
339
+ function modesOf (def) {
340
+ if (Array.isArray(def.modes) && def.modes.length > 0) return def.modes
341
+ if (def.mode) return [def.mode]
342
+ return [DEFAULT_MODE]
343
+ }
344
+
345
+ /**
346
+ * A JavaScript condition on the character code in `v` that is true exactly for the members of a
347
+ * 128-entry table, written as ranges: `v === 95 || (v >= 48 && v <= 57)`.
348
+ *
349
+ * @param {Uint8Array} table
350
+ * @param {string} v
351
+ */
352
+ function condition (table, v) {
353
+ const parts = []
354
+ for (let i = 0; i < 128;) {
355
+ if (table[i] !== 1) {
356
+ i++
357
+ continue
358
+ }
359
+ let j = i
360
+ while (j + 1 < 128 && table[j + 1] === 1) j++
361
+ parts.push(i === j ? `${v} === ${i}` : `(${v} >= ${i} && ${v} <= ${j})`)
362
+ i = j + 1
363
+ }
364
+ return parts.length > 0 ? parts.join(' || ') : 'false'
365
+ }
366
+
367
+ // The scanners below are `scan.js`'s cases written out for one token, with its fields as constants.
368
+ // Where they differ in form they must not differ in meaning; test/lexgen.test.js checks that.
369
+
370
+ /** KEYWORD: shared by every keyword token, which differ only in their tables. */
371
+ const KEYWORD_HELPER = `function scanKeyword (text, offset, length, lower, upper, look) {
372
+ const n = lower.length
373
+ if (offset + n > length) return -1
374
+ for (let i = 0; i < n; i++) {
375
+ const c = text.charCodeAt(offset + i)
376
+ if (c !== lower[i] && c !== upper[i]) return -1
377
+ }
378
+ const end = offset + n
379
+ if (end < length && look(text.charCodeAt(end))) return -1
380
+ return end
381
+ }`
382
+
383
+ /** CLASS over `\s`: returns -2 where a non-ASCII character means the regex has to decide. */
384
+ function wideClassHelper (id, s) {
385
+ return `function scan${id} (text, offset, length) {
386
+ const code = text.charCodeAt(offset)
387
+ if (code >= 128) return -2
388
+ if (!(${condition(s.first, 'code')})) return -1
389
+ let j = offset + 1
390
+ while (j < length) {
391
+ const c = text.charCodeAt(j)
392
+ if (c >= 128) return -2
393
+ if (!(${condition(s.rest, 'c')})) break
394
+ j++
395
+ }
396
+ return j
397
+ }`
398
+ }
399
+
400
+ /** QUOTED: `'(?:''|[^'])*'`, where the regex prefers `''` as an escape and backs off to the latest pair. */
401
+ function quotedHelper (id, s) {
402
+ return `function scan${id} (text, offset) {
403
+ if (text.charCodeAt(offset) !== ${s.quote}) return -1
404
+ let fallback = -1
405
+ let j = offset + 1
406
+ for (;;) {
407
+ j = text.indexOf(${q(String.fromCharCode(s.quote))}, j)
408
+ if (j === -1) return fallback
409
+ if (text.charCodeAt(j + 1) !== ${s.quote}) return j + 1
410
+ fallback = j + 1
411
+ j += 2
412
+ }
413
+ }`
414
+ }
415
+
416
+ /** NUMBER: a sign, an integer part, a fraction and an exponent, each only if the pattern has it. */
417
+ function numberHelper (id, s) {
418
+ return `function scan${id} (text, offset, length) {
419
+ let j = offset
420
+ ${s.sign ? 'if (text.charCodeAt(j) === 45) j++' : ''}
421
+ const c = text.charCodeAt(j)
422
+ ${s.jsonInt
423
+ ? 'if (c === 48) j++\n else if (c >= 49 && c <= 57) j = skipDigits(text, j + 1, length)\n else return -1'
424
+ : 'if (!(c >= 48 && c <= 57)) return -1\n j = skipDigits(text, j + 1, length)'}
425
+ ${s.frac ? 'if (text.charCodeAt(j) === 46) { const k = skipDigits(text, j + 1, length); if (k > j + 1) j = k }' : ''}
426
+ ${s.exp ? 'const e = text.charCodeAt(j)\n if (e === 101 || e === 69) { let k = j + 1; const sg = text.charCodeAt(k); if (sg === 43 || sg === 45) k++; const end = skipDigits(text, k, length); if (end > k) j = end }' : ''}
427
+ return j
428
+ }`
429
+ }
430
+
431
+ /** STRING: a quote, then characters and escapes, then the quote. */
432
+ function stringHelper (id, s) {
433
+ return `function scan${id} (text, offset, length) {
434
+ if (text.charCodeAt(offset) !== ${s.quote}) return -1
435
+ let j = offset + 1
436
+ for (;;) {
437
+ if (j >= length) return -1
438
+ const c = text.charCodeAt(j)
439
+ if (c === ${s.quote}) return j + 1
440
+ if (c === 92) {
441
+ const d = text.charCodeAt(j + 1)
442
+ ${s.escAny
443
+ ? 'if (!(d >= 0) || d === 10 || d === 13 || d === 0x2028 || d === 0x2029) return -1\n j += 2'
444
+ : `if (${condition(s.esc, 'd')}) j += 2\n ${s.uni ? 'else if (d === 117 && isHex(text.charCodeAt(j + 2)) && isHex(text.charCodeAt(j + 3)) && isHex(text.charCodeAt(j + 4)) && isHex(text.charCodeAt(j + 5))) j += 6\n ' : ''}else return -1`}
445
+ continue
446
+ }
447
+ ${s.ctrl ? 'if (c < 32) return -1' : ''}
448
+ j++
449
+ }
450
+ }`
451
+ }
@@ -0,0 +1,220 @@
1
+ // @ts-check
2
+ 'use strict'
3
+
4
+ import { opOf, firstOf, buildDispatch, leftmostCalls } from './analyze.js'
5
+ import { usesOuterScope } from './plan.js'
6
+
7
+ /**
8
+ * Chooses the rules worth memoizing, so a grammar does not have to switch on
9
+ * packrat memoization everywhere to escape one exponential spot.
10
+ *
11
+ * The spot is an `alt` whose branches the dispatch table cannot tell apart and
12
+ * that all begin with the same sub-parser:
13
+ *
14
+ * Predicate = alt(Comparison, In, Between, Like, IsNull, Additive)
15
+ *
16
+ * where every branch starts by parsing an `Additive`. Ordered backtracking
17
+ * parses that `Additive` again for each branch it tries, and since an
18
+ * `Additive` can contain a parenthesised `Predicate`, the repetition compounds
19
+ * at every level of nesting: about 6x per level in the SQL example, 255 ms for
20
+ * seven nested parentheses. Left-factoring cannot help when the shared prefix is
21
+ * labelled or sits behind a rule boundary, as here.
22
+ *
23
+ * Remembering the shared rule's result per position makes every branch after the
24
+ * first a lookup. Only that rule needs it — global memoization costs about 2x on
25
+ * grammars that do not need it.
26
+ *
27
+ * Which rule: for each group of colliding branches, the rules each branch can
28
+ * start by invoking (transitively, without consuming a token). A rule shared by
29
+ * two or more branches is a candidate; among candidates only the *topmost* is
30
+ * kept, since memoizing it already covers everything it starts with.
31
+ *
32
+ * And only a candidate whose own work contains such an `alt` again. Re-parsing a
33
+ * shared rule once per branch is a constant factor; it becomes an exponent only
34
+ * when the re-parsed work re-parses *its* shared rule in turn. That happens along
35
+ * a cycle — `Predicate` -> `Additive` -> `(` `Predicate` `)` — or down a chain of
36
+ * such alts (`r0 := r1 ';' | r1`, `r1 := r2 ';' | r2`, ...). A shared rule that is
37
+ * a leaf of that structure costs a constant factor, and a memo table costs more
38
+ * than that: on the formula example, memoizing the two shared name rules made
39
+ * short inputs ~10% slower.
40
+ *
41
+ * Memoization replays a stored result instead of running the rule, so it is
42
+ * valid only for a rule whose result depends on nothing but the position. A rule
43
+ * that can reach an action reading an enclosing scope (`{val: [[-1], ...]}`)
44
+ * depends on its caller, and is left alone.
45
+ *
46
+ * @param {Record<string, any>} grammar
47
+ * @param {Map<string, import('./analyze.js').First>} firsts
48
+ * @param {{ ll1?: boolean }} [options] `ll1: false` means no dispatch table, so
49
+ * every `alt` backtracks over all of its branches.
50
+ * @returns {string[]} Rule names, in declaration order.
51
+ */
52
+ export function selectMemoRules (grammar, firsts, options = {}) {
53
+ const ll1 = options.ll1 !== false
54
+ const names = Object.keys(grammar)
55
+
56
+ /** Rules a rule can invoke first, with no token consumed before. */
57
+ const leftEdges = new Map()
58
+ /** Every rule a rule mentions anywhere. */
59
+ const callEdges = new Map()
60
+ for (const name of names) {
61
+ const left = new Set()
62
+ leftmostCalls(grammar[name], firsts, left)
63
+ leftEdges.set(name, [...left].filter((r) => r in grammar))
64
+
65
+ const calls = new Set()
66
+ walk(grammar[name], (node) => {
67
+ if (opOf(node) !== 'subrule') return
68
+ const arg = node.subrule
69
+ const target = Array.isArray(arg) ? arg[0] : arg
70
+ if (typeof target === 'string' && target in grammar) calls.add(target)
71
+ })
72
+ callEdges.set(name, [...calls])
73
+ }
74
+
75
+ const closure = (edges, from) => {
76
+ const seen = new Set()
77
+ const stack = [...from]
78
+ while (stack.length > 0) {
79
+ const next = stack.pop()
80
+ if (seen.has(next)) continue
81
+ seen.add(next)
82
+ for (const edge of edges.get(next) || []) stack.push(edge)
83
+ }
84
+ return seen
85
+ }
86
+
87
+ const leftCache = new Map()
88
+ const leftReach = (name) => {
89
+ let reach = leftCache.get(name)
90
+ if (reach === undefined) {
91
+ reach = closure(leftEdges, leftEdges.get(name) || [])
92
+ leftCache.set(name, reach)
93
+ }
94
+ return reach
95
+ }
96
+
97
+ /** Rules a branch can start by invoking. */
98
+ const startsWith = (branch) => {
99
+ const direct = new Set()
100
+ leftmostCalls(branch, firsts, direct)
101
+ const all = new Set()
102
+ for (const rule of direct) {
103
+ if (!(rule in grammar)) continue
104
+ all.add(rule)
105
+ for (const deeper of leftReach(rule)) all.add(deeper)
106
+ }
107
+ return all
108
+ }
109
+
110
+ // Every rule hosting an `alt` whose colliding branches share a rule, with the
111
+ // topmost shared rules of each such `alt`.
112
+ const sites = []
113
+ for (const name of names) {
114
+ walk(grammar[name], (node) => {
115
+ if (opOf(node) !== 'alt' || !Array.isArray(node.alt) || node.alt.length < 2) return
116
+
117
+ for (const group of collisionGroups(node.alt, firsts, ll1)) {
118
+ const counts = new Map()
119
+ for (const branch of group) {
120
+ for (const rule of startsWith(branch)) counts.set(rule, (counts.get(rule) || 0) + 1)
121
+ }
122
+ const shared = [...counts].filter(([, n]) => n >= 2).map(([rule]) => rule)
123
+ const topmost = shared.filter((rule) => !shared.some((other) =>
124
+ other !== rule && leftReach(other).has(rule) && !leftReach(rule).has(other)))
125
+ if (topmost.length > 0) sites.push({ host: name, rules: topmost })
126
+ }
127
+ })
128
+ }
129
+
130
+ // Worth a table only if its work can reach a site again — see above.
131
+ const hosts = new Set(sites.map((site) => site.host))
132
+ const chosen = new Set()
133
+ for (const { rules } of sites) {
134
+ for (const rule of rules) {
135
+ const inside = closure(callEdges, [rule])
136
+ inside.add(rule)
137
+ for (const host of hosts) {
138
+ if (inside.has(host)) { chosen.add(rule); break }
139
+ }
140
+ }
141
+ }
142
+
143
+ // A rule is only safe to replay if nothing it can run reads the caller's scope.
144
+ const dependsOnCaller = (name) => {
145
+ const reachable = closure(callEdges, [name])
146
+ reachable.add(name)
147
+ for (const rule of reachable) if (usesOuterScope(grammar[rule])) return true
148
+ return false
149
+ }
150
+
151
+ return names.filter((name) => chosen.has(name) && !dependsOnCaller(name))
152
+ }
153
+
154
+ /**
155
+ * The groups of an `alt`'s branches that dispatch cannot separate and that are
156
+ * therefore tried in order: each array in the dispatch table, or — with no table
157
+ * (a nullable or unanalysable branch, or `ll1` off) — every branch together.
158
+ */
159
+ function collisionGroups (branches, firsts, ll1) {
160
+ const table = ll1 ? buildDispatch(branches, firsts) : null
161
+ if (table === null) return [branches]
162
+ const groups = new Set()
163
+ for (const entry of table.values()) if (Array.isArray(entry) && entry.length > 1) groups.add(entry)
164
+ return [...groups]
165
+ }
166
+
167
+ /** Calls `visit` on every parser node under `node`, itself included. */
168
+ function walk (node, visit) {
169
+ if (Array.isArray(node)) {
170
+ for (const child of node) walk(child, visit)
171
+ return
172
+ }
173
+ const op = opOf(node)
174
+ if (op === null) return
175
+ visit(node)
176
+ const arg = node[op]
177
+
178
+ switch (op) {
179
+ case 'seq':
180
+ case 'alt':
181
+ if (Array.isArray(arg)) for (const child of arg) walk(child, visit)
182
+ break
183
+ case 'label':
184
+ if (Array.isArray(arg)) walk(arg[1], visit)
185
+ break
186
+ case 'many':
187
+ case 'many1':
188
+ case 'option':
189
+ case 'flag':
190
+ case 'text':
191
+ case 'lookahead':
192
+ case 'negLookahead':
193
+ walk(arg, visit)
194
+ break
195
+ case 'as':
196
+ walk(Array.isArray(arg) ? arg[0] : arg && arg.parser, visit)
197
+ break
198
+ case 'between':
199
+ if (arg) for (const part of [arg.open, arg.body, arg.close]) walk(part, visit)
200
+ break
201
+ case 'pick':
202
+ case 'yields':
203
+ if (arg && Array.isArray(arg.parsers)) for (const part of arg.parsers) walk(part, visit)
204
+ break
205
+ case 'prefix':
206
+ if (arg) { walk(arg.operator, visit); walk(arg.operand, visit) }
207
+ break
208
+ case 'postfix':
209
+ if (arg) { walk(arg.operand, visit); walk(arg.suffix, visit) }
210
+ break
211
+ case 'infixLeft':
212
+ case 'infixRight':
213
+ if (arg) { walk(arg.operand, visit); if (arg.operator !== undefined) walk(arg.operator, visit) }
214
+ break
215
+ case 'manySep':
216
+ case 'many1Sep':
217
+ if (arg) { walk(arg.rule, visit); if (typeof arg.sep !== 'string') walk(arg.sep, visit) }
218
+ break
219
+ }
220
+ }
package/src/methods.js CHANGED
@@ -25,7 +25,7 @@ import { declareSync } from './plan.js'
25
25
  export const OPERATORS = [
26
26
  'consume', 'epsilon', 'seq', 'alt', 'oneOf', 'eof',
27
27
  'many', 'many1', 'option', 'manySep', 'many1Sep',
28
- 'infixLeft', 'infixRight', 'prefix', 'postfix', 'between', 'as',
28
+ 'infixLeft', 'infixRight', 'prefix', 'postfix', 'between', 'pick', 'yields', 'flag', 'as',
29
29
  'subrule', 'label', 'action', 'lookahead', 'negLookahead', 'text'
30
30
  ]
31
31