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/src/emit.js ADDED
@@ -0,0 +1,374 @@
1
+ // @ts-check
2
+ 'use strict'
3
+
4
+ import { createLexer } from './lexer.js'
5
+ import { analyze } from './analyze.js'
6
+ import { createPlanner } from './plan.js'
7
+ import { createCodegen } from './codegen.js'
8
+ import { usesOuterScope } from './plan.js'
9
+ import { emitMethodsBlock, evaluateMethodsBlock } from './methodsblock.js'
10
+ import { createValueMethods } from './values.js'
11
+ import { Compiler, LogicEngine } from 'json-logic-engine'
12
+
13
+ /**
14
+ * Things the engine's compiler can emit that a generated file has no way to
15
+ * provide: its coercion helpers, its `truthy`, closures captured by a compile
16
+ * hook (`reduce` and friends), and raw logic kept for lazy operators. An action
17
+ * whose source touches any of them keeps its `action({...})` wrapper.
18
+ *
19
+ * `engine.methods` is deliberately *not* here — it is only a lookup table for
20
+ * the semantic methods the caller registers, and a generated file can hold one
21
+ * of those itself.
22
+ */
23
+ const UNSUPPORTED = /\b(coerceArray|precoerceNumber|compareCheck|assertAllowedDepth|assertSize|asyncIterators|notTraversed)\b|(?<!engine\.)\bmethods\[|engine\.(?!methods\b)/
24
+
25
+ /**
26
+ * Tries to compile one action to inlinable source.
27
+ *
28
+ * Returns null when it cannot, which is always safe: the caller falls back to
29
+ * shipping the logic as data and letting the engine compile it at import.
30
+ */
31
+ function inlineAction (logic, engine) {
32
+ try {
33
+ // `build` populates the buildState defaults (notably `compile`); a fresh
34
+ // state then collects exactly what this one action needs.
35
+ const seed = { engine }
36
+ Compiler.build(logic, seed)
37
+ const state = {
38
+ engine, compile: seed.compile, values: [], methods: [], notTraversed: [], processing: [], state: {}
39
+ }
40
+ const source = Compiler.buildString(logic, state)
41
+
42
+ if (state.methods.length || state.notTraversed.length || state.processing.length) return null
43
+ if (UNSUPPORTED.test(source)) return null
44
+
45
+ // Constants the compiler hoisted. Emittable only if they are really data.
46
+ let values = null
47
+ if (state.values.length > 0) {
48
+ values = JSON.stringify(state.values)
49
+ if (JSON.parse(values) === undefined) return null
50
+ if (JSON.stringify(JSON.parse(values)) !== values) return null
51
+ }
52
+
53
+ return {
54
+ source,
55
+ values,
56
+ needsMethods: source.includes('engine.methods'),
57
+ needsAbove: /\babove\b/.test(source)
58
+ }
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+ import { GrammarError } from './parser.js'
64
+
65
+ /**
66
+ * The block's methods, or null when it cannot be evaluated here — an importing
67
+ * block, or a host with no `new Function`. Failing is not fatal: the actions
68
+ * that would have been compiled ship as JSON Logic instead.
69
+ */
70
+ function tryEvaluate (body) {
71
+ try {
72
+ return evaluateMethodsBlock(body).methods
73
+ } catch {
74
+ return null
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Writes a grammar out as a JavaScript module, the way Peggy emits a parser.
80
+ *
81
+ * The generated file contains the same rule functions `createParser` would build
82
+ * at run time, as source. What that buys is not speed — it is the same code —
83
+ * but everything around it: the grammar is analyzed once, at build time, so
84
+ * there is no FIRST-set computation or planning at startup; the file is a
85
+ * reviewable, diffable artifact; and nothing calls `new Function`, so it runs
86
+ * under a Content-Security-Policy without `unsafe-eval`.
87
+ *
88
+ * The file imports `grammatik/runtime` for the lexer and parse state, and
89
+ * `json-logic-engine` for the semantic actions, which stay as JSON Logic data
90
+ * in the file and are compiled at import. Actions can be interpreted instead
91
+ * with `execution: 'interpreted'`, which keeps the whole module eval-free.
92
+ */
93
+
94
+ const quote = (value) => JSON.stringify(value)
95
+
96
+ /** Pretty-prints data with a stable indent, for readability in the output. */
97
+ const literal = (value, indent = 0) => JSON.stringify(value, null, 2)
98
+ .split('\n')
99
+ .join('\n' + ' '.repeat(indent))
100
+
101
+ /**
102
+ * @param {import('./parser.js').GrammarSpec} spec
103
+ * @param {{ memo?: boolean, strict?: boolean, ll1?: boolean, maxSteps?: number,
104
+ * positions?: 'full'|'offset', execution?: 'generated'|'interpreted',
105
+ * format?: 'esm'|'cjs', validate?: boolean, moduleName?: string,
106
+ * runtimeSpecifier?: string, engineSpecifier?: string,
107
+ * methods?: Record<string, any> }} [options]
108
+ * @returns {string} JavaScript module source
109
+ */
110
+ export function emitModule (spec, options = {}) {
111
+ const { tokens: tokenDefs, rules, start } = spec
112
+ const {
113
+ memo = false,
114
+ strict = true,
115
+ ll1 = true,
116
+ maxSteps = 0,
117
+ positions = 'full',
118
+ execution = 'generated',
119
+ format = 'esm',
120
+ validate = true,
121
+ moduleName = spec.name || 'parser',
122
+ runtimeSpecifier = 'grammatik/runtime',
123
+ engineSpecifier = 'json-logic-engine',
124
+ methods: semanticMethods
125
+ } = options
126
+
127
+ if (format !== 'esm' && format !== 'cjs') throw new Error(`emitModule: format must be 'esm' or 'cjs', got ${quote(format)}`)
128
+
129
+ // A `methods { ... }` block travels with the grammar and is written into the
130
+ // output as source. Nothing evaluates it here, so this path stays eval-free.
131
+ const methodsBlock = options.methodsBlock !== undefined ? options.methodsBlock : spec.methodsBlock
132
+ const block = methodsBlock ? emitMethodsBlock(methodsBlock, format) : null
133
+ if (!start || !(start in rules)) throw new Error(`emitModule: start rule '${start}' is not defined in rules`)
134
+
135
+ const lexer = createLexer(tokenDefs, { positions })
136
+ const analysis = analyze(rules, { tokenNames: lexer.tokenNames })
137
+ if (validate && analysis.errors.length > 0) throw new GrammarError(analysis.errors)
138
+
139
+ // The planner is needed only so codegen can record what each helper is; the
140
+ // closures it makes are thrown away.
141
+ const firsts = ll1 ? analysis.firsts : null
142
+ const engine = { build: () => { throw new Error('not compiled during emit') }, run: () => {} }
143
+ const planner = createPlanner({ engine, rules, firsts, memo, maxSteps, unsafeEval: false })
144
+ const codegen = createCodegen({ planner, rules, firsts, memo, maxSteps })
145
+
146
+ codegen.compile(start)
147
+
148
+ const reached = [...codegen.ruleIndex.entries()].sort((a, b) => a[1] - b[1])
149
+ const unsupported = codegen.ORIGINS
150
+ .map((origin, index) => ({ origin, index }))
151
+ .filter(({ origin }) => origin.kind === 'unknownRule')
152
+ if (unsupported.length > 0) {
153
+ const names = unsupported.map(({ origin }) => origin.name).join(', ')
154
+ throw new Error(`emitModule: cannot emit a grammar with unknown rules (${names})`)
155
+ }
156
+
157
+ // Each action is compiled to source where possible and shipped as data where
158
+ // not. An action that climbs to the parser state always stays interpreted:
159
+ // the engine does not thread `above` into built functions.
160
+ //
161
+ // Compiling an action that calls a method needs that method to exist, so a
162
+ // grammar carrying its own methods block is evaluated here — the functions are
163
+ // used only to compile, never emitted. A block that imports cannot be
164
+ // evaluated in place; the CLI resolves those as a module and passes them in,
165
+ // and without that the actions simply ship as data.
166
+ const blockMethods = block && !semanticMethods ? tryEvaluate(methodsBlock) : null
167
+ const compileEngine = new LogicEngine()
168
+ for (const [name, method] of Object.entries(createValueMethods())) compileEngine.addMethod(name, method)
169
+ for (const [name, method] of Object.entries(semanticMethods || blockMethods || {})) compileEngine.addMethod(name, method)
170
+
171
+ let needsEngine = execution === 'interpreted'
172
+ let needsAbove = false
173
+
174
+ const emitAction = (logic) => {
175
+ if (usesOuterScope(logic) || execution === 'interpreted') {
176
+ needsEngine = true
177
+ return `interpretedAction(${literal(logic, 2)})`
178
+ }
179
+ const inlined = inlineAction(logic, compileEngine)
180
+ if (!inlined) {
181
+ needsEngine = true
182
+ return `action(${literal(logic, 2)})`
183
+ }
184
+ if (inlined.needsAbove) needsAbove = true
185
+ const body = `(context) => { let prev; return ${inlined.source} }`
186
+ return inlined.values ? `((values) => ${body})(${inlined.values})` : body
187
+ }
188
+
189
+ const helpers = codegen.ORIGINS.map((origin) => {
190
+ if (origin.kind === 'constant') return `constant(${literal(origin.node, 2)})`
191
+ if (origin.kind === 'action') return emitAction(origin.logic)
192
+ if (origin.kind === 'valueExpression') return `valueExpression(${emitAction(origin.logic)})`
193
+ throw new Error(`emitModule: unsupported helper (${origin.kind})`)
194
+ })
195
+
196
+ // With every action inlined, all that is left of the engine is a table of the
197
+ // caller's own semantic methods — which the file can hold itself.
198
+ const engineFree = !needsEngine
199
+
200
+ const ruleSources = reached.map(([name, index]) =>
201
+ `// ${name}\nR[${index}] = ${codegen.SOURCES[index]}`
202
+ ).join('\n\n')
203
+
204
+ const interpretedActions = execution === 'interpreted'
205
+
206
+ // The two formats differ only in how the file opens and closes; every rule,
207
+ // helper and action between them is the same source.
208
+ const cjs = format === 'cjs'
209
+ // A module that still has an engine runs some actions through it, and those
210
+ // actions may use the value methods, so it has to register them the way
211
+ // `createParser` does. They come from the runtime rather than being written
212
+ // out here, so there is one implementation rather than two.
213
+ const runtimeImports = ['FAIL as F', 'expect as E', 'createState', 'buildParseError', 'createLexer']
214
+ if (needsEngine) runtimeImports.push('createValueMethods')
215
+
216
+ const importRuntime = cjs
217
+ ? `const { ${runtimeImports.map((b) => b.replace(' as ', ': ')).join(', ')} } = require(${quote(runtimeSpecifier)})`
218
+ : `import { ${runtimeImports.join(', ')} } from ${quote(runtimeSpecifier)}`
219
+ const importEngine = engineFree
220
+ ? ''
221
+ : cjs
222
+ ? `const { LogicEngine } = require(${quote(engineSpecifier)})\n`
223
+ : `import { LogicEngine } from ${quote(engineSpecifier)}\n`
224
+
225
+ const PUBLIC = ['parse', 'parseTokens', 'tokenize', 'tokens', 'start', 'registerMethods']
226
+
227
+ // A block's named exports come out of the generated parser too: a grammar that
228
+ // needs a helper on the host side can ship it rather than make the caller keep
229
+ // a matching file in sync.
230
+ for (const name of block ? block.names : []) {
231
+ if (PUBLIC.includes(name)) throw new Error(`emitModule: the methods block exports '${name}', which the generated parser already exports`)
232
+ }
233
+ const exported = block ? [...PUBLIC, ...block.names] : PUBLIC
234
+
235
+ const exports = cjs
236
+ ? `module.exports = { ${exported.join(', ')} }\n// Named the same under \`default\`, so an \`import parser from\` interops.\nmodule.exports.default = module.exports`
237
+ : `export { ${exported.join(', ')} }\nexport default { ${PUBLIC.join(', ')} }`
238
+
239
+ return `// Generated by grammatik from the ${quote(moduleName)} grammar. Do not edit.
240
+ //
241
+ // The grammar was analyzed when this file was written: FIRST sets, left-recursion
242
+ // checks and rule specialization all happened then. Importing it does no grammar
243
+ // work and generates no code.
244
+ ${engineFree
245
+ ? '// Every semantic action was compiled to source too, so this file has no\n// dependency on json-logic-engine at all.'
246
+ : interpretedActions
247
+ ? '// Semantic actions are interpreted, so nothing here calls `eval`.'
248
+ : '// Semantic actions that could not be compiled to source are shipped as JSON\n// Logic and built by json-logic-engine when this module loads.'}
249
+
250
+ ${importRuntime}
251
+ ${importEngine}
252
+
253
+ const tokens = ${literal(tokenDefs)}
254
+
255
+ const start = ${quote(start)}
256
+
257
+ const lexer = createLexer(tokens, { positions: ${quote(positions)} })
258
+ const tokenize = lexer.tokenize
259
+
260
+ ${engineFree
261
+ ? `// Every semantic action compiled to source, so the engine reduced to a table
262
+ // of the methods below. Nothing here imports json-logic-engine.
263
+ const M = Object.create(null)
264
+ const engine = { methods: M }
265
+ ${needsAbove ? 'const above = []\n' : ''}
266
+ /**
267
+ * Semantic methods the grammar refers to. Pass the same object you would give
268
+ * \`createParser\`; anything the actions call must be registered before use.
269
+ */
270
+ function registerMethods (methods) {
271
+ for (const [name, method] of Object.entries(methods || {})) {
272
+ M[name] = typeof method === 'function' ? { method } : method
273
+ }
274
+ return engine
275
+ }`
276
+ : `const engine = new LogicEngine()
277
+ for (const [name, method] of Object.entries(createValueMethods())) engine.addMethod(name, method)
278
+ ${needsAbove ? 'const above = []\n' : ''}
279
+ /**
280
+ * Semantic methods the grammar refers to. Pass the same object you would give
281
+ * \`createParser\`; anything the actions call must be registered before use.
282
+ */
283
+ function registerMethods (methods) {
284
+ for (const [name, method] of Object.entries(methods || {})) engine.addMethod(name, method)
285
+ return engine
286
+ }`}
287
+
288
+ ${block
289
+ ? `// ---------------------------------------------------------------------------
290
+ // The grammar's \`methods { ... }\` block, written out as source. It is registered
291
+ // below, so importing this module is enough — no wiring at the call site.
292
+ // ---------------------------------------------------------------------------
293
+
294
+ ${block.source}
295
+
296
+ registerMethods(${block.table})
297
+ `
298
+ : ''}
299
+ ${engineFree ? '' : `const interpretedAction = (logic) => (bindings, state) => engine.run(logic, bindings, { above: [state] })
300
+ `}
301
+ ${engineFree
302
+ ? ''
303
+ : interpretedActions
304
+ ? 'const action = interpretedAction'
305
+ : `const action = (logic) => {
306
+ // Compiled on first use, so \`registerMethods\` can run first.
307
+ let fn
308
+ return (bindings, state) => {
309
+ if (fn === undefined) {
310
+ try {
311
+ const built = engine.build(logic)
312
+ fn = typeof built === 'function' ? (b) => built(b) : () => built
313
+ } catch {
314
+ fn = (b, s) => engine.run(logic, b, { above: [s] })
315
+ }
316
+ }
317
+ return fn(bindings, state)
318
+ }
319
+ }`}
320
+
321
+ /** A JSON Logic value expression sitting in parser position. */
322
+ const valueExpression = (run) => (state) => run(state.bindings || {}, state)
323
+ const constant = (value) => () => value
324
+
325
+ const P = (op, at) => {
326
+ throw new Error(\`'\${op}' matched without consuming any input at token index \${at}; this would loop forever.\`)
327
+ }
328
+ const T = () => { throw new Error('Parser exceeded maxSteps (${maxSteps}); the grammar is likely looping.') }
329
+
330
+ /** Rule functions, indexed. Filled below; recursion resolves through the array. */
331
+ const R = []
332
+
333
+ /** Values the rules close over: semantic actions and literals. */
334
+ const H = [
335
+ ${helpers.map((source, index) => ` /* ${index} */ ${source}`).join(',\n')}
336
+ ]
337
+
338
+ ${ruleSources}
339
+
340
+ const RULES = ${literal(Object.fromEntries(reached.map(([name, index]) => [name, index])))}
341
+
342
+ /**
343
+ * Parses a token stream you lexed yourself.
344
+ * @param {any[]} tokenList
345
+ * @param {string} [source]
346
+ */
347
+ function parseTokens (tokenList, source, track = false) {
348
+ const state = createState(tokenList, {}, { memo: ${memo}, maxSteps: ${maxSteps}, track })
349
+ state.source = source
350
+ state.positions = ${quote(positions)}
351
+ state.ruleNames[0] = start
352
+ state.ruleDepth = 1
353
+
354
+ const result = R[RULES[start]](state)
355
+ const trailing = ${strict ? 'state.idx < tokenList.length' : 'false'}
356
+ if (result !== F && !trailing) return result
357
+
358
+ if (!track) return parseTokens(tokenList, source, true)
359
+ if (trailing && state.maxIdx < state.idx) {
360
+ state.maxIdx = state.idx
361
+ state.expected = ['end of input']
362
+ state.failureStack = state.ruleNames.slice(0, state.ruleDepth)
363
+ }
364
+ throw buildParseError(state)
365
+ }
366
+
367
+ /** Lexes and parses text. @param {string} text */
368
+ function parse (text) {
369
+ return parseTokens(lexer.tokenize(text), text)
370
+ }
371
+
372
+ ${exports}
373
+ `
374
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @param {string} source Regex source text.
3
+ * @param {boolean} ignoreCase
4
+ * @returns {Set<number>|null} Possible first char codes, or null if undetermined.
5
+ */
6
+ export function firstCharSet(source: string, ignoreCase: boolean): Set<number> | null;