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/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
|
package/bin/grammatik.js
ADDED
|
@@ -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
|
+
}
|