grammatik 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/scan.js ADDED
@@ -0,0 +1,422 @@
1
+ // @ts-check
2
+ 'use strict'
3
+
4
+ /**
5
+ * Hand-rolled matchers for the handful of regex shapes that dominate real
6
+ * token definitions.
7
+ *
8
+ * A sticky regex call has a fixed cost — reset `lastIndex`, enter the engine,
9
+ * come back — that is larger than the matching itself for the short tokens
10
+ * lexers mostly see (identifiers, whitespace, keywords). Recognising a pattern's
11
+ * *shape* once, when the lexer is built, and running a table-driven loop for it
12
+ * afterwards is measurably faster: ~1.3x on a SQL lexer, ~1.1x on a spreadsheet
13
+ * formula lexer, and ~1.2x on a JSON lexer once its string and number patterns
14
+ * are covered (with `positions: 'offset'`; the default `full` mode spends much of
15
+ * the rest on newline tracking).
16
+ *
17
+ * Recognised shapes, all ASCII-only and case-sensitive unless noted:
18
+ *
19
+ * CLASS `[a-z]+` `[a-z]*` `[A-Za-z_][A-Za-z0-9_]*` `\s+` `\d+`
20
+ * SEQ2 `[A-Za-z]+[0-9]+` (the two classes must be disjoint)
21
+ * KEYWORD `SELECT(?![A-Za-z0-9_])` (also with `ignoreCase`)
22
+ * LINE `--[^\n]*`
23
+ * QUOTED `'(?:''|[^'])*'` (also with `"`)
24
+ * NUMBER `-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?` (JSON), `\d+(?:\.\d+)?`,
25
+ * and the combinations of: a `-?` sign, a JSON or plain integer part,
26
+ * an optional `.digits` fraction, an optional exponent
27
+ * STRING `"(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))*"` (JSON),
28
+ * and `"(?:[^"\\]|\\.)*"` — either quote character
29
+ *
30
+ * Anything else yields `null` from `buildScanner` and stays a regex. That
31
+ * includes every negated class other than the LINE shape, `ignoreCase` on
32
+ * anything but KEYWORD, the `u`/`v` flags, and any class member outside ASCII:
33
+ * declining is always safe, a wrong scanner is a silent lexing bug.
34
+ *
35
+ * Contract: `scanEnd` returns exactly what the regex would, as an end offset or
36
+ * -1, except that it may return `DEFER` where it cannot decide (see below), in
37
+ * which case the caller runs the regex. `test/scan.test.js` fuzzes every
38
+ * scanner against its regex.
39
+ *
40
+ * Every scanner has the same object shape (see `make`). That is deliberate:
41
+ * `scanEnd` is called from the lexer's hottest loop, and with one shape per
42
+ * kind its property loads went megamorphic and the whole gain (1.34x) fell to
43
+ * 1.13x. Add a field to `make`, never to one kind's literal.
44
+ */
45
+
46
+ /** Returned by `scanEnd` when the regex must decide (non-ASCII text under `\s`). */
47
+ export const DEFER = -2
48
+
49
+ const CLASS = 0
50
+ const SEQ2 = 1
51
+ const KEYWORD = 2
52
+ const LINE = 3
53
+ const QUOTED = 4
54
+ const NUMBER = 5
55
+ const STRING = 6
56
+
57
+ /** The kinds of scanner `buildScanner` can return, for code that writes them out as source. */
58
+ export const SCANNER_KINDS = Object.freeze({ CLASS, SEQ2, KEYWORD, LINE, QUOTED, NUMBER, STRING })
59
+
60
+ const NONE = new Uint8Array(128)
61
+
62
+ /**
63
+ * @typedef {object} Scanner
64
+ * @property {number} kind
65
+ * @property {boolean} wide Class contains `\s`: non-ASCII input is not ours to judge.
66
+ * @property {Uint8Array} first CLASS/SEQ2: may start the match. LINE: unused.
67
+ * @property {Uint8Array} rest CLASS/SEQ2: may continue the first run.
68
+ * @property {Uint8Array} second SEQ2: the second run's class.
69
+ * @property {Uint8Array} look KEYWORD: characters that must NOT follow. LINE: stop characters.
70
+ * @property {number[]} lower KEYWORD: each character's lower-case code.
71
+ * @property {number[]} upper KEYWORD: each character's upper-case code.
72
+ * @property {string} prefix LINE: the literal prefix.
73
+ * @property {number} quote QUOTED, STRING: the quote's char code.
74
+ * @property {boolean} sign NUMBER: an optional leading `-`.
75
+ * @property {boolean} jsonInt NUMBER: the integer part is `0|[1-9]\d*` rather than `\d+`.
76
+ * @property {boolean} frac NUMBER: an optional `.digits`.
77
+ * @property {boolean} exp NUMBER: an optional `e[+-]digits`.
78
+ * @property {boolean} ctrl STRING: characters below U+0020 are not allowed inside.
79
+ * @property {boolean} uni STRING: `\uXXXX` is an escape.
80
+ * @property {boolean} escAny STRING: a backslash may precede any character but a line terminator.
81
+ * @property {Uint8Array} esc STRING: the characters that may follow a backslash.
82
+ */
83
+
84
+ /** @returns {Scanner} */
85
+ function make (kind, fields) {
86
+ return {
87
+ kind,
88
+ wide: false,
89
+ first: NONE,
90
+ rest: NONE,
91
+ second: NONE,
92
+ look: NONE,
93
+ lower: [],
94
+ upper: [],
95
+ prefix: '',
96
+ quote: -1,
97
+ sign: false,
98
+ jsonInt: false,
99
+ frac: false,
100
+ exp: false,
101
+ ctrl: false,
102
+ uni: false,
103
+ escAny: false,
104
+ esc: NONE,
105
+ ...fields
106
+ }
107
+ }
108
+
109
+ const SHORTHAND = {
110
+ d: (t) => { for (let k = 48; k < 58; k++) t[k] = 1 },
111
+ w: (t) => { for (let k = 0; k < 128; k++) if (/\w/.test(String.fromCharCode(k))) t[k] = 1 },
112
+ s: (t) => { for (const k of [9, 10, 11, 12, 13, 32]) t[k] = 1 }
113
+ }
114
+ const SIMPLE_ESCAPES = { t: '\t', r: '\r', n: '\n', f: '\f', v: '\v' }
115
+ const ALNUM = /[a-zA-Z0-9]/
116
+
117
+ /**
118
+ * The membership table of a bracket class body (the text between `[` and `]`),
119
+ * or null when it is anything but plain ASCII members, ranges and simple
120
+ * escapes. `\s` is refused inside brackets — only the bare `\s` shape is
121
+ * handled, because a class mixing it with other members would need its
122
+ * non-ASCII-whitespace behaviour reproduced here.
123
+ *
124
+ * @param {string} body
125
+ * @returns {Uint8Array | null}
126
+ */
127
+ function parseClass (body) {
128
+ if (body.length === 0 || body[0] === '^') return null
129
+ const table = new Uint8Array(128)
130
+ for (let i = 0; i < body.length; i++) {
131
+ let c = body[i]
132
+ if (c === '\\') {
133
+ const n = body[++i]
134
+ if (n === 'd' || n === 'w') { SHORTHAND[n](table); continue }
135
+ if (n === undefined) return null
136
+ if (ALNUM.test(n) && !(n in SIMPLE_ESCAPES)) return null // \s \b \0 \x41 A \cJ ...
137
+ c = SIMPLE_ESCAPES[n] ?? n
138
+ }
139
+ if (body[i + 1] === '-' && i + 2 < body.length) {
140
+ let hi = body[i + 2]
141
+ let advance = 2
142
+ if (hi === '\\') {
143
+ hi = body[i + 3]
144
+ advance = 3
145
+ if (hi === undefined || ALNUM.test(hi)) return null
146
+ }
147
+ const from = c.charCodeAt(0)
148
+ const to = hi.charCodeAt(0)
149
+ if (from > to || to >= 128) return null
150
+ for (let k = from; k <= to; k++) table[k] = 1
151
+ i += advance
152
+ continue
153
+ }
154
+ const code = c.charCodeAt(0)
155
+ if (code >= 128) return null
156
+ table[code] = 1
157
+ }
158
+ return table
159
+ }
160
+
161
+ const BODY = String.raw`((?:[^\]\\]|\\.)*)`
162
+ const RE_CLASS_RUN = new RegExp(String.raw`^\[${BODY}\]([+*])$`)
163
+ const RE_CLASS_PAIR = new RegExp(String.raw`^\[${BODY}\]\[${BODY}\]\*$`)
164
+ const RE_CLASS_SEQ = new RegExp(String.raw`^\[${BODY}\]\+\[${BODY}\]\+$`)
165
+ const RE_SHORTHAND = /^\\([sdw])[+*]$/
166
+ const RE_KEYWORD = new RegExp(String.raw`^([A-Za-z0-9_]+)\(\?!\[${BODY}\]\)$`)
167
+ const RE_LINE = new RegExp(String.raw`^([^\\\[\]()*+?.|^$]+)\[\^${BODY}\]\*$`)
168
+ const RE_QUOTED = /^(['"])\(\?:\1\1\|\[\^\1\]\)\*\1$/
169
+ // -? (?:0|[1-9]\d*) or \d+ (?:\.\d+)? (?:[eE][+-]?\d+)? — `[0-9]` is read as `\d`
170
+ const RE_NUMBER = /^(-\?)?(?:(\(\?:0\|\[1-9\]\\d\*\))|\\d\+)(\(\?:\\\.\\d\+\)\?)?(\(\?:\[eE\]\[\+-\]\?\\d\+\)\?)?$/
171
+ // "(?:[^"\<ctrl>?]|\(?:[<escapes>](|u[0-9a-fA-F]{4})?))*"
172
+ const RE_STRING_ESC = new RegExp(String.raw`^(["'])\(\?:\[\^\1\\\\(\\u0000-\\u001f|\\x00-\\x1f)?\]\|\\\\\(\?:\[${BODY}\](\|u\[0-9a-fA-F\]\{4\})?\)\)\*\1$`)
173
+ // "(?:[^"\]|\.)*"
174
+ const RE_STRING_ANY = new RegExp(String.raw`^(["'])\(\?:\[\^\1\\\\\]\|\\\\\.\)\*\1$`)
175
+
176
+ /**
177
+ * @param {string} source The token's `pattern`.
178
+ * @param {boolean} ignoreCase
179
+ * @returns {Scanner | null}
180
+ */
181
+ export function buildScanner (source, ignoreCase) {
182
+ let m = RE_KEYWORD.exec(source)
183
+ if (m) {
184
+ const look = parseClass(m[2])
185
+ if (look === null) return null
186
+ const word = m[1]
187
+ const lower = []
188
+ const upper = []
189
+ for (const ch of word) {
190
+ lower.push((ignoreCase ? ch.toLowerCase() : ch).charCodeAt(0))
191
+ upper.push((ignoreCase ? ch.toUpperCase() : ch).charCodeAt(0))
192
+ }
193
+ return make(KEYWORD, { look, lower, upper })
194
+ }
195
+
196
+ // Everything below is case-sensitive only: a case-folded class would need its
197
+ // folding reproduced, and no pattern in the wild has asked for it yet.
198
+ if (ignoreCase) return null
199
+
200
+ m = RE_STRING_ESC.exec(source)
201
+ if (m) {
202
+ const esc = parseClass(m[3])
203
+ if (esc === null) return null
204
+ return make(STRING, { quote: m[1].charCodeAt(0), ctrl: m[2] !== undefined, uni: m[4] !== undefined, esc })
205
+ }
206
+ m = RE_STRING_ANY.exec(source)
207
+ if (m) return make(STRING, { quote: m[1].charCodeAt(0), escAny: true })
208
+
209
+ // A number needs at least one feature beyond a bare digit run, which CLASS already does.
210
+ m = RE_NUMBER.exec(source.replace(/\[0-9\]/g, '\\d'))
211
+ if (m && (m[1] || m[2] || m[3] || m[4])) {
212
+ return make(NUMBER, { sign: m[1] !== undefined, jsonInt: m[2] !== undefined, frac: m[3] !== undefined, exp: m[4] !== undefined })
213
+ }
214
+
215
+ m = RE_CLASS_RUN.exec(source)
216
+ if (m) {
217
+ const table = parseClass(m[1])
218
+ return table && make(CLASS, { first: table, rest: table })
219
+ }
220
+
221
+ m = RE_CLASS_PAIR.exec(source)
222
+ if (m) {
223
+ const first = parseClass(m[1])
224
+ const rest = parseClass(m[2])
225
+ return first && rest && make(CLASS, { first, rest })
226
+ }
227
+
228
+ m = RE_CLASS_SEQ.exec(source)
229
+ if (m) {
230
+ const first = parseClass(m[1])
231
+ const second = parseClass(m[2])
232
+ // Disjoint classes mean the first run never has to give anything back to
233
+ // the second, so greedy scanning agrees with the regex's backtracking.
234
+ if (first && second && first.every((v, i) => !(v && second[i]))) {
235
+ return make(SEQ2, { first, rest: first, second })
236
+ }
237
+ return null
238
+ }
239
+
240
+ m = RE_SHORTHAND.exec(source)
241
+ if (m) {
242
+ const table = new Uint8Array(128)
243
+ SHORTHAND[m[1]](table)
244
+ return make(CLASS, { first: table, rest: table, wide: m[1] === 's' })
245
+ }
246
+
247
+ m = RE_QUOTED.exec(source)
248
+ if (m) return make(QUOTED, { quote: m[1].charCodeAt(0) })
249
+
250
+ m = RE_LINE.exec(source)
251
+ if (m) {
252
+ const stop = parseClass(m[2])
253
+ return stop && make(LINE, { prefix: m[1], look: stop })
254
+ }
255
+
256
+ return null
257
+ }
258
+
259
+ const skipDigits = (text, i, length) => {
260
+ while (i < length) {
261
+ const c = text.charCodeAt(i)
262
+ if (c < 48 || c > 57) break
263
+ i++
264
+ }
265
+ return i
266
+ }
267
+
268
+ const isHex = (c) => (c >= 48 && c <= 57) || (c >= 65 && c <= 70) || (c >= 97 && c <= 102)
269
+
270
+ /**
271
+ * Where the pattern's sticky match at `offset` ends, -1 for none, or `DEFER`.
272
+ * A zero-length match is reported as -1, as the lexer treats it that way.
273
+ *
274
+ * @param {Scanner} sc
275
+ * @param {string} text
276
+ * @param {number} offset Always below `length`.
277
+ * @param {number} length
278
+ * @returns {number}
279
+ */
280
+ export function scanEnd (sc, text, offset, length) {
281
+ switch (sc.kind) {
282
+ case CLASS: {
283
+ const code = text.charCodeAt(offset)
284
+ if (code >= 128) return sc.wide ? DEFER : -1
285
+ if (sc.first[code] !== 1) return -1
286
+ const rest = sc.rest
287
+ let j = offset + 1
288
+ while (j < length) {
289
+ const c = text.charCodeAt(j)
290
+ if (c >= 128) return sc.wide ? DEFER : j
291
+ if (rest[c] !== 1) break
292
+ j++
293
+ }
294
+ return j
295
+ }
296
+
297
+ case SEQ2: {
298
+ const c0 = text.charCodeAt(offset)
299
+ if (c0 >= 128 || sc.first[c0] !== 1) return -1
300
+ const { first, second } = sc
301
+ let j = offset + 1
302
+ while (j < length) {
303
+ const c = text.charCodeAt(j)
304
+ if (c < 128 && first[c] === 1) j++
305
+ else break
306
+ }
307
+ const mid = j
308
+ while (j < length) {
309
+ const c = text.charCodeAt(j)
310
+ if (c < 128 && second[c] === 1) j++
311
+ else break
312
+ }
313
+ return j > mid ? j : -1
314
+ }
315
+
316
+ case KEYWORD: {
317
+ const { lower, upper } = sc
318
+ const n = lower.length
319
+ if (offset + n > length) return -1
320
+ for (let i = 0; i < n; i++) {
321
+ const c = text.charCodeAt(offset + i)
322
+ if (c !== lower[i] && c !== upper[i]) return -1
323
+ }
324
+ const end = offset + n
325
+ if (end < length) {
326
+ const c = text.charCodeAt(end)
327
+ if (c < 128 && sc.look[c] === 1) return -1
328
+ }
329
+ return end
330
+ }
331
+
332
+ case LINE: {
333
+ if (!text.startsWith(sc.prefix, offset)) return -1
334
+ let j = offset + sc.prefix.length
335
+ const stop = sc.look
336
+ while (j < length) {
337
+ const c = text.charCodeAt(j)
338
+ if (c < 128 && stop[c] === 1) break
339
+ j++
340
+ }
341
+ return j
342
+ }
343
+
344
+ case NUMBER: {
345
+ let j = offset
346
+ if (sc.sign && text.charCodeAt(j) === 45) j++
347
+ const c = text.charCodeAt(j)
348
+ if (sc.jsonInt) {
349
+ // `0` stands alone: "01" is the number 0 followed by 1.
350
+ if (c === 48) j++
351
+ else if (c >= 49 && c <= 57) j = skipDigits(text, j + 1, length)
352
+ else return -1
353
+ } else {
354
+ if (!(c >= 48 && c <= 57)) return -1
355
+ j = skipDigits(text, j + 1, length)
356
+ }
357
+ // A fraction or exponent counts only if it is complete: "1." is 1 then ".".
358
+ if (sc.frac && text.charCodeAt(j) === 46) {
359
+ const k = skipDigits(text, j + 1, length)
360
+ if (k > j + 1) j = k
361
+ }
362
+ if (sc.exp) {
363
+ const e = text.charCodeAt(j)
364
+ if (e === 101 || e === 69) {
365
+ let k = j + 1
366
+ const s = text.charCodeAt(k)
367
+ if (s === 43 || s === 45) k++
368
+ const end = skipDigits(text, k, length)
369
+ if (end > k) j = end
370
+ }
371
+ }
372
+ return j
373
+ }
374
+
375
+ case STRING: {
376
+ const q = sc.quote
377
+ if (text.charCodeAt(offset) !== q) return -1
378
+ let j = offset + 1
379
+ for (;;) {
380
+ if (j >= length) return -1
381
+ const c = text.charCodeAt(j)
382
+ if (c === q) return j + 1
383
+ if (c === 92) {
384
+ const d = text.charCodeAt(j + 1)
385
+ if (sc.escAny) {
386
+ // `\\.`: any character but a line terminator (or the end of the text).
387
+ if (!(d >= 0) || d === 10 || d === 13 || d === 0x2028 || d === 0x2029) return -1
388
+ j += 2
389
+ } else if (d < 128 && sc.esc[d] === 1) {
390
+ j += 2
391
+ } else if (sc.uni && d === 117 && isHex(text.charCodeAt(j + 2)) && isHex(text.charCodeAt(j + 3)) &&
392
+ isHex(text.charCodeAt(j + 4)) && isHex(text.charCodeAt(j + 5))) {
393
+ j += 6
394
+ } else {
395
+ return -1
396
+ }
397
+ continue
398
+ }
399
+ if (sc.ctrl && c < 32) return -1
400
+ j++
401
+ }
402
+ }
403
+
404
+ default: { // QUOTED
405
+ const q = sc.quote
406
+ if (text.charCodeAt(offset) !== q) return -1
407
+ const quote = String.fromCharCode(q)
408
+ // The regex prefers to read `''` as an escaped quote, and only when that
409
+ // leaves no closing quote does it backtrack to the *latest* pair and
410
+ // close on its first quote instead.
411
+ let fallback = -1
412
+ let j = offset + 1
413
+ for (;;) {
414
+ j = text.indexOf(quote, j)
415
+ if (j === -1) return fallback
416
+ if (text.charCodeAt(j + 1) !== q) return j + 1
417
+ fallback = j + 1
418
+ j += 2
419
+ }
420
+ }
421
+ }
422
+ }
package/src/state.js CHANGED
@@ -27,14 +27,25 @@ export const FAIL = Symbol('grammatik.fail')
27
27
  * @property {boolean} track Whether to record error-reporting detail.
28
28
  * @property {number} steps Combinator invocations, for the runaway guard.
29
29
  * @property {number} maxSteps 0 disables the guard.
30
+ * @property {{ ends: Int32Array, vals: any[] }[]|null} rm Per-rule memo tables for the
31
+ * rules chosen by `selectMemoRules`, indexed by slot. `ends[i]` is the cursor
32
+ * after the rule ran at token `i`, plus one (0 means "not seen yet"); `vals[i]`
33
+ * is what it returned.
30
34
  */
31
35
 
32
36
  /**
33
37
  * @param {import('./lexer.js').Token[]} tokens
34
38
  * @param {Record<string, any>} grammar
35
- * @param {{ memo?: boolean, maxSteps?: number, track?: boolean }} [options]
39
+ * @param {{ memo?: boolean, maxSteps?: number, track?: boolean, memoSlots?: number }} [options]
36
40
  * @returns {ParserState}
37
41
  */
42
+ const memoTables = (slots, length) => {
43
+ if (!slots) return null
44
+ const tables = new Array(slots)
45
+ for (let i = 0; i < slots; i++) tables[i] = { ends: new Int32Array(length + 1), vals: new Array(length + 1) }
46
+ return tables
47
+ }
48
+
38
49
  export const createState = (tokens, grammar, options = {}) => ({
39
50
  tokens,
40
51
  idx: 0,
@@ -42,6 +53,7 @@ export const createState = (tokens, grammar, options = {}) => ({
42
53
  expected: [],
43
54
  grammar,
44
55
  memo: options.memo ? new Map() : null,
56
+ rm: memoTables(options.memoSlots, tokens.length),
45
57
  // Entering a rule is one array store rather than an object allocation, and
46
58
  // the stack is snapshotted only on a furthest failure — by the time an error
47
59
  // is built it would otherwise have unwound to just the start rule.
@@ -79,6 +91,26 @@ export function expect (state, expectation) {
79
91
  if (!state.expected.includes(expectation)) state.expected.push(expectation)
80
92
  }
81
93
 
94
+ /**
95
+ * Thrown when no token matches. It lives here, beside `ParseError`, rather than in
96
+ * the lexer: a parser whose lexer was generated as source needs this class and
97
+ * nothing else from the lexer module, and importing it from there would pull the
98
+ * whole lexer engine back into the bundle.
99
+ */
100
+ export class LexError extends Error {
101
+ /**
102
+ * @param {string} message
103
+ * @param {{ offset: number, line: number, col: number }} position
104
+ */
105
+ constructor (message, position) {
106
+ super(message)
107
+ this.name = 'LexError'
108
+ this.offset = position.offset
109
+ this.line = position.line
110
+ this.col = position.col
111
+ }
112
+ }
113
+
82
114
  export class ParseError extends Error {
83
115
  /**
84
116
  * @param {string} message