grammatik 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/bin/grammatik.js +8 -0
- package/dist/cjs/index.js +1471 -104
- package/dist/cjs/runtime.js +635 -25
- package/dist/esm/index.js +1471 -104
- package/dist/esm/runtime.js +635 -25
- package/dist/types/index.d.ts +36 -0
- package/dist/types/runtime.d.ts +4 -3
- package/package.json +1 -1
- package/src/analyze.js +114 -4
- package/src/codegen.js +147 -11
- package/src/definition.js +5 -0
- package/src/emit.js +29 -7
- package/src/lexer.d.ts +43 -2
- package/src/lexer.js +535 -28
- package/src/lexgen.js +451 -0
- package/src/memoplan.js +220 -0
- package/src/methods.js +1 -1
- package/src/parser.js +31 -3
- package/src/plan.js +88 -26
- package/src/runtime.js +8 -6
- package/src/scan.js +422 -0
- package/src/state.js +33 -1
package/dist/types/index.d.ts
CHANGED
|
@@ -70,6 +70,29 @@ export interface ParserOptions {
|
|
|
70
70
|
positions?: PositionMode
|
|
71
71
|
/** Packrat memoization on (rule, position). */
|
|
72
72
|
memo?: boolean
|
|
73
|
+
/**
|
|
74
|
+
* Memoize only the rules that several colliding branches of an `alt` would
|
|
75
|
+
* otherwise each re-parse (default `true`; ignored when `memo` is on). Avoids
|
|
76
|
+
* the exponential blow-up of nested alternatives that share a leading rule,
|
|
77
|
+
* at no cost to a grammar with none. See `memoRules`.
|
|
78
|
+
*/
|
|
79
|
+
autoMemo?: boolean
|
|
80
|
+
/**
|
|
81
|
+
* In generated code, write single-caller operator levels (`infixLeft`,
|
|
82
|
+
* `infixRight`, `prefix`, `postfix`) into their caller instead of calling them
|
|
83
|
+
* (default `true` for `createParser`, `false` for `emitModule`). Several levels of an
|
|
84
|
+
* expression grammar become one function: 2-3x faster on a bare precedence chain, about
|
|
85
|
+
* 1.02-1.04x on real grammars, at the cost of a bigger file, which is why an emitted
|
|
86
|
+
* parser leaves it off.
|
|
87
|
+
*/
|
|
88
|
+
inlineRules?: boolean
|
|
89
|
+
/**
|
|
90
|
+
* Recognize common spellings of a grammar (`seq(open, label, close, action)`, a
|
|
91
|
+
* prefix plus one labelled result, a constant action, an `alt` of bare `consume`s,
|
|
92
|
+
* a shared leading run of `alt` branches) and run them as the cheaper combinator
|
|
93
|
+
* they are equivalent to. Default `true`; `false` runs the grammar exactly as written.
|
|
94
|
+
*/
|
|
95
|
+
idioms?: boolean
|
|
73
96
|
/** Require the whole token stream to be consumed. */
|
|
74
97
|
strict?: boolean
|
|
75
98
|
/** FIRST-set dispatch for `alt`. Off forces ordered backtracking. */
|
|
@@ -109,6 +132,8 @@ export interface JLParser {
|
|
|
109
132
|
engine: any
|
|
110
133
|
analysis: Analysis
|
|
111
134
|
execution: ExecutionMode
|
|
135
|
+
/** The rules `autoMemo` chose to memoize, in declaration order. */
|
|
136
|
+
memoRules: string[]
|
|
112
137
|
compiled: boolean
|
|
113
138
|
readonly firsts: Record<string, Set<string>>
|
|
114
139
|
}
|
|
@@ -126,6 +151,17 @@ export declare function analyze (
|
|
|
126
151
|
): Analysis
|
|
127
152
|
|
|
128
153
|
export interface EmitOptions extends ParserOptions {
|
|
154
|
+
/**
|
|
155
|
+
* How the file lexes. `'generated'` (default) writes the lexer out as source, so the
|
|
156
|
+
* file imports only the parse state and error class from the runtime: a smaller
|
|
157
|
+
* bundle (JSON: 24 KB to 10 KB minified), a faster start, and faster lexing.
|
|
158
|
+
* `'runtime'` keeps the token table and builds the lexer from it when the file loads.
|
|
159
|
+
*/
|
|
160
|
+
lexer?: 'generated' | 'runtime'
|
|
161
|
+
/** Scanner substitution for the lexer, as for `createLexer`. Default `true`. */
|
|
162
|
+
scanners?: boolean
|
|
163
|
+
/** Fusing runs of regex tokens in the lexer, as for `createLexer`. Default `true`. */
|
|
164
|
+
fuse?: boolean
|
|
129
165
|
/** Module format of the generated file. */
|
|
130
166
|
format?: 'esm' | 'cjs'
|
|
131
167
|
/** Name recorded in the file header. Defaults to `spec.name`. */
|
package/dist/types/runtime.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `grammatik/runtime` — the surface a generated parser imports.
|
|
3
3
|
*
|
|
4
|
-
* Deliberately small: a generated file needs the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Deliberately small: a generated file needs the parse state and error
|
|
5
|
+
* construction, and nothing else. The analyzer, planner, code generator and
|
|
6
|
+
* (by default) the lexer were already run when the file was written. `createLexer`
|
|
7
|
+
* is still here for files emitted with `lexer: 'runtime'`.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import type { Token, TokenDef, PositionMode, Lexer, Parser } from './index.js'
|
package/package.json
CHANGED
package/src/analyze.js
CHANGED
|
@@ -60,9 +60,9 @@ export function opOf (node) {
|
|
|
60
60
|
|
|
61
61
|
/** The set of node shapes this module understands as *parsers*. */
|
|
62
62
|
export const PARSER_OPS = new Set([
|
|
63
|
-
'consume', 'seq', 'alt', 'many', 'many1', 'option',
|
|
63
|
+
'consume', 'seq', 'alt', 'many', 'many1', 'option', 'flag',
|
|
64
64
|
'manySep', 'many1Sep', 'infixLeft', 'infixRight', 'postfix', 'prefix',
|
|
65
|
-
'between', 'as', 'oneOf', 'eof',
|
|
65
|
+
'between', 'pick', 'yields', 'as', 'oneOf', 'eof',
|
|
66
66
|
'subrule', 'label', 'action',
|
|
67
67
|
'lookahead', 'negLookahead', 'text', 'epsilon'
|
|
68
68
|
])
|
|
@@ -122,7 +122,8 @@ function firstOf (node, ruleFirsts) {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
case 'many':
|
|
125
|
-
case 'option':
|
|
125
|
+
case 'option':
|
|
126
|
+
case 'flag': {
|
|
126
127
|
const inner = firstOf(arg, ruleFirsts)
|
|
127
128
|
return { tokens: inner.tokens, nullable: true, unknown: inner.unknown }
|
|
128
129
|
}
|
|
@@ -153,6 +154,11 @@ function firstOf (node, ruleFirsts) {
|
|
|
153
154
|
// Exactly a sequence of open, body, close.
|
|
154
155
|
return firstOf({ seq: [arg && arg.open, arg && arg.body, arg && arg.close] }, ruleFirsts)
|
|
155
156
|
|
|
157
|
+
case 'pick':
|
|
158
|
+
case 'yields':
|
|
159
|
+
// Exactly a sequence of its parsers.
|
|
160
|
+
return firstOf({ seq: arg && Array.isArray(arg.parsers) ? arg.parsers : [] }, ruleFirsts)
|
|
161
|
+
|
|
156
162
|
case 'prefix': {
|
|
157
163
|
// Zero operators are allowed, so the operand's first set counts too.
|
|
158
164
|
const ops = firstOf(arg && arg.operator, ruleFirsts)
|
|
@@ -254,9 +260,61 @@ export function recognizeSeqIdiom (children) {
|
|
|
254
260
|
}
|
|
255
261
|
}
|
|
256
262
|
|
|
263
|
+
// The two idioms below drop the `seq`'s bindings object, so they decline when a
|
|
264
|
+
// child could reach into an enclosing scope: without that object an outward hop
|
|
265
|
+
// would land one scope further out than it did.
|
|
266
|
+
if (children.length >= 2) {
|
|
267
|
+
const last = children[children.length - 1]
|
|
268
|
+
const head = children.slice(0, -1)
|
|
269
|
+
|
|
270
|
+
// yields: parsers, then an action that is a constant — `seq(consume(True), action(true))`
|
|
271
|
+
if (opOf(last) === 'action' && isConstant(last.action) && head.every(isPlainParser) && !head.some(usesOuterScope)) {
|
|
272
|
+
return { yields: { parsers: head, value: last.action } }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// pick: any number of unlabelled parsers around exactly one labelled one, then
|
|
276
|
+
// an action that returns just that label — `seq(consume(Where), label(e, P), action({val: e}))`.
|
|
277
|
+
// `between` is the special case with one on each side.
|
|
278
|
+
if (opOf(last) === 'action' && opOf(last.action) === 'val' && typeof last.action.val === 'string') {
|
|
279
|
+
const name = last.action.val
|
|
280
|
+
const labelled = head.map((child, i) => (opOf(child) === 'label' ? i : -1)).filter((i) => i !== -1)
|
|
281
|
+
if (labelled.length === 1 && head[labelled[0]].label[0] === name &&
|
|
282
|
+
head.every((child, i) => i === labelled[0] || isPlainParser(child)) &&
|
|
283
|
+
!head.some(usesOuterScope)) {
|
|
284
|
+
return { pick: { parsers: head.map((child, i) => (i === labelled[0] ? child.label[1] : child)), index: labelled[0] } }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
257
289
|
return null
|
|
258
290
|
}
|
|
259
291
|
|
|
292
|
+
/** A value that cannot be mutated or aliased: safe to hand out from every parse. */
|
|
293
|
+
const isConstant = (value) =>
|
|
294
|
+
value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* True when the logic reads from an outer scope (`{val: [[-1], ...]}` or
|
|
298
|
+
* `{var: "../x"}`). The engine's compiler does not thread `above` into built
|
|
299
|
+
* functions, so such actions must stay interpreted — and a rewrite that removes
|
|
300
|
+
* a `seq`'s bindings object would change what such a hop reaches.
|
|
301
|
+
*/
|
|
302
|
+
export function usesOuterScope (logic) {
|
|
303
|
+
if (Array.isArray(logic)) return logic.some(usesOuterScope)
|
|
304
|
+
if (!logic || typeof logic !== 'object') return false
|
|
305
|
+
|
|
306
|
+
for (const key of Object.keys(logic)) {
|
|
307
|
+
const arg = logic[key]
|
|
308
|
+
if (key === 'val' && Array.isArray(arg) && Array.isArray(arg[0])) return true
|
|
309
|
+
if (key === 'var') {
|
|
310
|
+
if (typeof arg === 'string' && arg.includes('../')) return true
|
|
311
|
+
if (Array.isArray(arg) && typeof arg[0] === 'string' && arg[0].includes('../')) return true
|
|
312
|
+
}
|
|
313
|
+
if (usesOuterScope(arg)) return true
|
|
314
|
+
}
|
|
315
|
+
return false
|
|
316
|
+
}
|
|
317
|
+
|
|
260
318
|
/** A node that is a parser and not one of `seq`'s own interpreted shapes. */
|
|
261
319
|
function isPlainParser (node) {
|
|
262
320
|
const op = opOf(node)
|
|
@@ -339,6 +397,32 @@ export function recognizeAltPrefixIdiom (branches) {
|
|
|
339
397
|
}
|
|
340
398
|
}
|
|
341
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Rewrites an `alt` whose every branch is a bare `consume` into `oneOf`:
|
|
402
|
+
* `alt(consume(Plus), consume(Minus))` is `oneOf(Plus, Minus)`.
|
|
403
|
+
*
|
|
404
|
+
* Same language, same value (the matched token), same expected-token list on
|
|
405
|
+
* failure — a token has one type, so "first branch that matches" and "any of
|
|
406
|
+
* these" cannot differ. What differs is cost: `alt` builds a branch per token,
|
|
407
|
+
* `oneOf` is a single test. Measured at 1.3–1.8x for the 2–7 token operator sets
|
|
408
|
+
* grammars actually have, and still 1.2x at 24.
|
|
409
|
+
*
|
|
410
|
+
* @param {any[]} branches
|
|
411
|
+
* @returns {{ oneOf: string[] } | null}
|
|
412
|
+
*/
|
|
413
|
+
export function recognizeAltConsumeIdiom (branches) {
|
|
414
|
+
if (!Array.isArray(branches) || branches.length < 2) return null
|
|
415
|
+
const names = []
|
|
416
|
+
for (const branch of branches) {
|
|
417
|
+
if (opOf(branch) !== 'consume') return null
|
|
418
|
+
const arg = branch.consume
|
|
419
|
+
const name = typeof arg === 'string' ? arg : (Array.isArray(arg) && arg.length === 1 ? arg[0] : null)
|
|
420
|
+
if (typeof name !== 'string') return null
|
|
421
|
+
names.push(name)
|
|
422
|
+
}
|
|
423
|
+
return { oneOf: names }
|
|
424
|
+
}
|
|
425
|
+
|
|
342
426
|
const BAIL = Symbol('grammatik.bail')
|
|
343
427
|
|
|
344
428
|
/**
|
|
@@ -422,7 +506,7 @@ function computeFirsts (grammar) {
|
|
|
422
506
|
* @param {Map<string, First>} ruleFirsts
|
|
423
507
|
* @param {Set<string>} out
|
|
424
508
|
*/
|
|
425
|
-
function leftmostCalls (node, ruleFirsts, out) {
|
|
509
|
+
export function leftmostCalls (node, ruleFirsts, out) {
|
|
426
510
|
const op = opOf(node)
|
|
427
511
|
if (op === null) return
|
|
428
512
|
const arg = node[op]
|
|
@@ -440,6 +524,7 @@ function leftmostCalls (node, ruleFirsts, out) {
|
|
|
440
524
|
case 'many':
|
|
441
525
|
case 'many1':
|
|
442
526
|
case 'option':
|
|
527
|
+
case 'flag':
|
|
443
528
|
case 'lookahead':
|
|
444
529
|
case 'negLookahead':
|
|
445
530
|
leftmostCalls(arg, ruleFirsts, out)
|
|
@@ -450,6 +535,10 @@ function leftmostCalls (node, ruleFirsts, out) {
|
|
|
450
535
|
case 'between':
|
|
451
536
|
if (arg) leftmostCalls({ seq: [arg.open, arg.body, arg.close] }, ruleFirsts, out)
|
|
452
537
|
break
|
|
538
|
+
case 'pick':
|
|
539
|
+
case 'yields':
|
|
540
|
+
if (arg && Array.isArray(arg.parsers)) leftmostCalls({ seq: arg.parsers }, ruleFirsts, out)
|
|
541
|
+
break
|
|
453
542
|
case 'prefix':
|
|
454
543
|
if (arg) {
|
|
455
544
|
leftmostCalls(arg.operator, ruleFirsts, out)
|
|
@@ -527,6 +616,7 @@ function validateNode (node, grammar, tokenNames, errors, path) {
|
|
|
527
616
|
case 'many':
|
|
528
617
|
case 'many1':
|
|
529
618
|
case 'option':
|
|
619
|
+
case 'flag':
|
|
530
620
|
case 'text':
|
|
531
621
|
case 'lookahead':
|
|
532
622
|
case 'negLookahead':
|
|
@@ -548,6 +638,26 @@ function validateNode (node, grammar, tokenNames, errors, path) {
|
|
|
548
638
|
}
|
|
549
639
|
break
|
|
550
640
|
}
|
|
641
|
+
case 'pick': {
|
|
642
|
+
if (!arg || !Array.isArray(arg.parsers) || arg.parsers.length === 0) {
|
|
643
|
+
errors.push(`${path}: 'pick' expects { parsers: [parser, ...], index }`)
|
|
644
|
+
} else if (!Number.isInteger(arg.index) || arg.index < 0 || arg.index >= arg.parsers.length) {
|
|
645
|
+
errors.push(`${path}: 'pick' index ${JSON.stringify(arg.index)} is out of range for ${arg.parsers.length} parser(s)`)
|
|
646
|
+
} else {
|
|
647
|
+
arg.parsers.forEach((c, i) => validateNode(c, grammar, tokenNames, errors, `${path}.pick[${i}]`))
|
|
648
|
+
}
|
|
649
|
+
break
|
|
650
|
+
}
|
|
651
|
+
case 'yields': {
|
|
652
|
+
if (!arg || !Array.isArray(arg.parsers) || arg.parsers.length === 0 || !('value' in arg)) {
|
|
653
|
+
errors.push(`${path}: 'yields' expects { parsers: [parser, ...], value }`)
|
|
654
|
+
} else if (!isConstant(arg.value)) {
|
|
655
|
+
errors.push(`${path}: 'yields' value must be a string, number, boolean or null, got ${JSON.stringify(arg.value)}`)
|
|
656
|
+
} else {
|
|
657
|
+
arg.parsers.forEach((c, i) => validateNode(c, grammar, tokenNames, errors, `${path}.yields[${i}]`))
|
|
658
|
+
}
|
|
659
|
+
break
|
|
660
|
+
}
|
|
551
661
|
case 'prefix': {
|
|
552
662
|
if (!arg || typeof arg !== 'object' || !('operator' in arg) || !('operand' in arg) || !('combine' in arg)) {
|
|
553
663
|
errors.push(`${path}: 'prefix' expects { operator, operand, combine }`)
|
package/src/codegen.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
'use strict'
|
|
3
3
|
|
|
4
4
|
import { FAIL, expect } from './state.js'
|
|
5
|
-
import { buildDispatch, opOf, recognizeSeqIdiom, recognizeAltPrefixIdiom } from './analyze.js'
|
|
5
|
+
import { buildDispatch, opOf, recognizeSeqIdiom, recognizeAltPrefixIdiom, recognizeAltConsumeIdiom } from './analyze.js'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* A real source generator for grammars.
|
|
@@ -23,10 +23,11 @@ import { buildDispatch, opOf, recognizeSeqIdiom, recognizeAltPrefixIdiom } from
|
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* @param {{ planner: any, rules: Record<string, any>, firsts: Map<string, any>|null,
|
|
26
|
-
* memo: boolean, maxSteps: number, tokenIds?: Map<string, number>|null
|
|
26
|
+
* memo: boolean, maxSteps: number, tokenIds?: Map<string, number>|null,
|
|
27
|
+
* memoSlots?: Map<string, number>, inlineRules?: boolean, idioms?: boolean }} config
|
|
27
28
|
*/
|
|
28
29
|
export function createCodegen (config) {
|
|
29
|
-
const { planner, rules, firsts, memo, maxSteps, tokenIds = null } = config
|
|
30
|
+
const { planner, rules, firsts, memo, maxSteps, tokenIds = null, memoSlots = new Map(), inlineRules = true, idioms = true } = config
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* `token.id === <n>` is an integer compare instead of a string compare, and
|
|
@@ -74,6 +75,56 @@ export function createCodegen (config) {
|
|
|
74
75
|
/** A literal value sitting in parser position. */
|
|
75
76
|
const constantHelper = (node) => helper(planner.plan(node), { kind: 'constant', node })
|
|
76
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Operator-precedence levels (`Additive = infixLeft(subrule(Multiplicative), ...)`)
|
|
80
|
+
* are written as a chain of rules, one per level, and each level costs a call, a
|
|
81
|
+
* loop and a token check even when no operator of that level follows: measured at
|
|
82
|
+
* ~10 ns per level per atom, so an 8-level chain costs several times the atom
|
|
83
|
+
* itself. A level with exactly one caller can be written into that caller's body
|
|
84
|
+
* instead, which keeps the loop and drops the call.
|
|
85
|
+
*
|
|
86
|
+
* Only these four shapes, and only a single call site: nothing is duplicated, and
|
|
87
|
+
* the function that results stays small enough for V8 to optimize (a rule like
|
|
88
|
+
* `Primary`, with an `alt` of many branches, is not a level and is left a call).
|
|
89
|
+
*/
|
|
90
|
+
// How many levels go into one function. Unbounded, a long chain makes a function
|
|
91
|
+
// larger than V8 will optimize at all (its `max-optimized-bytecode-size`), and the
|
|
92
|
+
// gain turns back into a loss: measured on a synthetic chain, 8 levels in one
|
|
93
|
+
// function ran at 66 ns per atom against 33 ns for 7. Six per function was the best
|
|
94
|
+
// of the caps tried (2, 3, 4, 5, 6, 8) across chains of 3 to 16 levels.
|
|
95
|
+
const MAX_INLINE_DEPTH = 6
|
|
96
|
+
/** `oneOf` with more names than this is generated as a `switch` on the token id. */
|
|
97
|
+
const ONE_OF_SWITCH_AT = 12
|
|
98
|
+
const INLINE_OPS = new Set(['infixLeft', 'infixRight', 'prefix', 'postfix'])
|
|
99
|
+
const callSites = new Map()
|
|
100
|
+
;(function count (node) {
|
|
101
|
+
if (Array.isArray(node)) { for (const child of node) count(child); return }
|
|
102
|
+
if (!node || typeof node !== 'object') return
|
|
103
|
+
if ('subrule' in node) {
|
|
104
|
+
const target = Array.isArray(node.subrule) ? node.subrule[0] : node.subrule
|
|
105
|
+
if (typeof target === 'string') callSites.set(target, (callSites.get(target) || 0) + 1)
|
|
106
|
+
}
|
|
107
|
+
for (const key of Object.keys(node)) count(node[key])
|
|
108
|
+
})(rules)
|
|
109
|
+
/** Rules being written into a caller right now, so a cycle cannot recurse forever. */
|
|
110
|
+
const inlining = new Set()
|
|
111
|
+
/**
|
|
112
|
+
* Some constructs write a child twice: `infixLeft` and `infixRight` their operand (once for the
|
|
113
|
+
* first, once inside the loop), `many1` and `many1Sep` their item. Inline a rule into *both*
|
|
114
|
+
* copies and a chain of such rules multiplies out — one copy per level at the first, two at the
|
|
115
|
+
* next, four at the next: measured, the formula grammar's module nearly doubled and a six-level
|
|
116
|
+
* chain grew fivefold. The second copy is the loop body, which only runs when more input follows,
|
|
117
|
+
* so it stays a call; only the first copy, the one every parse goes through, is inlined.
|
|
118
|
+
*/
|
|
119
|
+
let coldDepth = 0
|
|
120
|
+
const cold = (write) => {
|
|
121
|
+
coldDepth++
|
|
122
|
+
try { return write() } finally { coldDepth-- }
|
|
123
|
+
}
|
|
124
|
+
const canInline = (name) =>
|
|
125
|
+
inlineRules && coldDepth === 0 && !memo && !memoSlots.has(name) && !inlining.has(name) && inlining.size < MAX_INLINE_DEPTH &&
|
|
126
|
+
callSites.get(name) === 1 && INLINE_OPS.has(opOf(rules[name]))
|
|
127
|
+
|
|
77
128
|
function indexOfRule (name) {
|
|
78
129
|
let index = ruleIndex.get(name)
|
|
79
130
|
if (index !== undefined) return index
|
|
@@ -153,6 +204,17 @@ export function createCodegen (config) {
|
|
|
153
204
|
return result
|
|
154
205
|
}
|
|
155
206
|
|
|
207
|
+
case 'flag': {
|
|
208
|
+
const start = nextId()
|
|
209
|
+
out.push(`let ${result};`, `const ${start} = c.idx;`, '{')
|
|
210
|
+
const value = gen(arg, out, bindingsVar)
|
|
211
|
+
out.push(
|
|
212
|
+
`if (${value} === F) { c.idx = ${start}; ${result} = false; } else ${result} = true;`,
|
|
213
|
+
'}'
|
|
214
|
+
)
|
|
215
|
+
return result
|
|
216
|
+
}
|
|
217
|
+
|
|
156
218
|
case 'many':
|
|
157
219
|
case 'many1': {
|
|
158
220
|
const atLeastOne = op === 'many1'
|
|
@@ -172,7 +234,7 @@ export function createCodegen (config) {
|
|
|
172
234
|
}
|
|
173
235
|
|
|
174
236
|
out.push('for (;;) {', `const ${before} = c.idx;`)
|
|
175
|
-
const value = gen(arg, out, bindingsVar)
|
|
237
|
+
const value = atLeastOne ? cold(() => gen(arg, out, bindingsVar)) : gen(arg, out, bindingsVar)
|
|
176
238
|
out.push(
|
|
177
239
|
`if (${value} === F) { c.idx = ${before}; break; }`,
|
|
178
240
|
`if (c.idx === ${before}) P(${JSON.stringify(op)}, ${before});`,
|
|
@@ -209,7 +271,7 @@ export function createCodegen (config) {
|
|
|
209
271
|
out.push(`if (${operatorVar} === F) { c.idx = ${mark}; break; }`)
|
|
210
272
|
}
|
|
211
273
|
|
|
212
|
-
const right = gen(arg.operand, out, bindingsVar)
|
|
274
|
+
const right = cold(() => gen(arg.operand, out, bindingsVar))
|
|
213
275
|
out.push(
|
|
214
276
|
`if (${right} === F) { c.idx = ${mark}; break; }`,
|
|
215
277
|
`if (${bindings} === null) ${bindings} = { left: null, op: null, right: null };`,
|
|
@@ -225,12 +287,34 @@ export function createCodegen (config) {
|
|
|
225
287
|
case 'oneOf': {
|
|
226
288
|
const names = Array.isArray(arg) ? arg : [arg]
|
|
227
289
|
const token = nextId()
|
|
290
|
+
const ids = tokenIds ? names.map((n) => tokenIds.get(n)) : []
|
|
291
|
+
const failed = `else { ${names.map((n) => `E(c, ${JSON.stringify(n)});`).join(' ')} ${result} = F; }`
|
|
292
|
+
|
|
293
|
+
if (names.length > ONE_OF_SWITCH_AT && ids.every((id) => id !== undefined)) {
|
|
294
|
+
// Past a handful of names a chain of comparisons costs more than a jump
|
|
295
|
+
// table: measured, the chain matched `alt`'s switch at 12 names and lost
|
|
296
|
+
// beyond. Integer cases only, as `alt` does it — a `case undefined` for a
|
|
297
|
+
// hand-built token would stop V8 building the table — so the id is
|
|
298
|
+
// normalised first, from the type name when the token has none.
|
|
299
|
+
const hit = nextId()
|
|
300
|
+
const cases = [...new Set(ids)].map((id) => `case ${id}:`).join(' ')
|
|
301
|
+
out.push(
|
|
302
|
+
`let ${result};`,
|
|
303
|
+
`const ${token} = c.tokens[c.idx];`,
|
|
304
|
+
`let ${hit} = false;`,
|
|
305
|
+
`if (${token} !== undefined) { switch (${token}.id !== undefined ? ${token}.id : I[${token}.type]) { ${cases} ${hit} = true; } }`,
|
|
306
|
+
`if (${hit}) { c.idx++; ${result} = ${token}; }`,
|
|
307
|
+
failed
|
|
308
|
+
)
|
|
309
|
+
return result
|
|
310
|
+
}
|
|
311
|
+
|
|
228
312
|
const test = names.map((n) => typeCheck(token, n)).join(' || ')
|
|
229
313
|
out.push(
|
|
230
314
|
`let ${result};`,
|
|
231
315
|
`const ${token} = c.tokens[c.idx];`,
|
|
232
316
|
`if (${token} !== undefined && (${test})) { c.idx++; ${result} = ${token}; }`,
|
|
233
|
-
|
|
317
|
+
failed
|
|
234
318
|
)
|
|
235
319
|
return result
|
|
236
320
|
}
|
|
@@ -266,7 +350,7 @@ export function createCodegen (config) {
|
|
|
266
350
|
operatorVar = gen(arg.operator, out, bindingsVar)
|
|
267
351
|
out.push(`if (${operatorVar} === F) { c.idx = ${mark}; break; }`)
|
|
268
352
|
}
|
|
269
|
-
const right = gen(arg.operand, out, bindingsVar)
|
|
353
|
+
const right = cold(() => gen(arg.operand, out, bindingsVar))
|
|
270
354
|
out.push(
|
|
271
355
|
`if (${right} === F) { c.idx = ${mark}; break; }`,
|
|
272
356
|
`if (${items} === null) ${items} = [];`,
|
|
@@ -347,6 +431,22 @@ export function createCodegen (config) {
|
|
|
347
431
|
return result
|
|
348
432
|
}
|
|
349
433
|
|
|
434
|
+
case 'pick':
|
|
435
|
+
case 'yields': {
|
|
436
|
+
// The parsers in order, then one of their results or a constant; no
|
|
437
|
+
// bindings object, which is the point (see `recognizeSeqIdiom`).
|
|
438
|
+
const label = 'L' + nextId()
|
|
439
|
+
const start = nextId()
|
|
440
|
+
out.push(tickSrc, `let ${result};`, `${label}: {`, `const ${start} = c.idx;`)
|
|
441
|
+
const values = arg.parsers.map((parser) => {
|
|
442
|
+
const value = gen(parser, out, bindingsVar)
|
|
443
|
+
out.push(`if (${value} === F) { c.idx = ${start}; ${result} = F; break ${label}; }`)
|
|
444
|
+
return value
|
|
445
|
+
})
|
|
446
|
+
out.push(`${result} = ${op === 'pick' ? values[arg.index] : JSON.stringify(arg.value)};`, '}')
|
|
447
|
+
return result
|
|
448
|
+
}
|
|
449
|
+
|
|
350
450
|
case 'prefix': {
|
|
351
451
|
const label = 'L' + nextId()
|
|
352
452
|
const start = nextId()
|
|
@@ -428,7 +528,7 @@ export function createCodegen (config) {
|
|
|
428
528
|
)
|
|
429
529
|
const sep = gen(sepNode, out, bindingsVar)
|
|
430
530
|
out.push(`if (${sep} === F) { c.idx = ${beforeSep}; break; }`, `const ${beforeItem} = c.idx;`)
|
|
431
|
-
const item = gen(arg.rule, out, bindingsVar)
|
|
531
|
+
const item = cold(() => gen(arg.rule, out, bindingsVar))
|
|
432
532
|
out.push(
|
|
433
533
|
`if (${item} === F) { c.idx = ${trailing ? beforeItem : beforeSep}; break; }`,
|
|
434
534
|
`${result}.push(${item});`,
|
|
@@ -445,8 +545,20 @@ export function createCodegen (config) {
|
|
|
445
545
|
out.push(`const ${result} = ${helper(planner.plan(node), { kind: 'unknownRule', name })}(c);`)
|
|
446
546
|
return result
|
|
447
547
|
}
|
|
448
|
-
const index = indexOfRule(name)
|
|
449
548
|
const parent = nextId()
|
|
549
|
+
if (canInline(name)) {
|
|
550
|
+
out.push(tickSrc)
|
|
551
|
+
out.push(
|
|
552
|
+
`const ${parent} = c.ruleDepth;`,
|
|
553
|
+
`if (c.track) { c.ruleNames[${parent}] = ${JSON.stringify(name)}; c.ruleDepth = ${parent} + 1; }`
|
|
554
|
+
)
|
|
555
|
+
inlining.add(name)
|
|
556
|
+
const inner = gen(rules[name], out, null)
|
|
557
|
+
inlining.delete(name)
|
|
558
|
+
out.push(`if (c.track) c.ruleDepth = ${parent};`)
|
|
559
|
+
return inner
|
|
560
|
+
}
|
|
561
|
+
const index = indexOfRule(name)
|
|
450
562
|
out.push(tickSrc)
|
|
451
563
|
if (memo) {
|
|
452
564
|
const key = nextId()
|
|
@@ -467,6 +579,27 @@ export function createCodegen (config) {
|
|
|
467
579
|
`c.memo.set(${key}, { value: ${result}, end: c.idx });`,
|
|
468
580
|
'}'
|
|
469
581
|
)
|
|
582
|
+
} else if (memoSlots.has(name)) {
|
|
583
|
+
// Chosen by `selectMemoRules`: remember the result per position.
|
|
584
|
+
const table = nextId()
|
|
585
|
+
const at = nextId()
|
|
586
|
+
const end = nextId()
|
|
587
|
+
out.push(
|
|
588
|
+
`let ${result};`,
|
|
589
|
+
`const ${table} = c.rm[${memoSlots.get(name)}];`,
|
|
590
|
+
`const ${at} = c.idx;`,
|
|
591
|
+
`const ${end} = ${table}.ends[${at}];`,
|
|
592
|
+
`if (${end} !== 0) { c.idx = ${end} - 1; ${result} = ${table}.vals[${at}]; }`,
|
|
593
|
+
'else {',
|
|
594
|
+
`const ${parent} = c.ruleDepth;`,
|
|
595
|
+
`if (c.track) { c.ruleNames[${parent}] = ${JSON.stringify(name)}; c.ruleDepth = ${parent} + 1; }`,
|
|
596
|
+
`${result} = R[${index}](c);`,
|
|
597
|
+
`if (c.track) c.ruleDepth = ${parent};`,
|
|
598
|
+
`if (${result} === F) c.idx = ${at};`,
|
|
599
|
+
`${table}.ends[${at}] = c.idx + 1;`,
|
|
600
|
+
`${table}.vals[${at}] = ${result};`,
|
|
601
|
+
'}'
|
|
602
|
+
)
|
|
470
603
|
} else {
|
|
471
604
|
out.push(
|
|
472
605
|
`const ${parent} = c.ruleDepth;`,
|
|
@@ -489,7 +622,7 @@ export function createCodegen (config) {
|
|
|
489
622
|
if (!Array.isArray(children)) throw new Error("'seq' expects an array of parsers")
|
|
490
623
|
|
|
491
624
|
// Same idiom recognition as the planner, so both paths agree.
|
|
492
|
-
const idiom = recognizeSeqIdiom(children)
|
|
625
|
+
const idiom = idioms ? recognizeSeqIdiom(children) : null
|
|
493
626
|
if (idiom !== null) return gen(idiom, out, null)
|
|
494
627
|
|
|
495
628
|
let hasLabels = false
|
|
@@ -556,7 +689,10 @@ export function createCodegen (config) {
|
|
|
556
689
|
function genAlt (branches, out, result, bindingsVar) {
|
|
557
690
|
if (!Array.isArray(branches)) throw new Error("'alt' expects an array of branches")
|
|
558
691
|
|
|
559
|
-
const
|
|
692
|
+
const oneOf = idioms ? recognizeAltConsumeIdiom(branches) : null
|
|
693
|
+
if (oneOf !== null) return gen(oneOf, out, bindingsVar)
|
|
694
|
+
|
|
695
|
+
const factored = idioms ? recognizeAltPrefixIdiom(branches) : null
|
|
560
696
|
if (factored !== null) return gen(factored, out, bindingsVar)
|
|
561
697
|
|
|
562
698
|
const table = firsts ? buildDispatch(branches, firsts) : null
|
package/src/definition.js
CHANGED
|
@@ -227,6 +227,7 @@ const CONSTRUCTORS = {
|
|
|
227
227
|
many: { arity: [1, 1], of: ([a]) => ({ many: a }) },
|
|
228
228
|
many1: { arity: [1, 1], of: ([a]) => ({ many1: a }) },
|
|
229
229
|
option: { arity: [1, 1], of: ([a]) => ({ option: a }) },
|
|
230
|
+
flag: { arity: [1, 1], of: ([a]) => ({ flag: a }) },
|
|
230
231
|
action: { arity: [1, 1], of: ([a]) => ({ action: a }) },
|
|
231
232
|
lookahead: { arity: [1, 1], of: ([a]) => ({ lookahead: a }) },
|
|
232
233
|
negLookahead: { arity: [1, 1], of: ([a]) => ({ negLookahead: a }) },
|
|
@@ -241,6 +242,10 @@ const CONSTRUCTORS = {
|
|
|
241
242
|
many1Sep: { arity: [2, 2], of: ([a, b]) => ({ many1Sep: { rule: a, sep: b } }) },
|
|
242
243
|
|
|
243
244
|
between: { arity: [3, 3], of: ([a, b, c]) => ({ between: { open: a, body: b, close: c } }) },
|
|
245
|
+
// pick(1, consume(Open), subrule(Body), consume(Close)) — the result at that index.
|
|
246
|
+
pick: { arity: [2, Infinity], of: ([index, ...parsers]) => ({ pick: { parsers, index } }) },
|
|
247
|
+
// yields(consume(True), true) — run the parsers, give back the constant.
|
|
248
|
+
yields: { arity: [2, Infinity], of: (args) => ({ yields: { parsers: args.slice(0, -1), value: args[args.length - 1] } }) },
|
|
244
249
|
infixLeft: { arity: [3, 3], of: ([a, b, c]) => ({ infixLeft: { operand: a, operator: b, combine: logicOf(c) } }) },
|
|
245
250
|
infixRight: { arity: [3, 3], of: ([a, b, c]) => ({ infixRight: { operand: a, operator: b, combine: logicOf(c) } }) },
|
|
246
251
|
prefix: { arity: [3, 3], of: ([a, b, c]) => ({ prefix: { operator: a, operand: b, combine: logicOf(c) } }) },
|
package/src/emit.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
'use strict'
|
|
3
3
|
|
|
4
4
|
import { createLexer } from './lexer.js'
|
|
5
|
+
import { generateLexer } from './lexgen.js'
|
|
5
6
|
import { analyze } from './analyze.js'
|
|
6
7
|
import { createPlanner } from './plan.js'
|
|
7
8
|
import { createCodegen } from './codegen.js'
|
|
9
|
+
import { selectMemoRules } from './memoplan.js'
|
|
8
10
|
import { usesOuterScope } from './plan.js'
|
|
9
11
|
import { emitMethodsBlock, evaluateMethodsBlock } from './methodsblock.js'
|
|
10
12
|
import { createValueMethods } from './values.js'
|
|
@@ -100,8 +102,9 @@ const literal = (value, indent = 0) => JSON.stringify(value, null, 2)
|
|
|
100
102
|
|
|
101
103
|
/**
|
|
102
104
|
* @param {import('./parser.js').GrammarSpec} spec
|
|
103
|
-
* @param {{ memo?: boolean, strict?: boolean, ll1?: boolean, maxSteps?: number,
|
|
105
|
+
* @param {{ memo?: boolean, autoMemo?: boolean, inlineRules?: boolean, idioms?: boolean, strict?: boolean, ll1?: boolean, maxSteps?: number,
|
|
104
106
|
* positions?: 'full'|'offset', execution?: 'generated'|'interpreted',
|
|
107
|
+
* lexer?: 'generated'|'runtime', scanners?: boolean, fuse?: boolean,
|
|
105
108
|
* format?: 'esm'|'cjs', validate?: boolean, moduleName?: string,
|
|
106
109
|
* runtimeSpecifier?: string, engineSpecifier?: string,
|
|
107
110
|
* methods?: Record<string, any> }} [options]
|
|
@@ -111,11 +114,21 @@ export function emitModule (spec, options = {}) {
|
|
|
111
114
|
const { tokens: tokenDefs, rules, start } = spec
|
|
112
115
|
const {
|
|
113
116
|
memo = false,
|
|
117
|
+
autoMemo = true,
|
|
118
|
+
// Off here, on in `createParser`. Inlining a precedence chain is worth 2-3x on a bare chain but only
|
|
119
|
+
// 1.02-1.04x on the formula and SQL examples, and it makes the file bigger: the formula module grows
|
|
120
|
+
// 32% minified (38 to 50 KB) even after the second copy of each operand is kept as a call. A parser
|
|
121
|
+
// built at run time pays nothing for that; a file someone ships does.
|
|
122
|
+
inlineRules = false,
|
|
123
|
+
idioms = true,
|
|
114
124
|
strict = true,
|
|
115
125
|
ll1 = true,
|
|
116
126
|
maxSteps = 0,
|
|
117
127
|
positions = 'full',
|
|
118
128
|
execution = 'generated',
|
|
129
|
+
lexer: lexerMode = 'generated',
|
|
130
|
+
scanners = true,
|
|
131
|
+
fuse = true,
|
|
119
132
|
format = 'esm',
|
|
120
133
|
validate = true,
|
|
121
134
|
moduleName = spec.name || 'parser',
|
|
@@ -125,6 +138,7 @@ export function emitModule (spec, options = {}) {
|
|
|
125
138
|
} = options
|
|
126
139
|
|
|
127
140
|
if (format !== 'esm' && format !== 'cjs') throw new Error(`emitModule: format must be 'esm' or 'cjs', got ${quote(format)}`)
|
|
141
|
+
if (lexerMode !== 'generated' && lexerMode !== 'runtime') throw new Error(`emitModule: lexer must be 'generated' or 'runtime', got ${quote(lexerMode)}`)
|
|
128
142
|
|
|
129
143
|
// A `methods { ... }` block travels with the grammar and is written into the
|
|
130
144
|
// output as source. Nothing evaluates it here, so this path stays eval-free.
|
|
@@ -132,6 +146,8 @@ export function emitModule (spec, options = {}) {
|
|
|
132
146
|
const block = methodsBlock ? emitMethodsBlock(methodsBlock, format) : null
|
|
133
147
|
if (!start || !(start in rules)) throw new Error(`emitModule: start rule '${start}' is not defined in rules`)
|
|
134
148
|
|
|
149
|
+
// Built here even when the file will carry its own lexer: constructing it is what validates the token
|
|
150
|
+
// definitions, and it fixes the token ids the rules are compiled against.
|
|
135
151
|
const lexer = createLexer(tokenDefs, { positions })
|
|
136
152
|
const analysis = analyze(rules, { tokenNames: lexer.tokenNames })
|
|
137
153
|
if (validate && analysis.errors.length > 0) throw new GrammarError(analysis.errors)
|
|
@@ -144,8 +160,12 @@ export function emitModule (spec, options = {}) {
|
|
|
144
160
|
// comment in parser.js.
|
|
145
161
|
const tokenIds = new Map(lexer.tokenNames.map((name, id) => [name, id]))
|
|
146
162
|
const engine = { build: () => { throw new Error('not compiled during emit') }, run: () => {} }
|
|
147
|
-
const
|
|
148
|
-
|
|
163
|
+
const memoRules = autoMemo && !memo && analysis.errors.length === 0
|
|
164
|
+
? selectMemoRules(rules, analysis.firsts, { ll1 })
|
|
165
|
+
: []
|
|
166
|
+
const memoSlots = new Map(memoRules.map((name, slot) => [name, slot]))
|
|
167
|
+
const planner = createPlanner({ engine, rules, firsts, memo, maxSteps, unsafeEval: false, tokenIds, memoSlots, idioms })
|
|
168
|
+
const codegen = createCodegen({ planner, rules, firsts, memo, maxSteps, tokenIds, memoSlots, inlineRules, idioms })
|
|
149
169
|
|
|
150
170
|
codegen.compile(start)
|
|
151
171
|
|
|
@@ -214,7 +234,9 @@ export function emitModule (spec, options = {}) {
|
|
|
214
234
|
// actions may use the value methods, so it has to register them the way
|
|
215
235
|
// `createParser` does. They come from the runtime rather than being written
|
|
216
236
|
// out here, so there is one implementation rather than two.
|
|
217
|
-
|
|
237
|
+
// A lexer written out as source needs only the error class from the runtime; the engine stays out of the file.
|
|
238
|
+
const generatedLexer = lexerMode === 'generated'
|
|
239
|
+
const runtimeImports = ['FAIL as F', 'expect as E', 'createState', 'buildParseError', generatedLexer ? 'LexError' : 'createLexer']
|
|
218
240
|
if (needsEngine) runtimeImports.push('createValueMethods')
|
|
219
241
|
|
|
220
242
|
const importRuntime = cjs
|
|
@@ -244,7 +266,7 @@ export function emitModule (spec, options = {}) {
|
|
|
244
266
|
//
|
|
245
267
|
// The grammar was analyzed when this file was written: FIRST sets, left-recursion
|
|
246
268
|
// checks and rule specialization all happened then. Importing it does no grammar
|
|
247
|
-
// work and generates no code.
|
|
269
|
+
// work and generates no code.${generatedLexer ? '\n// The lexer was written out as source too, so there is no lexer engine to load.' : ''}
|
|
248
270
|
${engineFree
|
|
249
271
|
? '// Every semantic action was compiled to source too, so this file has no\n// dependency on json-logic-engine at all.'
|
|
250
272
|
: interpretedActions
|
|
@@ -258,7 +280,7 @@ const tokens = ${literal(tokenDefs)}
|
|
|
258
280
|
|
|
259
281
|
const start = ${quote(start)}
|
|
260
282
|
|
|
261
|
-
const lexer = createLexer(tokens, { positions: ${quote(positions)} })
|
|
283
|
+
const lexer = ${generatedLexer ? generateLexer(tokenDefs, { positions, scanners, fuse }) : `createLexer(tokens, { positions: ${quote(positions)}, scanners: ${scanners}, fuse: ${fuse} })`}
|
|
262
284
|
const tokenize = lexer.tokenize
|
|
263
285
|
|
|
264
286
|
${engineFree
|
|
@@ -357,7 +379,7 @@ const RULES = ${literal(Object.fromEntries(reached.map(([name, index]) => [name,
|
|
|
357
379
|
* @param {string} [source]
|
|
358
380
|
*/
|
|
359
381
|
function parseTokens (tokenList, source, track = false) {
|
|
360
|
-
const state = createState(tokenList, {}, { memo: ${memo}, maxSteps: ${maxSteps}, track })
|
|
382
|
+
const state = createState(tokenList, {}, { memo: ${memo}, maxSteps: ${maxSteps}, track, memoSlots: ${memoRules.length} })
|
|
361
383
|
state.source = source
|
|
362
384
|
state.positions = ${quote(positions)}
|
|
363
385
|
state.ruleNames[0] = start
|