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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jesse Mitchell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,443 @@
1
+ # grammatik
2
+
3
+ A parser toolkit whose grammars are data. Tokens, rules and semantic actions are
4
+ all JSON Logic, so a grammar can be stored in a database, shipped over the wire,
5
+ diffed, generated, or edited by someone who is not allowed to run code.
6
+
7
+ Semantic actions are evaluated by
8
+ [`json-logic-engine`](https://github.com/TotalTechGeek/json-logic-engine); the
9
+ parser itself is specialized ahead of time.
10
+
11
+ ```js
12
+ import { createParser } from 'grammatik'
13
+
14
+ const parser = createParser({
15
+ tokens: [
16
+ { name: 'WS', pattern: '\\s+', skip: true },
17
+ { name: 'Int', pattern: '\\d+' },
18
+ { name: 'Plus', literal: '+' }
19
+ ],
20
+ rules: {
21
+ sum: {
22
+ infixLeft: {
23
+ operand: { as: [{ consume: 'Int' }, { '+': [{ val: 'image' }, 0] }] },
24
+ operator: { consume: 'Plus' },
25
+ combine: { '+': [{ val: 'left' }, { val: 'right' }] }
26
+ }
27
+ }
28
+ },
29
+ start: 'sum'
30
+ })
31
+
32
+ parser.parse('1 + 2 + 3') // 6
33
+ ```
34
+
35
+ ## Install
36
+
37
+ ```
38
+ npm install grammatik json-logic-engine
39
+ ```
40
+
41
+ ## A grammar
42
+
43
+ A grammar is `{ tokens, rules, start }`.
44
+
45
+ **Tokens** are matched in declaration order at each position:
46
+
47
+ | field | meaning |
48
+ | --- | --- |
49
+ | `name` | the token type |
50
+ | `pattern` | a regex source string, matched sticky at the cursor |
51
+ | `literal` | a literal string; cheaper than a pattern, prefer it |
52
+ | `skip` | consume but produce no token (whitespace, comments) |
53
+ | `ignoreCase` | case-insensitive matching |
54
+ | `longerAlt` | defer to another token when it matches more text — `iffy` should lex as an identifier, not `if` + `fy` |
55
+ | `mode` / `modes` | the lexer mode(s) this token belongs to; default `'default'` |
56
+ | `pushMode` / `popMode` | enter or leave a mode after the token matches |
57
+
58
+ ### Lexer modes
59
+
60
+ Some languages mean different things by the same character depending on where
61
+ they are. In a template, whitespace outside `{{ }}` is literal text and
62
+ whitespace inside it is noise. A token belongs to one or more modes and only
63
+ competes while one of them is current:
64
+
65
+ ```js
66
+ const tokens = [
67
+ { name: 'Text', pattern: '(?:[^{]|\\{(?!\\{))+', mode: 'default' },
68
+ { name: 'Open', literal: '{{', mode: 'default', pushMode: 'expression' },
69
+ { name: 'Close', literal: '}}', mode: 'expression', popMode: true },
70
+ { name: 'WS', pattern: '\\s+', mode: 'expression', skip: true },
71
+ { name: 'Name', pattern: '[A-Za-z_][A-Za-z0-9_.]*', mode: 'expression' }
72
+ ]
73
+
74
+ createLexer(tokens).tokenize('hi {{ name }} there')
75
+ // Text('hi ') Open Name('name') Close Text(' there')
76
+ ```
77
+
78
+ The stack starts fresh at `'default'` on every `tokenize` call, so a lexer is
79
+ reusable and a failure leaves nothing behind. Skipped tokens transition too — a
80
+ comment opener can push a mode without producing anything. When `longerAlt`
81
+ changes which token wins, the winner's transition is the one that applies.
82
+
83
+ Reaching the end of input inside a pushed mode is deliberately *not* a lexer
84
+ error: the tokens are all valid, and the parser reports the missing closer far
85
+ better than the lexer could. Popping the initial mode is a `LexError`.
86
+
87
+ Refused when the lexer is built: a token that both pushes and pops (split it in
88
+ two), a push to a mode no token belongs to, and a `longerAlt` that is not active
89
+ in some mode where it could win.
90
+
91
+ A grammar that declares no modes never allocates a stack and takes exactly the
92
+ path it did before modes existed.
93
+
94
+ Modes are what makes a hand-written scanner unnecessary rather than merely
95
+ inconvenient. Handlebars is the worked example: four modes — text, `{{ }}`,
96
+ `{{{ }}}`, and `{{# }}` for the block opener that swallows the newline after it —
97
+ replace a 200-line scanner with token definitions that survive `JSON.stringify`.
98
+ A generic lexer does pay for that generality: measured against the bespoke
99
+ scanner it replaced, tokenization runs at roughly 0.7x.
100
+
101
+ **Rules** map a name to a parser, and may reference each other with `subrule`,
102
+ including recursively. Left recursion is rejected when the parser is built:
103
+
104
+ ```
105
+ Invalid grammar:
106
+ - rule 'expr' is left-recursive; this parser is LL-style and would not
107
+ terminate. Rewrite it with 'many'/'many1Sep' (the standard left-recursion
108
+ elimination).
109
+ ```
110
+
111
+ ## Operators
112
+
113
+ | Operator | Form | Meaning |
114
+ | --- | --- | --- |
115
+ | `consume` | `{"consume": "Token"}` | Match one token; yields the token object. |
116
+ | `oneOf` | `{"oneOf": ["Plus", "Minus"]}` | Any of a set of token types. |
117
+ | `eof` | `{"eof": null}` | Matches only at end of input, consuming nothing. |
118
+ | `epsilon` | `{"epsilon": null}` | Matches, consuming nothing. |
119
+ | `seq` | `{"seq": [...]}` | Ordered concatenation; rolls back on failure. |
120
+ | `alt` | `{"alt": [...]}` | Ordered choice. |
121
+ | `many` | `{"many": p}` | Zero or more. |
122
+ | `many1` | `{"many1": p}` | One or more. |
123
+ | `option` | `{"option": p}` | Zero or one; yields `null` when absent. |
124
+ | `manySep` | `{"manySep": {"rule": p, "sep": "Comma", "trailing": false}}` | Separated list. |
125
+ | `many1Sep` | as above | Separated list, at least one. |
126
+ | `between` | `{"between": {"open": p, "body": p, "close": p}}` | Bracketed; yields the body. |
127
+ | `infixLeft` | `{"infixLeft": {"operand": p, "operator": p, "combine": logic}}` | One left-associative precedence level; `combine` sees `left`/`op`/`right`. |
128
+ | `infixRight` | as above | Right-associative: `a ^ b ^ c` is `a ^ (b ^ c)`. |
129
+ | `prefix` | `{"prefix": {"operator": p, "operand": p, "combine": logic}}` | Prefix operators, folded right; `combine` sees `op`/`operand`. |
130
+ | `postfix` | `{"postfix": {"operand": p, "suffix": p, "combine": logic}}` | Operand plus suffixes; `combine` sees `left`/`suffix`. |
131
+ | `subrule` | `{"subrule": "name"}` | Invoke a named rule. |
132
+ | `label` | `{"label": ["n", p]}` | Bind a result for `action`. |
133
+ | `action` | `{"action": logic}` | Semantic action over the bindings; consumes nothing. |
134
+ | `as` | `{"as": [p, logic]}` | Parse, then transform. The parsed value *is* the logic's context. |
135
+ | `lookahead` | `{"lookahead": p}` | Positive lookahead. |
136
+ | `negLookahead` | `{"negLookahead": p}` | Negative lookahead. |
137
+ | `text` | `{"text": p}` | Yields the raw source span the parser covered. |
138
+
139
+ A `seq` yields the array of its children's results, unless it contains an
140
+ `action`, in which case it yields that action's value.
141
+
142
+ The transform is `as`, not `map`, deliberately: `map` is an ordinary JSON Logic
143
+ operator, and taking that name would shadow it inside every semantic action.
144
+
145
+ ## Semantic actions
146
+
147
+ Actions are JSON Logic, evaluated against the enclosing sequence's bindings:
148
+
149
+ ```js
150
+ {
151
+ seq: [
152
+ { label: ['a', { consume: 'Int' }] },
153
+ { consume: 'Comma' },
154
+ { label: ['b', { consume: 'Int' }] },
155
+ { action: { '+': [{ val: ['a', 'image'] }, { val: ['b', 'image'] }] } }
156
+ ]
157
+ }
158
+ ```
159
+
160
+ The parser state is one scope up, reachable with `{"val": [[-1], "idx"]}`.
161
+
162
+ ### Building nodes with computed keys
163
+
164
+ The commonest thing a semantic action does is build a node whose *type* comes
165
+ from the input. JSON Logic has no object literal, so that used to mean a
166
+ JavaScript method — `([op, left, right]) => ({ [op]: [left, right] })` — in every
167
+ grammar that had operators. `obj` takes flat key/value pairs and does it as data:
168
+
169
+ ```js
170
+ { obj: [{ val: ['op', 'image'] }, [{ val: 'left' }, { val: 'right' }]] }
171
+ // with op.image === '+' -> { '+': [left, right] }
172
+
173
+ { obj: [{ val: 'k' }, { val: 'v' }, 'kind', 'pair'] } // several entries
174
+ ```
175
+
176
+ It compiles to an object literal, so an action using it stays inlinable and the
177
+ generated parser keeps its engine-free build. That is the whole design
178
+ constraint: `eachKey` already covers static keys, and an operator that silently
179
+ cost a grammar its AOT build would be worse than no operator.
180
+
181
+ Pass `methods` to reach past the base operator set. Declare `optimizeUnary` on
182
+ single-argument methods: it tells the engine not to wrap the evaluated argument,
183
+ which skips an allocation and — for a method whose argument is *itself* an array
184
+ — avoids conflating the argument with the argument list.
185
+
186
+ ```js
187
+ createParser(grammar, {
188
+ methods: {
189
+ fromPairs: { method: (pairs) => Object.fromEntries(pairs), optimizeUnary: true }
190
+ }
191
+ })
192
+ ```
193
+
194
+ ## The grammar-definition language
195
+
196
+ Grammars can also be written in a surface syntax that parses *into* that JSON.
197
+ `examples/formula.gram` is a complete Excel-formula grammar in it, with no
198
+ JavaScript in it. It is the only copy of that grammar — `examples/formula.js`
199
+ loads it and supplies the semantic methods.
200
+
201
+ ```
202
+ grammar Calc;
203
+ start sum;
204
+
205
+ token WS pattern "\\s+" skip;
206
+ token Int pattern "[0-9]+";
207
+ token Plus literal "+";
208
+ // Modes too: `mode` (repeatable), `pushMode <name>`, `popMode`.
209
+ // token Open literal "{{" mode default pushMode expression;
210
+
211
+ rule sum = infixLeft(
212
+ as(consume(Int), action({"+":[{"val":"image"},0]})),
213
+ consume(Plus),
214
+ action({"+":[{"val":"left"},{"val":"right"}]})
215
+ );
216
+ ```
217
+
218
+ ```js
219
+ import { parseDefinition, createParserFromSource } from 'grammatik'
220
+
221
+ const grammar = parseDefinition(source) // plain data, JSON.stringify-able
222
+ const parser = createParserFromSource(source)
223
+ parser.parse('1 + 2 + 3') // 6
224
+ ```
225
+
226
+ Every constructor corresponds to one operator above. The language is itself
227
+ defined by a grammar in this format, and parsed by this library.
228
+
229
+ ### Carrying the methods with the grammar
230
+
231
+ Actions name methods, and those methods have to be real functions somewhere. A
232
+ `.gram` file can hold them itself, in a `methods { ... }` block at the end:
233
+
234
+ ```
235
+ rule sum = infixLeft(
236
+ as(consume(Int), action({"number": {"val": "image"}})),
237
+ consume(Plus),
238
+ action({"add": [{"val": "left"}, {"val": "right"}]})
239
+ );
240
+
241
+ methods {
242
+ export const number = { method: (image) => Number.parseFloat(image), optimizeUnary: true }
243
+ export const add = ([left, right]) => left + right
244
+ }
245
+ ```
246
+
247
+ The block is a module body: its default export is the methods table, or, with no
248
+ default export, its named exports become the table. It is separated from the
249
+ source before the definition language sees it, so `parseDefinition` returns the
250
+ same JSON-serializable grammar it always did, with the block alongside as an
251
+ opaque string — a grammar with a block is still data.
252
+
253
+ `emitModule` writes the block into the generated parser as source and registers
254
+ it there, so importing the file is all the wiring there is. The block's named
255
+ exports are re-exported by the parser too, which is how a grammar can ship a
256
+ helper its host needs.
257
+
258
+ ```js
259
+ import { parse } from './parser.js' // no registerMethods call
260
+ parse('1 + 2 + 3') // 6
261
+ ```
262
+
263
+ `createParserFromSource` evaluates the block with `new Function`, so a block is
264
+ the one part of a `.gram` that needs `eval` at run time — and a block that
265
+ `import`s cannot be evaluated that way at all. Both are build-time features:
266
+ `grammatik generate` resolves an importing block as a real module, and the
267
+ emitted file only ever contains it as source.
268
+
269
+ ## API
270
+
271
+ | | |
272
+ | --- | --- |
273
+ | `createParser(spec, options?)` | Build a parser from a grammar object. |
274
+ | `parseDefinition(source, options?)` | Definition source to a grammar object. |
275
+ | `createParserFromSource(source, options?)` | Definition source straight to a parser. |
276
+ | `createDefinitionParser(options?)` | A parser for the definition language itself. |
277
+ | `splitMethodsBlock(source)` | Definition source to `{ grammar, block }`. |
278
+ | `evaluateMethodsBlock(body)` | A block to its methods table. Needs `new Function`. |
279
+ | `emitModule(spec, options?)` | Write a grammar out as a JavaScript module. |
280
+ | `createLexer(defs, options?)` | The lexer on its own. |
281
+ | `analyze(rules, options?)` | FIRST sets, nullability, left recursion, validation errors. |
282
+
283
+ A parser exposes `parse(text)`, `parseTokens(tokens, source?)`, `tokenize(text)`,
284
+ `execution` and `analysis`.
285
+
286
+ ### Options
287
+
288
+ | Option | Default | Meaning |
289
+ | --- | --- | --- |
290
+ | `methods` | — | Extra JSON Logic methods for semantic actions. |
291
+ | `execution` | `'generated'` | `'generated'` (a JS function per rule) or `'interpreted'` (closures, no code generation). |
292
+ | `positions` | `'full'` | `'full'` records line and column per token; `'offset'` records byte offsets only and skips scanning for newlines. |
293
+ | `memo` | `false` | Packrat memoization on `(rule, position)`. Changes the complexity class on grammars that backtrack; pure overhead on grammars that do not. |
294
+ | `strict` | `true` | Require the whole token stream to be consumed. |
295
+ | `ll1` | `true` | Use FIRST-set dispatch for `alt`. Off forces ordered backtracking. |
296
+ | `validate` | `true` | Throw `GrammarError` on left recursion and bad references. |
297
+ | `maxSteps` | `0` | Runaway guard; `0` disables. |
298
+
299
+ ## Errors
300
+
301
+ Failures report the furthest position reached, not the last alternative tried:
302
+
303
+ ```
304
+ Expecting one of [False, LCurly, LSquare, Null, Number, String, True]
305
+ but found --> ] <-- (RSquare) at line 1 column 4
306
+ ```
307
+
308
+ `ParseError` carries `token`, `expected` and `ruleStack`, the rule nesting at the
309
+ point of failure:
310
+
311
+ ```js
312
+ ['value', 'object', 'pair', 'value', 'array', 'value']
313
+ ```
314
+
315
+ ## Generating a parser file
316
+
317
+ ```
318
+ npx grammatik generate grammar.gram -o parser.js
319
+ ```
320
+
321
+ ```js
322
+ import { parse, registerMethods } from './parser.js'
323
+ parse('5*3+2*5-1')
324
+ ```
325
+
326
+ The file holds the same rule functions `createParser` would build at run time, as
327
+ source. The grammar was analyzed when the file was written, so importing it does
328
+ no grammar work and calls no `new Function`.
329
+
330
+ Pass `--methods ./methods.js` and the emitter compiles the semantic actions to
331
+ source as well. When every action compiles, the `json-logic-engine` import is
332
+ dropped and the file's only dependency is `grammatik/runtime`. Actions that
333
+ cannot be compiled — `reduce`, `map` and `merge` become closures with no source
334
+ to emit — fall back to shipping as data, one at a time.
335
+
336
+ ```
337
+ -o, --out <file> Write here instead of stdout
338
+ --methods <file> Module whose default export is the semantic methods
339
+ --format <format> esm (default) | cjs
340
+ --execution <mode> generated (default) | interpreted
341
+ --positions <mode> full (default) | offset
342
+ --memo Enable packrat memoization
343
+ --no-ll1 Force ordered backtracking
344
+ --max-steps <n> Runaway guard
345
+ ```
346
+
347
+ `--format cjs` emits the same parser as a CommonJS module — `require` for the
348
+ imports, `module.exports` for the surface, and the named exports repeated under
349
+ `default` so an `import parser from` still works. Nothing else about the file
350
+ changes; the rules are the same source either way.
351
+
352
+ ## Running without `eval`
353
+
354
+ Both fast paths generate code: rule functions via `new Function`, and semantic
355
+ actions compiled by json-logic-engine, which builds through `eval`. A
356
+ Content-Security-Policy without `unsafe-eval` forbids both.
357
+
358
+ ```js
359
+ createParser(grammar, { execution: 'interpreted' })
360
+ ```
361
+
362
+ turns off both — same grammars, same results, same error messages. It is also
363
+ cheaper to construct, so it wins when a parser is built to read one short input
364
+ and then discarded.
365
+
366
+ `npm run test:csp` checks this under
367
+ `node --disallow-code-generation-from-strings`, where `eval` and `new Function`
368
+ actually throw.
369
+
370
+ ## Performance
371
+
372
+ Against two hand-written parsers for the same language — one generated by Peggy,
373
+ one written with Chevrotain — on an Excel-formula grammar, all of them emitting
374
+ deep-equal JSON Logic:
375
+
376
+ ```
377
+ 6 rotating formulas 1,393-character formula
378
+ grammatik (runtime) 3.56x grammatik (runtime) 2.93x
379
+ grammatik (generated) 3.18x grammatik (generated) 2.57x
380
+ Chevrotain 1.46x Chevrotain 1.25x
381
+ Peggy 1.00x Peggy 1.00x
382
+ ```
383
+
384
+ On Chevrotain's own JSON benchmark — its grammar, its samples, its rule that
385
+ semantic actions do not count — against the hand-written recogniser it uses as a
386
+ reference point:
387
+
388
+ ```
389
+ 1K sample 10K sample
390
+ grammatik (generated) 1.39x grammatik (generated) 1.40x
391
+ Chevrotain 1.37x Chevrotain 1.34x
392
+ hand-written 1.00x hand-written 1.00x
393
+ Peggy (generated) 0.29x Peggy (generated) 0.24x
394
+ ```
395
+
396
+ `npm run bench:json-parsers` runs it; `bench/jsonParsers/README.md` covers the
397
+ methodology and what is ported from where. Note the margin over Chevrotain is
398
+ much narrower there than below, because that benchmark builds no value — the
399
+ difference between the two is semantic actions.
400
+
401
+ `npm run bench` compares against Chevrotain on JSON; `npm run bench:formula` and
402
+ `npm run bench:generated` run the comparisons above. Both rivals live in this
403
+ repository — the Peggy grammar under `bench/peggy`, generated as part of those
404
+ scripts, and the hand-written Chevrotain parser under `bench/chevrotain` — so
405
+ the numbers are reproducible from a clone. Every benchmark asserts all parsers
406
+ produce identical output before timing anything, and each case runs in its own
407
+ process.
408
+
409
+ ## Development
410
+
411
+ ```
412
+ npm test # 356 tests
413
+ npm run test:csp # the no-eval path, under a code-generation ban
414
+ npm run bench
415
+ npm run build # dist/esm, dist/cjs and dist/types
416
+ ```
417
+
418
+ `npm run build` bundles two entry points with esbuild — the toolkit and the
419
+ runtime — as both ES modules and CommonJS, leaving `json-logic-engine` external.
420
+ The runtime is built separately rather than sliced out of the bundle: a
421
+ generated parser imports it and nothing else, so anything that leaked into it
422
+ would show up in its size immediately.
423
+
424
+ ```
425
+ src/lexer.js Token definitions to a sticky-regex lexer with first-char dispatch
426
+ src/firstchars.js Static analysis of regex sources, for that dispatch
427
+ src/analyze.js FIRST sets, nullability, left recursion, idiom recognition
428
+ src/plan.js Specializes a grammar into closures
429
+ src/codegen.js Generates a JavaScript function per rule
430
+ src/emit.js Writes a grammar out as a module
431
+ src/definition.js The grammar-definition language
432
+ src/methodsblock.js The `methods { ... }` block a .gram can carry
433
+ src/parser.js createParser
434
+ src/runtime.js The surface a generated module imports
435
+ types/ Hand-written declarations, copied to dist/types by the build
436
+ examples/ JSON, arithmetic and Excel-formula grammars, in both notations
437
+ bench/peggy/ The Peggy grammar the formula benchmarks measure against
438
+ bench/chevrotain/ The same formula grammar, hand-written with Chevrotain
439
+ ```
440
+
441
+ ## Licence
442
+
443
+ MIT
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ 'use strict'
4
+
5
+ /**
6
+ * Generates a parser module from a grammar, the way `peggy` does.
7
+ *
8
+ * grammatik generate <grammar.gram|grammar.json> [-o out.js] [options]
9
+ *
10
+ * A `.gram` file is parsed with the grammar-definition language; a `.json` file
11
+ * is read as a grammar object directly.
12
+ */
13
+
14
+ import { readFile, writeFile, unlink } from 'node:fs/promises'
15
+ import path from 'node:path'
16
+ import { pathToFileURL } from 'node:url'
17
+ import { parseDefinition, emitModule } from '../src/index.js'
18
+
19
+ /**
20
+ * Resolves a grammar's `methods { ... }` block to real functions.
21
+ *
22
+ * The block is written out as a module beside the grammar and imported, rather
23
+ * than evaluated with `new Function`, so its relative imports resolve the way
24
+ * the author wrote them. The functions are only used to compile the grammar's
25
+ * actions to source; the block itself is emitted verbatim.
26
+ */
27
+ async function loadMethodsBlock (block, grammarPath) {
28
+ const file = path.join(
29
+ path.dirname(path.resolve(grammarPath)),
30
+ `.grammatik-methods-${process.pid}-${Date.now()}.mjs`
31
+ )
32
+ await writeFile(file, block)
33
+ try {
34
+ const loaded = await import(pathToFileURL(file).href)
35
+ return loaded.default || loaded
36
+ } finally {
37
+ await unlink(file).catch(() => {})
38
+ }
39
+ }
40
+
41
+ const USAGE = `Usage: grammatik generate <grammar.gram|grammar.json> [options]
42
+
43
+ -o, --out <file> Write here instead of stdout
44
+ --format <format> esm (default) | cjs
45
+ --execution <mode> generated (default) | interpreted
46
+ --positions <mode> full (default) | offset
47
+ --memo Enable packrat memoization
48
+ --no-ll1 Force ordered backtracking
49
+ --max-steps <n> Runaway guard (0 disables)
50
+ --name <name> Name recorded in the file header
51
+ --methods <file> Module whose default export is the semantic methods.
52
+ Supplying them lets actions that call them be compiled
53
+ to source, which can remove the json-logic-engine
54
+ dependency from the generated file entirely.
55
+ `
56
+
57
+ const args = process.argv.slice(2)
58
+ if (args[0] !== 'generate' || args.length < 2) {
59
+ console.error(USAGE)
60
+ process.exit(args.includes('--help') || args.includes('-h') ? 0 : 1)
61
+ }
62
+
63
+ /** Minimal flag parsing: enough for this, without a dependency. */
64
+ const flags = {}
65
+ let input = null
66
+ for (let i = 1; i < args.length; i++) {
67
+ const arg = args[i]
68
+ if (arg === '-o' || arg === '--out') flags.out = args[++i]
69
+ else if (arg === '--format') flags.format = args[++i]
70
+ else if (arg === '--execution') flags.execution = args[++i]
71
+ else if (arg === '--positions') flags.positions = args[++i]
72
+ else if (arg === '--max-steps') flags.maxSteps = Number(args[++i])
73
+ else if (arg === '--name') flags.name = args[++i]
74
+ else if (arg === '--methods') flags.methods = args[++i]
75
+ else if (arg === '--memo') flags.memo = true
76
+ else if (arg === '--no-ll1') flags.ll1 = false
77
+ else if (arg.startsWith('-')) { console.error(`unknown option ${arg}\n\n${USAGE}`); process.exit(1) }
78
+ else input = arg
79
+ }
80
+
81
+ if (!input) { console.error(USAGE); process.exit(1) }
82
+
83
+ const source = await readFile(input, 'utf8')
84
+ const grammar = input.endsWith('.json') ? JSON.parse(source) : parseDefinition(source)
85
+ const name = flags.name || grammar.name || path.basename(input, path.extname(input))
86
+
87
+ let methods
88
+ if (flags.methods) {
89
+ const loaded = await import(path.resolve(flags.methods))
90
+ methods = typeof loaded.default === 'function' ? loaded.default() : loaded.default || loaded
91
+ } else if (grammar.methodsBlock !== undefined) {
92
+ methods = await loadMethodsBlock(grammar.methodsBlock, input)
93
+ }
94
+
95
+ const output = emitModule(grammar, {
96
+ moduleName: name,
97
+ methods,
98
+ format: flags.format,
99
+ execution: flags.execution,
100
+ positions: flags.positions,
101
+ memo: flags.memo,
102
+ ll1: flags.ll1,
103
+ maxSteps: flags.maxSteps
104
+ })
105
+
106
+ if (flags.out) {
107
+ await writeFile(flags.out, output)
108
+ const engineFree = !/^(?:import|const) .*json-logic-engine/m.test(output)
109
+ console.error(
110
+ `wrote ${flags.out} (${output.length} bytes, ${Object.keys(grammar.rules).length} rules` +
111
+ `${engineFree ? ', no json-logic-engine dependency' : ''})`
112
+ )
113
+ } else {
114
+ process.stdout.write(output)
115
+ }