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/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
|
+
}
|
package/src/memoplan.js
ADDED
|
@@ -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
|
|