parseman 0.3.1 → 0.5.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/README.md +83 -32
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
Write parsers in TypeScript — fast enough to run as-is, and blazing fast when the bundler macro kicks in. Same code either way; no grammar files, no generated output to check in. Drop the plugin in tests or anywhere a bundler isn't around and everything still works.
|
|
8
8
|
|
|
9
|
-
_Note: Not necessarily production-ready! I still have test cases and sample parsers I want to build to more rigorously test the API and performance._
|
|
10
|
-
|
|
11
9
|
## Benchmarks
|
|
12
10
|
|
|
13
|
-
Measured on Apple M2 Pro. Bars show µs per parse — shorter is faster.
|
|
11
|
+
Measured on Apple M2 Pro. Bars show µs per parse — shorter is faster. Refresh: `pnpm bench`, then `pnpm bench:svg` (updates `assets/bench-*.svg`).
|
|
12
|
+
|
|
13
|
+
Compared parsers: **Parséman**, Peggy, Parsimmon, Chevrotain, Nearley, and Jison (plus `JSON.parse` on JSON). Each implements the same parsing work on the bench fixtures — building JS values / row arrays / GraphQL AST nodes, not syntax-only validation. Peggy grammars in `bench/*.pegjs` are the reference; Nearley JSON uses [kach/nearley `examples/json.ne`](https://github.com/kach/nearley/blob/master/examples/json.ne); other Nearley and Jison grammars are ports of those Peggy files (`bench/vendor/`).
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
@@ -18,9 +18,9 @@ Measured on Apple M2 Pro. Bars show µs per parse — shorter is faster.
|
|
|
18
18
|
|
|
19
19
|

|
|
20
20
|
|
|
21
|
-
Parséman has three modes — **interpreter** (zero setup, works anywhere), **macro build** (compiled by the bundler plugin at build time, zero runtime cost), and **`.compile()`** (optional runtime JIT). Most production use lands on one of the first two. The initialization section only shows parsers with a nonzero setup cost: `.compile()` costs 75–650 µs depending on grammar size; Chevrotain always costs 840–1,400 µs. Parsers not listed there start for free.
|
|
21
|
+
Parséman has three modes — **interpreter** (zero setup, works anywhere), **macro build** (compiled by the bundler plugin at build time, zero runtime cost), and **`.compile()`** (optional runtime JIT). Most production use lands on one of the first two. The initialization section only shows parsers with a nonzero setup cost: `.compile()` costs 75–650 µs depending on grammar size; Chevrotain always costs 840–1,400 µs. Parsers not listed there start for free. (Init numbers are pinned on the charts — they're noisy run-to-run; warm-parse bars are the meaningful comparison.)
|
|
22
22
|
|
|
23
|
-
On JSON, CSV, and GraphQL, Parséman macro beats Peggy at every fixture size in the charts above. For CST-building grammars (`node()` rules with trivia capture), the interpreter runs ~2× faster than Chevrotain on the JSON CST fixture while building an equivalent tree.
|
|
23
|
+
On JSON, CSV, and GraphQL, Parséman macro beats Peggy at every fixture size in the charts above (e.g. GraphQL large: **142 µs** vs Peggy **400 µs**). For CST-building grammars (`node()` rules with trivia capture), the interpreter runs ~2× faster than Chevrotain on the JSON CST fixture while building an equivalent tree.
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
@@ -118,30 +118,46 @@ Combinator trees — `literal`, `regex`, `sequence`, `choice`, `many`, `oneOrMor
|
|
|
118
118
|
|
|
119
119
|
## Combinators
|
|
120
120
|
|
|
121
|
+
**Terminology:** In this README, **combinator** means any building block that matches input (`literal`, `choice`, `sequence`, …). The `parser()` **function** is different — it wraps a root combinator with document-level options (trivia, line tracking) and gives you `.parse(input)`. Same word family, two roles: combinators compose the grammar; `parser()` configures how you run it.
|
|
122
|
+
|
|
123
|
+
A combinator reads from the input at the current position and succeeds or fails (returning a value on success). Composing combinators with `sequence`, `choice`, `many`, etc. is how you express parsing decisions.
|
|
124
|
+
|
|
125
|
+
`makeWord`, `rules`, and `parser` are **helpers**: definition-time factories or wrappers that *produce* combinators. They never match input themselves.
|
|
126
|
+
|
|
121
127
|
| Combinator | Description |
|
|
122
128
|
|---|---|
|
|
123
129
|
| `literal(s, opts?)` | Exact string match. `opts.caseInsensitive` for locale-aware comparison. |
|
|
124
130
|
| `word(s, boundary?)` | Single keyword with automatic word-boundary guard. |
|
|
125
|
-
| `
|
|
131
|
+
| `keywords(words, opts?)` | Match one of many keywords (longest-first), with optional boundary and case folding. |
|
|
126
132
|
| `regex(pattern)` | Match a regex at the current position. Patterns are optimized via `regexp-tree`. |
|
|
127
|
-
| `sequence(...
|
|
128
|
-
| `choice(...
|
|
129
|
-
| `many(
|
|
130
|
-
| `oneOrMore(
|
|
131
|
-
| `optional(
|
|
132
|
-
| `sepBy(
|
|
133
|
-
| `transform(
|
|
133
|
+
| `sequence(...combinators)` | Match all in order; returns a tuple `[v1, v2, ...]`. Skips trivia between terms when trivia is set. |
|
|
134
|
+
| `choice(...combinators)` | Ordered alternatives (PEG — first match wins). Disjoint first chars → O(1) dispatch. |
|
|
135
|
+
| `many(combinator)` | Zero or more; compiles to a `while` loop. |
|
|
136
|
+
| `oneOrMore(combinator)` | One or more; fails if nothing matches. |
|
|
137
|
+
| `optional(combinator)` | Zero or one; returns `null` on no match. |
|
|
138
|
+
| `sepBy(combinator, sep)` | Zero or more combinator matches separated by `sep`. |
|
|
139
|
+
| `transform(combinator, fn)` | Map the result: `fn(value, span) → newValue`. |
|
|
134
140
|
| `skip(main, skipped)` | Match `main` then `skipped`; return `main`'s value. |
|
|
135
|
-
| `
|
|
136
|
-
| `node(type,
|
|
141
|
+
| `label(name, combinator)` | Attach a string label to a combinator arm (metadata). Used on trivia `choice` branches for per-chunk kind capture — see [Whitespace and comment skipping](#whitespace-and-comment-skipping). |
|
|
142
|
+
| `node(type, combinator, build)` | CST/AST rule: captures the combinator's terminals into `children`/`rawChildren` and trivia offsets into a flat `triviaLog`, then calls `build(children, rawChildren, span, triviaLog)`. |
|
|
137
143
|
| `ref<T>()` | Low-level forward declaration slot (use `rules()` in most cases). |
|
|
138
|
-
| `not(
|
|
144
|
+
| `not(combinator)` | Negative lookahead — succeeds (consuming nothing) when the combinator fails. |
|
|
139
145
|
| `guard(predicate)` | Succeeds only when `predicate(ctx)` returns true; used for context-sensitive rules. |
|
|
140
|
-
| `withCtx(extra,
|
|
141
|
-
| `recover(
|
|
146
|
+
| `withCtx(extra, combinator)` | Merge `extra` into the user context for the duration of the combinator. |
|
|
147
|
+
| `recover(combinator, sentinel)` | On failure, skip input until `sentinel` matches; returns a `CSTError` node. |
|
|
142
148
|
| `scanTo(sentinel, opts?)` | Scan forward until `sentinel` matches (sentinel not consumed). Pass `opts.skip` to treat certain patterns as opaque blobs that may contain the sentinel character. Pass `opts.orEOF: true` to succeed at end-of-input. |
|
|
143
149
|
| `balanced(open, close, opts?)` | Match a single balanced delimited region — e.g. `(…)` or `[…]` — including the delimiters. Primarily used as an element of `scanTo`'s `opts.skip` list. |
|
|
144
150
|
|
|
151
|
+
### Helpers (produce combinators at definition time)
|
|
152
|
+
|
|
153
|
+
| Helper | Description |
|
|
154
|
+
|---|---|
|
|
155
|
+
| `trivia(combinator)` | Label a combinator as skippable filler (whitespace, comments). Does not skip by itself — pass the result to `parser({ trivia })` to turn on auto-skipping between tokens. |
|
|
156
|
+
| `makeWord(boundary?)` | Returns `(str) => Combinator` with a fixed word-boundary class. Not a combinator — see [keyword disambiguation](#ordered-choice-and-keyword-disambiguation). |
|
|
157
|
+
| `rules(factory)` | Named, mutually-recursive rule bundle. See [Named and recursive rules](#named-and-recursive-rules). |
|
|
158
|
+
| `parser({ trivia }, combinator)` | Wrap a root combinator with document-level trivia skipping. See [Whitespace and comment skipping](#whitespace-and-comment-skipping). |
|
|
159
|
+
| `triviaEntries(log, labels?, opts?)` | View over a flat trivia log: `.kind(i)`, `.text(i, input)`, `.start(i)`, `.end(i)`. Stride is 2/3 for root `_triviaLog`, 3/4 for per-node `triviaLog` when kinds are enabled. |
|
|
160
|
+
|
|
145
161
|
---
|
|
146
162
|
|
|
147
163
|
## Performance: collapse opaque shapes into one regex
|
|
@@ -165,18 +181,23 @@ Two takeaways:
|
|
|
165
181
|
|
|
166
182
|
**When NOT to collapse:** keep the parts as separate combinators wherever the builder needs them as distinct CST children — for per-field source spans (`fieldSpans`/`valueSpans`), for trivia recovered *between* the parts, or for distinct typed nodes. Correctness first; collapse only the genuinely opaque runs.
|
|
167
183
|
|
|
184
|
+
For library-level codegen and macro optimizations (not grammar authoring), see [PERF_IDEAS.md](./PERF_IDEAS.md).
|
|
185
|
+
|
|
168
186
|
---
|
|
169
187
|
|
|
170
188
|
## Whitespace and comment skipping
|
|
171
189
|
|
|
172
|
-
|
|
190
|
+
Skipping filler between tokens is a two-step setup:
|
|
191
|
+
|
|
192
|
+
1. **Define** what counts as filler — usually `regex(/\s+/)`, comments, or both — and wrap it with `trivia()`. This only sets a metadata flag (`isTrivia`); it does not change when the parser runs.
|
|
193
|
+
2. **Activate** skipping by passing that combinator to `parser({ trivia }, combinator)`. That installs it on the parse context so `sequence`, `sepBy`, `choice`, etc. consume matching filler automatically between terms.
|
|
173
194
|
|
|
174
195
|
```ts
|
|
175
196
|
import { parser, regex, trivia, sepBy, literal } from 'parseman'
|
|
176
197
|
|
|
177
|
-
const ws = trivia(regex(/\s*/))
|
|
198
|
+
const ws = trivia(regex(/\s*/)) // "this pattern is filler" — not skipped yet
|
|
178
199
|
const word = regex(/[a-z]+/)
|
|
179
|
-
const list = parser({ trivia: ws }, sepBy(word, literal(',')))
|
|
200
|
+
const list = parser({ trivia: ws }, sepBy(word, literal(','))) // skipping on
|
|
180
201
|
|
|
181
202
|
list.parse('foo , bar , baz')
|
|
182
203
|
// { ok: true, value: ['foo', 'bar', 'baz'], ... }
|
|
@@ -190,25 +211,53 @@ const blockComment = sequence(literal('/*'), scanTo(literal('*/'), []))
|
|
|
190
211
|
const ws = trivia(many(choice(regex(/\s+/), lineComment, blockComment)))
|
|
191
212
|
```
|
|
192
213
|
|
|
214
|
+
Label trivia arms so each captured chunk records its kind in `_triviaLog` / per-node `triviaLog`:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
const rw = trivia(oneOrMore(choice(
|
|
218
|
+
label('whitespace', regex(/[ \t\n\r\f]+/)),
|
|
219
|
+
label('blockComment', regex(/\/\*(?:[^*]|\*(?!\/))*\*\//)),
|
|
220
|
+
)))
|
|
221
|
+
|
|
222
|
+
// After parse, resolve kinds lazily:
|
|
223
|
+
import { triviaEntries } from 'parseman'
|
|
224
|
+
const entries = triviaEntries(triviaLog, rw._meta.triviaKindLabels)
|
|
225
|
+
entries.kind(0) // 'whitespace'
|
|
226
|
+
entries.text(0, input) // slice on demand
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`label(name, parser)` names a trivia arm; `node('Ruleset', …)` names a CST node — different namespaces, no conflict.
|
|
230
|
+
|
|
231
|
+
When all trivia arms are labeled, each captured chunk appends a **kind index** to the log (stride +1). Root `_triviaLog` entries are `[start, end, kind]`; per-node `triviaLog` entries are `[start, end, insertIdx, kind]`. Without labels, strides are 2 and 3 respectively.
|
|
232
|
+
|
|
193
233
|
---
|
|
194
234
|
|
|
195
235
|
## Ordered choice and keyword disambiguation
|
|
196
236
|
|
|
197
237
|
`choice()` uses PEG ordered-choice semantics: first match wins. **Order matters.**
|
|
198
238
|
|
|
199
|
-
For keywords — where `if` should not match the prefix of `ifdef` — use `word()
|
|
239
|
+
For keywords — where `if` should not match the prefix of `ifdef` — use the `word` combinator. Pass a custom **boundary** (the character class that must *not* follow the match) per call, or use `makeWord` to bake one boundary into a small factory:
|
|
200
240
|
|
|
201
241
|
```ts
|
|
202
|
-
|
|
242
|
+
import { word, makeWord, choice, regex } from 'parseman'
|
|
243
|
+
|
|
244
|
+
word('true') // combinator — default boundary
|
|
245
|
+
word('color', 'A-Za-z0-9_-') // combinator — one-off custom boundary
|
|
246
|
+
|
|
247
|
+
// Helper — returns a function that produces combinators (not a combinator itself)
|
|
248
|
+
const kw = makeWord()
|
|
249
|
+
const cssKw = makeWord('A-Za-z0-9_-')
|
|
203
250
|
|
|
204
251
|
const token = choice(
|
|
205
|
-
kw('if'),
|
|
252
|
+
kw('if'), // each call yields a combinator
|
|
206
253
|
kw('else'),
|
|
207
|
-
|
|
208
|
-
regex(/[a-zA-Z_]\w*/),
|
|
254
|
+
cssKw('color'),
|
|
255
|
+
regex(/[a-zA-Z_]\w*/), // ident fallback
|
|
209
256
|
)
|
|
210
257
|
```
|
|
211
258
|
|
|
259
|
+
`makeWord` is optional — `(s) => word(s, 'A-Za-z0-9_-')` is equivalent.
|
|
260
|
+
|
|
212
261
|
Or build the boundary guard by hand with `not()`:
|
|
213
262
|
|
|
214
263
|
```ts
|
|
@@ -290,7 +339,7 @@ value.define(choice(object, array, str, num, bool, nil))
|
|
|
290
339
|
|
|
291
340
|
## Grammars that build an AST
|
|
292
341
|
|
|
293
|
-
For grammars that produce a typed CST/AST, support incremental re-parsing, or care about trivia, wrap each rule in `node(type,
|
|
342
|
+
For grammars that produce a typed CST/AST, support incremental re-parsing, or care about trivia, wrap each rule in `node(type, combinator, build)`. parseman captures the rule's terminals into `children` / `rawChildren` and records trivia as flat `[start, end, insertIdx, …]` **entries** in `triviaLog` (three numbers per whitespace/comment chunk), then hands all four to `build(children, rawChildren, span, triviaLog)`. **Capture is the library's job:** you don't wrap terminals to recover their spans, and you don't reconstruct trivia — it's collected as the parser runs, in both the interpreter and the compiled build. Use `buildTriviaIndex(tree, input)` to turn `triviaLog` into a before/after lookup table.
|
|
294
343
|
|
|
295
344
|
```ts
|
|
296
345
|
import { rules, parser, node, regex, literal, sequence, many, trivia } from 'parseman'
|
|
@@ -314,9 +363,9 @@ Expr.parse('1 + 2 + 3', 0, { trackLines: false })
|
|
|
314
363
|
|
|
315
364
|
- **`children`** — structural items in source order: spanned `CSTLeaf` terminals (`{ _tag:'leaf', value, span }`) and sub-nodes (whatever a nested `node()`'s `build` returned). A `build` that returns a bare string is recorded by the parent as a spanned leaf, so single-item "collapsing" rules keep their source span.
|
|
316
365
|
- **`rawChildren`** — structural children only (same items as `children`, without trivia tokens).
|
|
317
|
-
- **`triviaLog`** — flat `[start, end, insertIdx, …]`
|
|
366
|
+
- **`triviaLog`** — flat `[start, end, insertIdx, …]` entries for whitespace/comments consumed between terms. `insertIdx` is the `rawChildren` index before which the trivia was consumed. Pass the tree to `buildTriviaIndex(tree, input)` for a `before`/`after` map of trivia tokens — useful for whitespace-sensitive syntax (e.g. CSS `div p` vs `div.p`).
|
|
318
367
|
|
|
319
|
-
Each rule returned from the factory is independently callable — `Expr`, `Num` above are the **rule registry** incremental re-parsing needs. Wrap a rule's inner
|
|
368
|
+
Each rule returned from the factory is independently callable — `Expr`, `Num` above are the **rule registry** incremental re-parsing needs. Wrap a rule's inner combinator in `parser({ trivia }, combinator)` so trivia-skipping is baked in regardless of which rule you start from; the macro compiles the wrapper (and all capture) away to flat JS.
|
|
320
369
|
|
|
321
370
|
> `transform(p, fn)` is still the tool for plain value-mapping (no children/trivia). `node()` is for CST/AST rules — it adds the capture `transform` doesn't. Both compile under the macro.
|
|
322
371
|
|
|
@@ -383,7 +432,7 @@ export const { Expr } = rules<{ Expr: Combinator<Node> }>(g => {
|
|
|
383
432
|
|
|
384
433
|
`withCtx` and `guard` implement context-sensitive rules without mutating shared state, and they compose into `rules()` like any other combinator.
|
|
385
434
|
|
|
386
|
-
`withCtx(extra,
|
|
435
|
+
`withCtx(extra, combinator)` merges `extra` into the user context for the duration of the combinator. `guard(predicate)` succeeds only when `predicate(ctx)` returns true, gating a rule behind runtime context.
|
|
387
436
|
|
|
388
437
|
```ts
|
|
389
438
|
import { rules, withCtx, guard, many, sequence, choice, literal, regex, trivia, parser } from 'parseman'
|
|
@@ -406,7 +455,7 @@ export const { Program } = rules<{ Program: Combinator<unknown> }>(g => {
|
|
|
406
455
|
|
|
407
456
|
## Error recovery
|
|
408
457
|
|
|
409
|
-
`recover(
|
|
458
|
+
`recover(combinator, sentinel)` wraps a combinator so that on failure it skips forward until `sentinel` matches, then returns a `CSTError` node instead of bailing on the whole parse. Error recovery is never pretty, but at least you can keep going.
|
|
410
459
|
|
|
411
460
|
```ts
|
|
412
461
|
import { recover, scanTo, balanced, literal } from 'parseman'
|
|
@@ -537,7 +586,9 @@ pnpm install
|
|
|
537
586
|
pnpm test # Vitest — interpreter + compiler parity + ordered-choice semantics
|
|
538
587
|
pnpm typecheck # TypeScript 7
|
|
539
588
|
pnpm build # ESM + CJS + .d.ts → dist/
|
|
540
|
-
pnpm bench
|
|
589
|
+
pnpm bench # Parséman vs Peggy, Parsimmon, Chevrotain, Nearley, Jison
|
|
590
|
+
pnpm bench:svg # refresh assets/bench-*.svg after bench
|
|
591
|
+
pnpm bench:compile-grammars # regenerate Peggy / Nearley / Jison parser output in bench/
|
|
541
592
|
```
|
|
542
593
|
|
|
543
594
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parseman",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Parser combinators that compile to optimized JavaScript — use as a library or as a build-time macro",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Matthew Dean <matthew-dean@users.noreply.github.com>",
|
|
@@ -35,7 +35,10 @@
|
|
|
35
35
|
"test:watch": "vitest",
|
|
36
36
|
"typecheck": "tsc",
|
|
37
37
|
"bench": "node --import tsx/esm bench/run.ts",
|
|
38
|
-
"bench:
|
|
38
|
+
"bench:baseline": "node --import tsx/esm bench/update-parseman-baseline.ts",
|
|
39
|
+
"bench:svg": "node --import tsx/esm bench/gen-svg.ts",
|
|
40
|
+
"build:chevrotain-bench": "node --import tsx/esm scripts/build-chevrotain-bench.mjs",
|
|
41
|
+
"bench:compile-grammars": "peggy --format es -o bench/json-parser.js bench/json.pegjs && peggy --format es -o bench/csv-parser.js bench/csv.pegjs && peggy --format es -o bench/graphql-parser.js bench/graphql.pegjs && peggy --format es -o bench/graphqlite-parser.js bench/graphqlite.pegjs && nearleyc bench/vendor/nearley/json.ne -o bench/json-nearley.js && nearleyc bench/vendor/nearley/csv.ne -o bench/csv-nearley.js && nearleyc bench/vendor/nearley/graphql.ne -o bench/graphql-nearley.js && cp bench/graphql-nearley.js bench/graphql-nearley.cjs && node bench/compile-jison.mjs"
|
|
39
42
|
},
|
|
40
43
|
"keywords": [
|
|
41
44
|
"parser",
|
|
@@ -50,7 +53,9 @@
|
|
|
50
53
|
"@types/node": "~26.0.0",
|
|
51
54
|
"@types/parsimmon": "~1.10.9",
|
|
52
55
|
"chevrotain": "~12.0.0",
|
|
53
|
-
"
|
|
56
|
+
"jison": "~0.4.18",
|
|
57
|
+
"moo": "~0.5.3",
|
|
58
|
+
"nearley": "~2.20.1",
|
|
54
59
|
"parsimmon": "~1.18.1",
|
|
55
60
|
"peggy": "~5.1.0",
|
|
56
61
|
"tsup": "~8.5.1",
|