grammatik 0.1.0
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/LICENSE +21 -0
- package/README.md +443 -0
- package/bin/grammatik.js +115 -0
- package/dist/cjs/index.js +3010 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/runtime.js +639 -0
- package/dist/esm/index.js +2959 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/runtime.js +605 -0
- package/dist/types/index.d.ts +214 -0
- package/dist/types/runtime.d.ts +40 -0
- package/package.json +65 -0
- package/src/analyze.d.ts +78 -0
- package/src/analyze.js +602 -0
- package/src/codegen.d.ts +36 -0
- package/src/codegen.js +635 -0
- package/src/definition.d.ts +133 -0
- package/src/definition.js +291 -0
- package/src/emit.d.ts +23 -0
- package/src/emit.js +374 -0
- package/src/firstchars.d.ts +6 -0
- package/src/firstchars.js +353 -0
- package/src/index.d.ts +7 -0
- package/src/index.js +15 -0
- package/src/lexer.d.ts +96 -0
- package/src/lexer.js +358 -0
- package/src/methods.d.ts +20 -0
- package/src/methods.js +61 -0
- package/src/methodsblock.js +197 -0
- package/src/parser.d.ts +130 -0
- package/src/parser.js +194 -0
- package/src/plan.d.ts +29 -0
- package/src/plan.js +938 -0
- package/src/runtime.js +16 -0
- package/src/state.d.ts +98 -0
- package/src/state.js +121 -0
- package/src/values.js +67 -0
package/src/analyze.js
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Static analysis of a grammar, in the spirit of Chevrotain's grammar recording.
|
|
6
|
+
*
|
|
7
|
+
* Two things come out of this:
|
|
8
|
+
*
|
|
9
|
+
* 1. FIRST sets, which let `alt` dispatch on the next token in O(1) instead of
|
|
10
|
+
* trying each branch and backtracking. This is the difference between an
|
|
11
|
+
* LL(1) parser and a PEG one, and it is most of the performance story.
|
|
12
|
+
* 2. Errors a grammar author actually wants at build time rather than at 3am:
|
|
13
|
+
* left recursion, references to rules or tokens that do not exist.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {object} First
|
|
18
|
+
* @property {Set<string>} tokens Token types that can begin this node.
|
|
19
|
+
* @property {boolean} nullable Whether the node can match while consuming nothing.
|
|
20
|
+
* @property {boolean} unknown Whether analysis could not fully determine the set.
|
|
21
|
+
* Any `unknown` disables LL(1) dispatch for the enclosing
|
|
22
|
+
* `alt`, which is a correctness guard, not a bug.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** @returns {First} */
|
|
26
|
+
const emptyFirst = () => ({ tokens: new Set(), nullable: false, unknown: false })
|
|
27
|
+
|
|
28
|
+
/** @returns {First} A node that matches nothing and consumes nothing (an action). */
|
|
29
|
+
const epsilon = () => ({ tokens: new Set(), nullable: true, unknown: false })
|
|
30
|
+
|
|
31
|
+
/** @returns {First} A node whose behaviour we cannot see through. */
|
|
32
|
+
const opaque = () => ({ tokens: new Set(), nullable: true, unknown: true })
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {First} target
|
|
36
|
+
* @param {First} source
|
|
37
|
+
* @returns {boolean} whether target changed
|
|
38
|
+
*/
|
|
39
|
+
function absorb (target, source) {
|
|
40
|
+
let changed = false
|
|
41
|
+
for (const t of source.tokens) {
|
|
42
|
+
if (!target.tokens.has(t)) {
|
|
43
|
+
target.tokens.add(t)
|
|
44
|
+
changed = true
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (source.unknown && !target.unknown) {
|
|
48
|
+
target.unknown = true
|
|
49
|
+
changed = true
|
|
50
|
+
}
|
|
51
|
+
return changed
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Returns the single operator key of a JSON Logic node, or null. */
|
|
55
|
+
export function opOf (node) {
|
|
56
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) return null
|
|
57
|
+
const keys = Object.keys(node)
|
|
58
|
+
return keys.length === 1 ? keys[0] : null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The set of node shapes this module understands as *parsers*. */
|
|
62
|
+
export const PARSER_OPS = new Set([
|
|
63
|
+
'consume', 'seq', 'alt', 'many', 'many1', 'option',
|
|
64
|
+
'manySep', 'many1Sep', 'infixLeft', 'infixRight', 'postfix', 'prefix',
|
|
65
|
+
'between', 'as', 'oneOf', 'eof',
|
|
66
|
+
'subrule', 'label', 'action',
|
|
67
|
+
'lookahead', 'negLookahead', 'text', 'epsilon'
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Computes the FIRST set of a node given current per-rule estimates.
|
|
72
|
+
* @param {*} node
|
|
73
|
+
* @param {Map<string, First>} ruleFirsts
|
|
74
|
+
* @returns {First}
|
|
75
|
+
*/
|
|
76
|
+
function firstOf (node, ruleFirsts) {
|
|
77
|
+
const op = opOf(node)
|
|
78
|
+
if (op === null) return opaque()
|
|
79
|
+
const arg = node[op]
|
|
80
|
+
|
|
81
|
+
switch (op) {
|
|
82
|
+
case 'consume': {
|
|
83
|
+
const result = emptyFirst()
|
|
84
|
+
const name = Array.isArray(arg) ? arg[0] : arg
|
|
85
|
+
if (typeof name === 'string') result.tokens.add(name)
|
|
86
|
+
else result.unknown = true
|
|
87
|
+
return result
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
case 'epsilon':
|
|
91
|
+
case 'action':
|
|
92
|
+
return epsilon()
|
|
93
|
+
|
|
94
|
+
case 'text':
|
|
95
|
+
case 'label':
|
|
96
|
+
// {label: [name, parser]} / {text: parser}
|
|
97
|
+
return firstOf(op === 'label' ? arg[1] : arg, ruleFirsts)
|
|
98
|
+
|
|
99
|
+
case 'seq': {
|
|
100
|
+
const result = epsilon()
|
|
101
|
+
if (!Array.isArray(arg)) return opaque()
|
|
102
|
+
for (const child of arg) {
|
|
103
|
+
const childFirst = firstOf(child, ruleFirsts)
|
|
104
|
+
absorb(result, childFirst)
|
|
105
|
+
if (!childFirst.nullable) {
|
|
106
|
+
result.nullable = false
|
|
107
|
+
break
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return result
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
case 'alt': {
|
|
114
|
+
const result = emptyFirst()
|
|
115
|
+
if (!Array.isArray(arg)) return opaque()
|
|
116
|
+
for (const child of arg) {
|
|
117
|
+
const childFirst = firstOf(child, ruleFirsts)
|
|
118
|
+
absorb(result, childFirst)
|
|
119
|
+
if (childFirst.nullable) result.nullable = true
|
|
120
|
+
}
|
|
121
|
+
return result
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
case 'many':
|
|
125
|
+
case 'option': {
|
|
126
|
+
const inner = firstOf(arg, ruleFirsts)
|
|
127
|
+
return { tokens: inner.tokens, nullable: true, unknown: inner.unknown }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
case 'many1': {
|
|
131
|
+
const inner = firstOf(arg, ruleFirsts)
|
|
132
|
+
return { tokens: inner.tokens, nullable: inner.nullable, unknown: inner.unknown }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
case 'oneOf': {
|
|
136
|
+
const result = emptyFirst()
|
|
137
|
+
const names = Array.isArray(arg) ? arg : [arg]
|
|
138
|
+
for (const name of names) {
|
|
139
|
+
if (typeof name === 'string') result.tokens.add(name)
|
|
140
|
+
else result.unknown = true
|
|
141
|
+
}
|
|
142
|
+
return result
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case 'eof':
|
|
146
|
+
// Zero-width and constraining, like a lookahead: never dispatch past it.
|
|
147
|
+
return opaque()
|
|
148
|
+
|
|
149
|
+
case 'as':
|
|
150
|
+
return firstOf(Array.isArray(arg) ? arg[0] : arg && arg.parser, ruleFirsts)
|
|
151
|
+
|
|
152
|
+
case 'between':
|
|
153
|
+
// Exactly a sequence of open, body, close.
|
|
154
|
+
return firstOf({ seq: [arg && arg.open, arg && arg.body, arg && arg.close] }, ruleFirsts)
|
|
155
|
+
|
|
156
|
+
case 'prefix': {
|
|
157
|
+
// Zero operators are allowed, so the operand's first set counts too.
|
|
158
|
+
const ops = firstOf(arg && arg.operator, ruleFirsts)
|
|
159
|
+
const inner = firstOf(arg && arg.operand, ruleFirsts)
|
|
160
|
+
const result = emptyFirst()
|
|
161
|
+
absorb(result, ops)
|
|
162
|
+
absorb(result, inner)
|
|
163
|
+
result.nullable = inner.nullable
|
|
164
|
+
return result
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
case 'postfix': {
|
|
168
|
+
// Begins and matches exactly where its operand does; suffixes are optional.
|
|
169
|
+
const inner = firstOf(arg && arg.operand, ruleFirsts)
|
|
170
|
+
return { tokens: inner.tokens, nullable: inner.nullable, unknown: inner.unknown }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
case 'infixRight':
|
|
174
|
+
case 'infixLeft': {
|
|
175
|
+
// The level begins wherever its operand begins, and matches whenever the
|
|
176
|
+
// operand does — the operator part is optional by construction.
|
|
177
|
+
const inner = firstOf(arg && arg.operand, ruleFirsts)
|
|
178
|
+
return { tokens: inner.tokens, nullable: inner.nullable, unknown: inner.unknown }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
case 'manySep':
|
|
182
|
+
case 'many1Sep': {
|
|
183
|
+
const inner = firstOf(arg && arg.rule, ruleFirsts)
|
|
184
|
+
return {
|
|
185
|
+
tokens: inner.tokens,
|
|
186
|
+
nullable: op === 'manySep' ? true : inner.nullable,
|
|
187
|
+
unknown: inner.unknown
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
case 'subrule': {
|
|
192
|
+
const name = Array.isArray(arg) ? arg[0] : arg
|
|
193
|
+
const known = ruleFirsts.get(name)
|
|
194
|
+
if (!known) return opaque()
|
|
195
|
+
return { tokens: known.tokens, nullable: known.nullable, unknown: known.unknown }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Zero-width assertions constrain the input without consuming it. They are
|
|
199
|
+
// nullable, but treating them as transparent would let `alt` dispatch past a
|
|
200
|
+
// guard that was meant to reject, so they are deliberately opaque.
|
|
201
|
+
case 'lookahead':
|
|
202
|
+
case 'negLookahead':
|
|
203
|
+
return opaque()
|
|
204
|
+
|
|
205
|
+
default:
|
|
206
|
+
// Any other JSON Logic (`+`, `cat`, `var`, ...) is a value expression: it
|
|
207
|
+
// produces a result without touching the token stream.
|
|
208
|
+
return epsilon()
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Rewrites a `seq` into a cheaper equivalent combinator when it matches a known
|
|
214
|
+
* idiom, in the spirit of json-logic-engine's own `checkIdioms`.
|
|
215
|
+
*
|
|
216
|
+
* Grammars are usually written with the general primitives, because that is
|
|
217
|
+
* what a grammar author reaches for. Two of those spellings are both very
|
|
218
|
+
* common and needlessly expensive, and both can be recognized mechanically:
|
|
219
|
+
*
|
|
220
|
+
* seq(open, label(n, body), close, action({val: n})) -> between(open, body, close)
|
|
221
|
+
* seq(label(n, parser), action(A)) -> as(parser, A')
|
|
222
|
+
*
|
|
223
|
+
* Together they were 40% of all `seq` invocations in the formula benchmark.
|
|
224
|
+
* Recognition means an existing grammar gets the faster path without being
|
|
225
|
+
* rewritten by hand.
|
|
226
|
+
*
|
|
227
|
+
* Returns the replacement node, or null when nothing matched.
|
|
228
|
+
*
|
|
229
|
+
* @param {any[]} children
|
|
230
|
+
*/
|
|
231
|
+
export function recognizeSeqIdiom (children) {
|
|
232
|
+
if (!Array.isArray(children)) return null
|
|
233
|
+
|
|
234
|
+
// between: open, label(n, body), close, action({val: n})
|
|
235
|
+
if (children.length === 4) {
|
|
236
|
+
const [open, labelled, close, act] = children
|
|
237
|
+
if (opOf(labelled) === 'label' && opOf(act) === 'action' &&
|
|
238
|
+
isPlainParser(open) && isPlainParser(close)) {
|
|
239
|
+
const [name, body] = labelled.label
|
|
240
|
+
const logic = act.action
|
|
241
|
+
if (opOf(logic) === 'val' && logic.val === name) {
|
|
242
|
+
return { between: { open, body, close } }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// as: label(n, parser), action(A) — where every reference in A is rooted at n
|
|
248
|
+
if (children.length === 2) {
|
|
249
|
+
const [labelled, act] = children
|
|
250
|
+
if (opOf(labelled) === 'label' && opOf(act) === 'action') {
|
|
251
|
+
const [name, parser] = labelled.label
|
|
252
|
+
const rewritten = rebaseOnLabel(act.action, name)
|
|
253
|
+
if (rewritten !== BAIL) return { as: [parser, rewritten] }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A node that is a parser and not one of `seq`'s own interpreted shapes. */
|
|
261
|
+
function isPlainParser (node) {
|
|
262
|
+
const op = opOf(node)
|
|
263
|
+
return op !== null && op !== 'label' && op !== 'action'
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const BAIL = Symbol('grammatik.bail')
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Rewrites logic that reads `name` from its enclosing sequence's bindings into
|
|
270
|
+
* logic that reads the value directly, since `as` passes the parsed value as
|
|
271
|
+
* the context itself.
|
|
272
|
+
*
|
|
273
|
+
* {val: name} -> {val: []} (the value)
|
|
274
|
+
* {val: [name, "image"]} -> {val: "image"}
|
|
275
|
+
*
|
|
276
|
+
* Returns BAIL for anything else that touches the context: a different label, a
|
|
277
|
+
* scope hop (`{val: [[-1], ...]}`), a bare `{val: []}` that would have meant the
|
|
278
|
+
* whole bindings object, or any `var` — whose path syntax is not worth
|
|
279
|
+
* re-deriving here.
|
|
280
|
+
*/
|
|
281
|
+
function rebaseOnLabel (logic, name) {
|
|
282
|
+
if (Array.isArray(logic)) {
|
|
283
|
+
const out = new Array(logic.length)
|
|
284
|
+
for (let i = 0; i < logic.length; i++) {
|
|
285
|
+
const value = rebaseOnLabel(logic[i], name)
|
|
286
|
+
if (value === BAIL) return BAIL
|
|
287
|
+
out[i] = value
|
|
288
|
+
}
|
|
289
|
+
return out
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!logic || typeof logic !== 'object') return logic
|
|
293
|
+
|
|
294
|
+
const keys = Object.keys(logic)
|
|
295
|
+
if (keys.length === 1 && keys[0] === 'val') {
|
|
296
|
+
const path = logic.val
|
|
297
|
+
if (path === name) return { val: [] }
|
|
298
|
+
if (Array.isArray(path) && path[0] === name) {
|
|
299
|
+
const rest = path.slice(1)
|
|
300
|
+
return { val: rest.length === 1 ? rest[0] : rest }
|
|
301
|
+
}
|
|
302
|
+
return BAIL
|
|
303
|
+
}
|
|
304
|
+
if (keys.length === 1 && keys[0] === 'var') return BAIL
|
|
305
|
+
|
|
306
|
+
const out = {}
|
|
307
|
+
for (const key of keys) {
|
|
308
|
+
const value = rebaseOnLabel(logic[key], name)
|
|
309
|
+
if (value === BAIL) return BAIL
|
|
310
|
+
out[key] = value
|
|
311
|
+
}
|
|
312
|
+
return out
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Runs a fixpoint over the grammar so recursive rules converge.
|
|
317
|
+
* @param {Record<string, any>} grammar
|
|
318
|
+
* @returns {Map<string, First>}
|
|
319
|
+
*/
|
|
320
|
+
function computeFirsts (grammar) {
|
|
321
|
+
/** @type {Map<string, First>} */
|
|
322
|
+
const ruleFirsts = new Map()
|
|
323
|
+
for (const name of Object.keys(grammar)) ruleFirsts.set(name, emptyFirst())
|
|
324
|
+
|
|
325
|
+
let changed = true
|
|
326
|
+
while (changed) {
|
|
327
|
+
changed = false
|
|
328
|
+
for (const name of Object.keys(grammar)) {
|
|
329
|
+
const current = ruleFirsts.get(name)
|
|
330
|
+
const computed = firstOf(grammar[name], ruleFirsts)
|
|
331
|
+
if (absorb(current, computed)) changed = true
|
|
332
|
+
if (computed.nullable && !current.nullable) {
|
|
333
|
+
current.nullable = true
|
|
334
|
+
changed = true
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return ruleFirsts
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Collects the rules a node can invoke without having consumed anything first.
|
|
344
|
+
* Used to find left recursion.
|
|
345
|
+
* @param {*} node
|
|
346
|
+
* @param {Map<string, First>} ruleFirsts
|
|
347
|
+
* @param {Set<string>} out
|
|
348
|
+
*/
|
|
349
|
+
function leftmostCalls (node, ruleFirsts, out) {
|
|
350
|
+
const op = opOf(node)
|
|
351
|
+
if (op === null) return
|
|
352
|
+
const arg = node[op]
|
|
353
|
+
|
|
354
|
+
switch (op) {
|
|
355
|
+
case 'subrule': {
|
|
356
|
+
const name = Array.isArray(arg) ? arg[0] : arg
|
|
357
|
+
out.add(name)
|
|
358
|
+
break
|
|
359
|
+
}
|
|
360
|
+
case 'label':
|
|
361
|
+
leftmostCalls(arg[1], ruleFirsts, out)
|
|
362
|
+
break
|
|
363
|
+
case 'text':
|
|
364
|
+
case 'many':
|
|
365
|
+
case 'many1':
|
|
366
|
+
case 'option':
|
|
367
|
+
case 'lookahead':
|
|
368
|
+
case 'negLookahead':
|
|
369
|
+
leftmostCalls(arg, ruleFirsts, out)
|
|
370
|
+
break
|
|
371
|
+
case 'as':
|
|
372
|
+
leftmostCalls(Array.isArray(arg) ? arg[0] : arg && arg.parser, ruleFirsts, out)
|
|
373
|
+
break
|
|
374
|
+
case 'between':
|
|
375
|
+
if (arg) leftmostCalls({ seq: [arg.open, arg.body, arg.close] }, ruleFirsts, out)
|
|
376
|
+
break
|
|
377
|
+
case 'prefix':
|
|
378
|
+
if (arg) {
|
|
379
|
+
leftmostCalls(arg.operator, ruleFirsts, out)
|
|
380
|
+
leftmostCalls(arg.operand, ruleFirsts, out)
|
|
381
|
+
}
|
|
382
|
+
break
|
|
383
|
+
case 'infixLeft':
|
|
384
|
+
case 'infixRight':
|
|
385
|
+
case 'postfix':
|
|
386
|
+
if (arg) leftmostCalls(arg.operand, ruleFirsts, out)
|
|
387
|
+
break
|
|
388
|
+
case 'manySep':
|
|
389
|
+
case 'many1Sep':
|
|
390
|
+
if (arg) leftmostCalls(arg.rule, ruleFirsts, out)
|
|
391
|
+
break
|
|
392
|
+
case 'alt':
|
|
393
|
+
if (Array.isArray(arg)) for (const child of arg) leftmostCalls(child, ruleFirsts, out)
|
|
394
|
+
break
|
|
395
|
+
case 'seq':
|
|
396
|
+
if (Array.isArray(arg)) {
|
|
397
|
+
for (const child of arg) {
|
|
398
|
+
leftmostCalls(child, ruleFirsts, out)
|
|
399
|
+
// Stop at the first element that must consume something.
|
|
400
|
+
if (!firstOf(child, ruleFirsts).nullable) break
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
break
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Walks every node, reporting unknown rule/token references.
|
|
409
|
+
* @param {*} node
|
|
410
|
+
* @param {Record<string, any>} grammar
|
|
411
|
+
* @param {Set<string>|null} tokenNames
|
|
412
|
+
* @param {string[]} errors
|
|
413
|
+
* @param {string} path
|
|
414
|
+
*/
|
|
415
|
+
function validateNode (node, grammar, tokenNames, errors, path) {
|
|
416
|
+
const op = opOf(node)
|
|
417
|
+
if (op === null) {
|
|
418
|
+
if (Array.isArray(node)) node.forEach((c, i) => validateNode(c, grammar, tokenNames, errors, `${path}[${i}]`))
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
const arg = node[op]
|
|
422
|
+
|
|
423
|
+
switch (op) {
|
|
424
|
+
case 'consume': {
|
|
425
|
+
const name = Array.isArray(arg) ? arg[0] : arg
|
|
426
|
+
if (typeof name !== 'string') errors.push(`${path}: 'consume' expects a token name, got ${JSON.stringify(name)}`)
|
|
427
|
+
else if (tokenNames && !tokenNames.has(name)) errors.push(`${path}: 'consume' references unknown token '${name}'`)
|
|
428
|
+
break
|
|
429
|
+
}
|
|
430
|
+
case 'subrule': {
|
|
431
|
+
const name = Array.isArray(arg) ? arg[0] : arg
|
|
432
|
+
if (typeof name !== 'string') errors.push(`${path}: 'subrule' expects a rule name, got ${JSON.stringify(name)}`)
|
|
433
|
+
else if (!(name in grammar)) errors.push(`${path}: 'subrule' references unknown rule '${name}'`)
|
|
434
|
+
break
|
|
435
|
+
}
|
|
436
|
+
case 'label': {
|
|
437
|
+
if (!Array.isArray(arg) || arg.length !== 2 || typeof arg[0] !== 'string') {
|
|
438
|
+
errors.push(`${path}: 'label' expects [name, parser]`)
|
|
439
|
+
} else validateNode(arg[1], grammar, tokenNames, errors, `${path}.label(${arg[0]})`)
|
|
440
|
+
break
|
|
441
|
+
}
|
|
442
|
+
case 'seq':
|
|
443
|
+
case 'alt': {
|
|
444
|
+
if (!Array.isArray(arg)) errors.push(`${path}: '${op}' expects an array`)
|
|
445
|
+
else {
|
|
446
|
+
if (op === 'alt' && arg.length === 0) errors.push(`${path}: 'alt' needs at least one branch`)
|
|
447
|
+
arg.forEach((c, i) => validateNode(c, grammar, tokenNames, errors, `${path}.${op}[${i}]`))
|
|
448
|
+
}
|
|
449
|
+
break
|
|
450
|
+
}
|
|
451
|
+
case 'many':
|
|
452
|
+
case 'many1':
|
|
453
|
+
case 'option':
|
|
454
|
+
case 'text':
|
|
455
|
+
case 'lookahead':
|
|
456
|
+
case 'negLookahead':
|
|
457
|
+
validateNode(arg, grammar, tokenNames, errors, `${path}.${op}`)
|
|
458
|
+
break
|
|
459
|
+
case 'as': {
|
|
460
|
+
const parser = Array.isArray(arg) ? arg[0] : arg && arg.parser
|
|
461
|
+
if (parser === undefined) errors.push(`${path}: 'as' expects [parser, logic]`)
|
|
462
|
+
else validateNode(parser, grammar, tokenNames, errors, `${path}.as`)
|
|
463
|
+
break
|
|
464
|
+
}
|
|
465
|
+
case 'between': {
|
|
466
|
+
if (!arg || typeof arg !== 'object' || !('open' in arg) || !('body' in arg) || !('close' in arg)) {
|
|
467
|
+
errors.push(`${path}: 'between' expects { open, body, close }`)
|
|
468
|
+
} else {
|
|
469
|
+
validateNode(arg.open, grammar, tokenNames, errors, `${path}.between.open`)
|
|
470
|
+
validateNode(arg.body, grammar, tokenNames, errors, `${path}.between.body`)
|
|
471
|
+
validateNode(arg.close, grammar, tokenNames, errors, `${path}.between.close`)
|
|
472
|
+
}
|
|
473
|
+
break
|
|
474
|
+
}
|
|
475
|
+
case 'prefix': {
|
|
476
|
+
if (!arg || typeof arg !== 'object' || !('operator' in arg) || !('operand' in arg) || !('combine' in arg)) {
|
|
477
|
+
errors.push(`${path}: 'prefix' expects { operator, operand, combine }`)
|
|
478
|
+
} else {
|
|
479
|
+
validateNode(arg.operator, grammar, tokenNames, errors, `${path}.prefix.operator`)
|
|
480
|
+
validateNode(arg.operand, grammar, tokenNames, errors, `${path}.prefix.operand`)
|
|
481
|
+
}
|
|
482
|
+
break
|
|
483
|
+
}
|
|
484
|
+
case 'postfix': {
|
|
485
|
+
if (!arg || typeof arg !== 'object' || !('operand' in arg) || !('suffix' in arg) || !('combine' in arg)) {
|
|
486
|
+
errors.push(`${path}: 'postfix' expects { operand, suffix, combine }`)
|
|
487
|
+
} else {
|
|
488
|
+
validateNode(arg.operand, grammar, tokenNames, errors, `${path}.postfix.operand`)
|
|
489
|
+
validateNode(arg.suffix, grammar, tokenNames, errors, `${path}.postfix.suffix`)
|
|
490
|
+
}
|
|
491
|
+
break
|
|
492
|
+
}
|
|
493
|
+
case 'oneOf': {
|
|
494
|
+
const names = Array.isArray(arg) ? arg : [arg]
|
|
495
|
+
for (const name of names) {
|
|
496
|
+
if (typeof name !== 'string') errors.push(`${path}: 'oneOf' expects token names`)
|
|
497
|
+
else if (tokenNames && !tokenNames.has(name)) errors.push(`${path}: 'oneOf' references unknown token '${name}'`)
|
|
498
|
+
}
|
|
499
|
+
break
|
|
500
|
+
}
|
|
501
|
+
case 'eof':
|
|
502
|
+
break
|
|
503
|
+
case 'infixRight':
|
|
504
|
+
case 'infixLeft': {
|
|
505
|
+
if (!arg || typeof arg !== 'object' || !('operand' in arg) || !('combine' in arg)) {
|
|
506
|
+
errors.push(`${path}: '${op}' expects { operand, combine, operator? }`)
|
|
507
|
+
} else {
|
|
508
|
+
validateNode(arg.operand, grammar, tokenNames, errors, `${path}.${op}.operand`)
|
|
509
|
+
if (arg.operator !== undefined) validateNode(arg.operator, grammar, tokenNames, errors, `${path}.${op}.operator`)
|
|
510
|
+
}
|
|
511
|
+
break
|
|
512
|
+
}
|
|
513
|
+
case 'manySep':
|
|
514
|
+
case 'many1Sep': {
|
|
515
|
+
if (!arg || typeof arg !== 'object' || !('rule' in arg) || !('sep' in arg)) {
|
|
516
|
+
errors.push(`${path}: '${op}' expects { rule, sep }`)
|
|
517
|
+
} else {
|
|
518
|
+
validateNode(arg.rule, grammar, tokenNames, errors, `${path}.${op}.rule`)
|
|
519
|
+
if (typeof arg.sep === 'string') {
|
|
520
|
+
if (tokenNames && !tokenNames.has(arg.sep)) errors.push(`${path}: '${op}' separator references unknown token '${arg.sep}'`)
|
|
521
|
+
} else validateNode(arg.sep, grammar, tokenNames, errors, `${path}.${op}.sep`)
|
|
522
|
+
}
|
|
523
|
+
break
|
|
524
|
+
}
|
|
525
|
+
// Everything else is a value expression; the logic engine validates it.
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* @param {Record<string, any>} grammar
|
|
531
|
+
* @param {{ tokenNames?: string[] }} [options]
|
|
532
|
+
* @returns {{ firsts: Map<string, First>, errors: string[], leftRecursive: string[] }}
|
|
533
|
+
*/
|
|
534
|
+
export function analyze (grammar, options = {}) {
|
|
535
|
+
const errors = []
|
|
536
|
+
const tokenNames = options.tokenNames ? new Set(options.tokenNames) : null
|
|
537
|
+
|
|
538
|
+
for (const name of Object.keys(grammar)) {
|
|
539
|
+
validateNode(grammar[name], grammar, tokenNames, errors, name)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const firsts = computeFirsts(grammar)
|
|
543
|
+
|
|
544
|
+
// A rule is left-recursive when it can reach itself without consuming a token.
|
|
545
|
+
const leftEdges = new Map()
|
|
546
|
+
for (const name of Object.keys(grammar)) {
|
|
547
|
+
const out = new Set()
|
|
548
|
+
leftmostCalls(grammar[name], firsts, out)
|
|
549
|
+
leftEdges.set(name, out)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const leftRecursive = []
|
|
553
|
+
for (const start of Object.keys(grammar)) {
|
|
554
|
+
const seen = new Set()
|
|
555
|
+
const stack = [...(leftEdges.get(start) || [])]
|
|
556
|
+
while (stack.length) {
|
|
557
|
+
const next = stack.pop()
|
|
558
|
+
if (next === start) {
|
|
559
|
+
leftRecursive.push(start)
|
|
560
|
+
break
|
|
561
|
+
}
|
|
562
|
+
if (seen.has(next)) continue
|
|
563
|
+
seen.add(next)
|
|
564
|
+
for (const edge of leftEdges.get(next) || []) stack.push(edge)
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
for (const name of leftRecursive) {
|
|
569
|
+
errors.push(`rule '${name}' is left-recursive; this parser is LL-style and would not terminate. Rewrite it with 'many'/'many1Sep' (the standard left-recursion elimination).`)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return { firsts, errors, leftRecursive }
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Builds an O(1) dispatch table for an `alt`, or null when the branches are not
|
|
577
|
+
* LL(1)-separable and we must fall back to ordered backtracking.
|
|
578
|
+
*
|
|
579
|
+
* @param {any[]} branches
|
|
580
|
+
* @param {Map<string, First>} firsts
|
|
581
|
+
* @returns {Map<string, any>|null}
|
|
582
|
+
*/
|
|
583
|
+
export function buildDispatch (branches, firsts) {
|
|
584
|
+
/** @type {Map<string, any>} */
|
|
585
|
+
const table = new Map()
|
|
586
|
+
|
|
587
|
+
for (const branch of branches) {
|
|
588
|
+
const first = firstOf(branch, firsts)
|
|
589
|
+
// A nullable or opaque branch could match anything, so no table is safe.
|
|
590
|
+
if (first.nullable || first.unknown || first.tokens.size === 0) return null
|
|
591
|
+
for (const token of first.tokens) {
|
|
592
|
+
// Overlapping FIRST sets mean the choice needs more than one token of
|
|
593
|
+
// lookahead; ordered backtracking still handles it correctly.
|
|
594
|
+
if (table.has(token)) return null
|
|
595
|
+
table.set(token, branch)
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return table.size > 0 ? table : null
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export { firstOf }
|
package/src/codegen.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A real source generator for grammars.
|
|
3
|
+
*
|
|
4
|
+
* The planner removes per-token *decisions*, but it still pays one indirect
|
|
5
|
+
* call per grammar node, through call sites that see many closure shapes and so
|
|
6
|
+
* cannot be inlined. That is the floor for a closure tree, and it is where the
|
|
7
|
+
* remaining gap against hand-written parsers lives.
|
|
8
|
+
*
|
|
9
|
+
* This module emits one JavaScript function per rule with every node inlined as
|
|
10
|
+
* straight-line statements — the shape a hand-written recursive-descent parser
|
|
11
|
+
* has. `alt` becomes a real `switch` on the token type, which V8 compiles to a
|
|
12
|
+
* jump table rather than a Map lookup. Only rule boundaries remain calls.
|
|
13
|
+
*
|
|
14
|
+
* Anything it does not know how to emit falls back to the planner's closure via
|
|
15
|
+
* the helper table, so adding an operator here is optional and incremental.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* @param {{ planner: any, rules: Record<string, any>, firsts: Map<string, any>|null,
|
|
19
|
+
* memo: boolean, maxSteps: number }} config
|
|
20
|
+
*/
|
|
21
|
+
export function createCodegen(config: {
|
|
22
|
+
planner: any;
|
|
23
|
+
rules: Record<string, any>;
|
|
24
|
+
firsts: Map<string, any> | null;
|
|
25
|
+
memo: boolean;
|
|
26
|
+
maxSteps: number;
|
|
27
|
+
}): {
|
|
28
|
+
compile: (start: string) => (state: any) => any;
|
|
29
|
+
functionFor: (name: any) => any;
|
|
30
|
+
sourceFor: (name: any) => string;
|
|
31
|
+
ruleIndex: Map<any, any>;
|
|
32
|
+
RULES: any[];
|
|
33
|
+
SOURCES: any[];
|
|
34
|
+
ORIGINS: any[];
|
|
35
|
+
guard: boolean;
|
|
36
|
+
};
|