parseman 0.8.0 → 0.10.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 CHANGED
@@ -6,21 +6,7 @@
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
- ## Benchmarks
10
-
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
-
15
- ![JSON parsing benchmarks](https://raw.githubusercontent.com/matthew-dean/parseman/main/assets/bench-json.svg)
16
-
17
- ![CSV parsing benchmarks](https://raw.githubusercontent.com/matthew-dean/parseman/main/assets/bench-csv.svg)
18
-
19
- ![GraphQL parsing benchmarks](https://raw.githubusercontent.com/matthew-dean/parseman/main/assets/bench-graphql.svg)
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. (Init numbers are pinned on the charts — they're noisy run-to-run; warm-parse bars are the meaningful comparison.)
22
-
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.
9
+ > **📖 Full documentation: [matthew-dean.github.io/parseman](https://matthew-dean.github.io/parseman/)**
24
10
 
25
11
  ---
26
12
 
@@ -31,8 +17,6 @@ npm install parseman
31
17
  # pnpm add parseman
32
18
  ```
33
19
 
34
- ---
35
-
36
20
  ## Quick start
37
21
 
38
22
  ```ts
@@ -51,559 +35,56 @@ parse(requestLine, 'GET /api/v1 HTTP/1.1')
51
35
  // { ok: true, value: { verb: 'GET', path: '/api/v1', version: 'HTTP/1.1' }, span: ... }
52
36
  ```
53
37
 
54
- ---
55
-
56
- ## Macro mode
38
+ ## Three modes, one grammar
57
39
 
58
- Add the plugin once — your parser imports are evaluated and compiled at build time. The `parseman` import disappears from the bundle entirely.
40
+ The same combinator code runs three ways, with identical results:
59
41
 
60
- ### 1. Register the plugin
42
+ - **Interpreter** — zero setup, works anywhere (tests, REPLs, dynamic grammars).
43
+ - **Macro build** — a [bundler plugin](https://matthew-dean.github.io/parseman/guide/macro-mode) evaluates your grammar at build time and replaces it with inline JS. Zero runtime cost; the `parseman` import disappears from the bundle.
44
+ - **`.compile()`** — the same optimizer, run on demand at runtime.
61
45
 
62
46
  ```ts
63
- // vite.config.ts
64
- import parseman from 'parseman/plugin'
65
- export default { plugins: [parseman()] }
66
- ```
67
-
68
- ```js
69
- // rollup.config.js
70
- import parseman from 'parseman/plugin'
71
- export default { plugins: [parseman.rollup()] }
72
- ```
73
-
74
- ```js
75
- // webpack.config.js
76
- const parseman = require('parseman/plugin')
77
- module.exports = { plugins: [parseman.webpack()] }
47
+ // Add the plugin (vite.config.ts) and one import attribute — that's the whole change:
48
+ import { literal, sequence, choice } from 'parseman' with { type: 'macro' }
78
49
  ```
79
50
 
80
- ### 2. Import with `with { type: 'macro' }`
81
-
82
- ```ts
83
- import { literal, sequence, choice, regex, transform } from 'parseman' with { type: 'macro' }
84
- ```
51
+ See **[The three modes](https://matthew-dean.github.io/parseman/guide/modes)** for the full story.
85
52
 
86
- Same combinators, no other changes. The plugin walks the initializer, evaluates it at build time, and replaces it with an inline function.
53
+ ## What's in the box
87
54
 
88
- ### What gets emitted
55
+ - **[Combinators](https://matthew-dean.github.io/parseman/guide/combinators)** — `literal`, `regex`, `sequence`, `choice`, `many`, `sepBy`, `not`, and more.
56
+ - **[Whitespace & trivia](https://matthew-dean.github.io/parseman/guide/trivia)** — grammar-defined filler skipping, with per-chunk kind capture.
57
+ - **[Recursive rules](https://matthew-dean.github.io/parseman/guide/recursive-rules)** — `rules()` for mutually recursive grammars; fully macro-compilable.
58
+ - **[CST / AST nodes](https://matthew-dean.github.io/parseman/guide/ast)** — `node()` captures terminals and trivia for you, with a `collapse` option for transparent wrapper rules.
59
+ - **[Incremental re-parsing](https://matthew-dean.github.io/parseman/guide/incremental)** — `makeFunctionalDoc` re-parses just the edited subtree on each keystroke.
60
+ - **[Error recovery](https://matthew-dean.github.io/parseman/guide/error-recovery)** — `recover`, `expect`, and a `{ recover: true }` channel keep parsing broken input and report every error.
61
+ - **[Context-sensitive parsing](https://matthew-dean.github.io/parseman/guide/context)** — `withCtx` / `guard` without mutating shared state.
89
62
 
90
- ```js
91
- // Before (source):
92
- const method = choice(literal('GET'), literal('POST'), literal('PUT'), literal('DELETE'))
93
-
94
- // After (bundle output):
95
- const method = function(input, _pos, _ctx) {
96
- const _code = _pos < input.length ? input.codePointAt(_pos) : -1
97
- if (_code === 71) { /* G-E-T */ }
98
- else if (_code === 80) { /* P-O-S-T */ }
99
- else if (_code === 68) { /* D-E-L-E-T-E */ }
100
- else return { ok: false, expected: ['"GET"', '"POST"', ...], span: { start: _pos, end: _pos } }
101
- ...
102
- }
103
- ```
104
-
105
- Disjoint first characters → single `codePointAt` dispatch. Regex parsers → sticky `/pattern/y` hoisted to closure scope. No objects allocated on failure paths.
106
-
107
- ### Debugging still works
108
-
109
- The plugin emits a precise source map via [magic-string](https://github.com/Rich-Harris/magic-string). Breakpoints set on the original `choice(...)` lines are hit when the compiled function runs; step-through shows original combinator source, not emitted charCode checks.
110
-
111
- If `with { type: 'macro' }` is stripped (older bundlers, test runners), the attribute is silently ignored and the interpreter runs instead — identical results, no errors.
112
-
113
- ### What gets compiled
114
-
115
- Combinator trees — `literal`, `regex`, `sequence`, `choice`, `many`, `oneOrMore`, `optional`, `sepBy`, `transform`, `skip`, `not`, `scanTo`, `balanced` — plus `rules()` factories (including mutually recursive ones), `parser({ trivia })` / `noTrivia()` wrappers, and `node()` rules (CST capture and all). A full grammar built as a `rules()` factory of `node()` rules compiles end to end: each rule becomes an independently-callable function, terminal/trivia capture is emitted inline, and every `build`/`transform` callback is inlined with its source span. Grammars with no `node()` emit zero capture code, so they compile byte-identically. Parsers that close over external variables the evaluator can't resolve stay as-is — the plugin compiles what it can and quietly leaves the rest alone.
116
-
117
- ---
118
-
119
- ## Combinators
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
-
127
- | Combinator | Description |
128
- |---|---|
129
- | `literal(s, opts?)` | Exact string match. `opts.caseInsensitive` for locale-aware comparison. |
130
- | `word(s, boundary?)` | Single keyword with automatic word-boundary guard. |
131
- | `keywords(words, opts?)` | Match one of many keywords (longest-first), with optional boundary and case folding. |
132
- | `regex(pattern)` | Match a regex at the current position. Patterns are optimized via `regexp-tree`. |
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`. |
140
- | `skip(main, skipped)` | Match `main` then `skipped`; return `main`'s value. |
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)`. |
143
- | `ref<T>()` | Low-level forward declaration slot (use `rules()` in most cases). |
144
- | `not(combinator)` | Negative lookahead — succeeds (consuming nothing) when the combinator fails. |
145
- | `guard(predicate)` | Succeeds only when `predicate(ctx)` returns true; used for context-sensitive rules. |
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. |
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. |
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. |
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. `trivia: null` clears trivia for that scope (see `noTrivia`). See [Whitespace and comment skipping](#whitespace-and-comment-skipping). |
159
- | `noTrivia(combinator)` | Run `combinator` with the active trivia cleared — no trivia is skipped between its `sequence`/`many` terms, so they must be **contiguous** in the input. (Trivia is whatever `parser({ trivia })` installed — often whitespace/comments, but it's grammar-defined.) The inverse of `parser({ trivia })`; re-enable trivia in a nested region with `parser({ trivia }, …)`. For a *static* glued token just use one `literal`/`regex` — reach for `noTrivia` when a glued part is itself a structured sub-rule (e.g. a head that must touch a `[subscript]` or `(args)`). See [Contiguous tokens](#contiguous-tokens-no-trivia-between-them). |
160
- | `triviaEntries(log, labels?, opts?)` | Indexed view over a trivia log — `.start(i)`, `.end(i)`, `.kind(i)`, `.text(i, input)`. The log is a **flat number array** (no per-entry objects); each entry is a fixed-size run of consecutive numbers, so entry `i` starts at `i * stride`. Root `_triviaLog`: `[start, end]` (stride 2). Per-node `triviaLog` (pass `{ nodeLog: true }`): `[start, end, insertIdx]` (stride 3). Passing `labels` appends a kind index per entry (+1 to stride). |
161
-
162
- ---
163
-
164
- ## Performance: collapse opaque shapes into one regex
165
-
166
- The single biggest grammar-level perf lever is **the number of combinator boundaries on the hot path**. Every `sequence` / `regex` / `oneOrMore` step is a function call plus a result-object allocation plus — in a `node()` rule — a leaf push. Fewer, fatter combinators beat many thin ones.
167
-
168
- Measured on a repeated 3-shape group (`name1 1px #111 …`, ~29 KB), parsing the same content three ways:
169
-
170
- | Approach | Interpreted | Compiled |
171
- |---|---|---|
172
- | `oneOrMore(sequence(ident, sp, num, sp, hex, sp))` | 0.289 ms | 0.167 ms |
173
- | same, with **inline** `regex(…)` instead of shared refs | 0.281 ms | 0.183 ms |
174
- | **one** `regex(/…ident…num…hex…/)` per group | **0.055 ms** | **0.042 ms** |
175
-
176
- Two takeaways:
177
-
178
- - **Shared combinator ref vs inline `regex(…)` literal makes no difference.** Both produce the identical runtime structure (one `regex` combinator either way), and the compiler inlines a `regex` test at every use site regardless of sharing — there's no function-call indirection to eliminate (that only exists for `ref()` / `rules()` entries). Factor out shared terminals for readability; it costs nothing.
179
- - **Collapsing a fixed multi-token shape into a single `regex` is 4–5× faster** in both the interpreter and compiled output, because it erases the per-step call + allocation overhead. `compile()` is a real but smaller win (~1.7×) and **stacks** with collapsing.
180
-
181
- **When to collapse:** only where the CST treats the group as opaque text — a dimension `\d+px`, a hex color, an `nth` expression, a simple ident-run. A single regex yields **one leaf**, not structured sub-nodes.
182
-
183
- **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.
184
-
185
- For library-level codegen and macro optimizations (not grammar authoring), see [PERF_IDEAS.md](./PERF_IDEAS.md).
63
+ Full API in the **[reference](https://matthew-dean.github.io/parseman/reference/api)**.
186
64
 
187
65
  ---
188
66
 
189
- ## Whitespace and comment skipping
190
-
191
- Skipping filler between tokens is a two-step setup:
192
-
193
- 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.
194
- 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.
195
-
196
- ```ts
197
- import { parser, regex, trivia, sepBy, literal } from 'parseman'
198
-
199
- const ws = trivia(regex(/\s*/)) // "this pattern is filler" — not skipped yet
200
- const word = regex(/[a-z]+/)
201
- const list = parser({ trivia: ws }, sepBy(word, literal(','))) // skipping on
202
-
203
- list.parse('foo , bar , baz')
204
- // { ok: true, value: ['foo', 'bar', 'baz'], ... }
205
- ```
206
-
207
- Multiple trivia types — whitespace and comments — combine with `choice()` and `many()`:
208
-
209
- ```ts
210
- const lineComment = sequence(literal('//'), regex(/[^\n]*/))
211
- const blockComment = sequence(literal('/*'), scanTo(literal('*/'), []))
212
- const ws = trivia(many(choice(regex(/\s+/), lineComment, blockComment)))
213
- ```
214
-
215
- Label trivia arms so each captured chunk records its kind in `_triviaLog` / per-node `triviaLog`:
216
-
217
- ```ts
218
- const rw = trivia(oneOrMore(choice(
219
- label('whitespace', regex(/[ \t\n\r\f]+/)),
220
- label('blockComment', regex(/\/\*(?:[^*]|\*(?!\/))*\*\//)),
221
- )))
222
-
223
- // After parse, resolve kinds lazily:
224
- import { triviaEntries } from 'parseman'
225
- const entries = triviaEntries(triviaLog, rw._meta.triviaKindLabels)
226
- entries.kind(0) // 'whitespace'
227
- entries.text(0, input) // slice on demand
228
- ```
229
-
230
- `label(name, parser)` names a trivia arm; `node('Ruleset', …)` names a CST node — different namespaces, no conflict.
231
-
232
- 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.
233
-
234
- ### Contiguous tokens (no trivia between them)
235
-
236
- Trivia skipping is ambient: once `parser({ trivia })` installs it, `sequence`/`many`/`choice` skip filler between **every** term. (Filler is whatever you passed as `trivia` — usually whitespace/comments, but it's grammar-defined.) Sometimes you need the opposite — parts that must touch.
237
-
238
- If the whole thing is *static*, don't reach for `noTrivia` — just write one `literal`/`regex`: a decimal is `regex(/[0-9]+\.[0-9]+/)`, an operator is `literal('>=')`. `noTrivia` earns its keep only when a glued part is itself a **structured sub-rule** you can't fold into one pattern — usually recursive, or with its own trivia-enabled interior. The classic case is a head that must touch a bracket, wrapping a sub-expression that *does* allow spaces:
239
-
240
- ```ts
241
- const ws = trivia(regex(/[ \t\n]+/))
242
-
243
- // `arr[i + 1]` — `arr` is glued to `[` (no `arr [i]`), but the bracketed
244
- // subscript is a full, space-tolerant expression. A regex can't express that:
245
- // `expr` is recursive. Turn trivia back on for a region by nesting another
246
- // parser({ trivia }) — the innermost one wins, and reverts on exit.
247
- const indexed = noTrivia(sequence(
248
- name,
249
- parser({ trivia: ws }, sequence(literal('['), expr, literal(']'))),
250
- ))
251
- ```
252
-
253
- Put the **whole** trivia-tolerant region inside the nested `parser({ trivia })` — including its leading `[`. `sequence` skips trivia only *between* terms, never before its first, so a `[` left *outside* the nested parser would glue fine but then reject a space right after it (`arr[ i ]`). Two rules of thumb: wrap the whole contiguous run in `noTrivia` (an enclosing `sequence` skips trivia *before* a term, so wrapping just the inner part leaks leading trivia), and wrap the whole spaced region in the nested `parser({ trivia })`. `noTrivia(child)` is exactly `parser({ trivia: null }, child)`.
254
-
255
- ---
256
-
257
- ## Ordered choice and keyword disambiguation
258
-
259
- `choice()` uses PEG ordered-choice semantics: first match wins. **Order matters.**
260
-
261
- 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:
262
-
263
- ```ts
264
- import { word, makeWord, choice, regex } from 'parseman'
265
-
266
- word('true') // combinator — default boundary
267
- word('color', 'A-Za-z0-9_-') // combinator — one-off custom boundary
268
-
269
- // Helper — returns a function that produces combinators (not a combinator itself)
270
- const kw = makeWord()
271
- const cssKw = makeWord('A-Za-z0-9_-')
272
-
273
- const token = choice(
274
- kw('if'), // each call yields a combinator
275
- kw('else'),
276
- cssKw('color'),
277
- regex(/[a-zA-Z_]\w*/), // ident fallback
278
- )
279
- ```
280
-
281
- `makeWord` is optional — `(s) => word(s, 'A-Za-z0-9_-')` is equivalent.
282
-
283
- Or build the boundary guard by hand with `not()`:
284
-
285
- ```ts
286
- const wordChar = regex(/\w/)
287
- const keyword = (s: string) => transform(sequence(literal(s), not(wordChar)), ([kw]) => kw)
288
- const ident = regex(/[a-zA-Z_]\w*/)
289
-
290
- const token = choice(
291
- keyword('if'),
292
- keyword('else'),
293
- keyword('return'),
294
- ident,
295
- )
296
- ```
297
-
298
- When alternatives share a prefix, put the longer one first:
299
-
300
- ```ts
301
- // Wrong: choice(literal('in'), literal('instanceof')) — 'instanceof' never reached
302
- const op = choice(literal('instanceof'), literal('in'), literal('if'))
303
- ```
304
-
305
- ---
306
-
307
- ## Named and recursive rules
308
-
309
- Use `rules()` when your combinators need to reference each other by name. Pass a factory that receives all rule names as ready-to-use references and returns the definitions:
310
-
311
- ```ts
312
- import { rules, parser, choice, sequence, literal, sepBy, transform, trivia, regex } from 'parseman'
313
- import type { Combinator } from 'parseman'
314
-
315
- type JSON = null | boolean | number | string | JSON[] | Record<string, JSON>
316
-
317
- const ws = trivia(regex(/[ \t\n\r]*/))
318
-
319
- const { value } = rules<{ value: Combinator<JSON> }>(g => {
320
- const comma = literal(',')
321
-
322
- const array = transform(
323
- sequence(literal('['), sepBy(g.value, comma), literal(']')),
324
- ([, items]) => items as JSON[]
325
- )
326
- const pair = transform(
327
- sequence(jsonString, literal(':'), g.value),
328
- ([key,, val]) => [key, val] as [string, JSON]
329
- )
330
- const object = transform(
331
- sequence(literal('{'), sepBy(pair, comma), literal('}')),
332
- ([, pairs]) => Object.fromEntries(pairs) as Record<string, JSON>
333
- )
334
-
335
- return {
336
- value: choice(object, array, jsonString, jsonNumber, jsonBool, jsonNull) as Combinator<JSON>,
337
- }
338
- })
339
-
340
- export const jsonParser = parser({ trivia: ws }, value)
341
- jsonParser.parse('{ "a": 1 }')
342
- ```
343
-
344
- `g.value` is a reference that works anywhere inside the factory regardless of order. Local helpers (`comma`, `pair`, `object`) that don't need to be cross-referenced can be plain `const`. Only put a rule in the returned object if other rules need to reach it as `g.xxx`.
345
-
346
- > **Macro and `rules()`:** The plugin fully compiles `rules()` factories, including recursive ones. It emits mutually recursive named functions (`_pf0` etc.) so the cycle is broken. Add `with { type: 'macro' }` to your import and the entire grammar — recursive rules included — is inlined at build time. Both binding forms compile: `const { value } = rules(...)` (each rule becomes a top-level function) and `const grammar = rules(...)` (an object literal of compiled rules, so `grammar.value(...)` works).
347
- >
348
- > If the plugin meets a macro-imported declaration it can't compile statically (it closes over a runtime value, or isn't a recognized combinator shape), it leaves that declaration for the interpreter, strips the `with { type: 'macro' }` attribute so the import stays valid, and emits a build **warning** (`[parseman] file:line — …`) pointing at it — so a silent fallback never goes unnoticed.
349
-
350
- ### `ref<T>()` — low-level forward declaration
351
-
352
- `rules()` handles forward references automatically. `ref<T>()` is the lower-level primitive it uses internally, exposed for cases where you need a single forward slot outside of a `rules()` call:
353
-
354
- ```ts
355
- const value = ref<JSON>()
356
- // ... build parsers that use value ...
357
- value.define(choice(object, array, str, num, bool, nil))
358
- ```
359
-
360
- ---
361
-
362
- ## Grammars that build an AST
363
-
364
- 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.
365
-
366
- ```ts
367
- import { rules, parser, node, regex, literal, sequence, many, trivia } from 'parseman'
368
- import type { Combinator } from 'parseman'
369
-
370
- // Any node shape works as long as it satisfies NodeLike (see below).
371
- type N = { _tag: 'node'; type: string; span: { start: number; end: number }; state: unknown; children: unknown[] }
372
- const ws = trivia(regex(/\s+/))
373
-
374
- export const { Expr, Num } = rules<{ Expr: Combinator<N>; Num: Combinator<N> }>(g => {
375
- const num = node('Num', regex(/[0-9]+/),
376
- (children, raw, span, _triviaLog) => ({ _tag: 'node', type: 'Num', span, state: null, children: [...children] }))
377
- const expr = node('Expr', parser({ trivia: ws }, sequence(g.Num, many(sequence(literal('+'), g.Num)))),
378
- (children, raw, span, _triviaLog) => ({ _tag: 'node', type: 'Expr', span, state: null, children: [...children] }))
379
- return { Expr: expr, Num: num }
380
- })
381
-
382
- Expr.parse('1 + 2 + 3', 0, { trackLines: false })
383
- // value is a Node whose children are the captured Num sub-nodes and '+' leaves
384
- ```
385
-
386
- - **`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.
387
- - **`rawChildren`** — structural children only (same items as `children`, without trivia tokens).
388
- - **`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`).
389
-
390
- 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.
391
-
392
- > `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.
393
-
394
- ### Incremental re-parsing
395
-
396
- `makeFunctionalDoc(registry, rootRule, input, opts?)` wraps a parse in a document that re-parses incrementally on edits. The `registry` is the object `rules()` returns (rule name → parser fn); the parser functions stay stateless, all incremental state lives in the doc.
397
-
398
- ```ts
399
- import { makeFunctionalDoc } from 'parseman'
400
-
401
- const registry = { Expr, Num } // straight from rules()
402
- let doc = makeFunctionalDoc(registry, 'Expr', src)
403
- doc.tree // your Node root, or null on failure
404
- doc.errors // ParseFail[], empty on success
405
- doc.input // the source string
406
-
407
- // edit(from, to, replacement) — two byte offsets into the OLD text + the
408
- // replacement. "Select from→to, type replacement" — the three things every
409
- // editor knows on each keystroke. Returns a new doc; the old one is untouched.
410
- doc = doc.edit(changeStart, changeStart + changeLength, newText)
411
- ```
412
-
413
- `edit()` finds the smallest node containing the change, re-parses just that rule from its start offset using the node's saved `state`, and grafts the result back in when the new span end lands where the edit's delta predicts. Nodes unaffected by the edit are shared by reference between old and new docs. For class-instance ASTs that can't be shallow-spread, pass `opts.rebuild(node, children)` to control how a parent is reconstructed when a child is replaced.
414
-
415
- **In an IDE extension**, keep one registry per language and one doc per open document. Each keystroke gives you the changed range as byte offsets — pass them straight to `edit()`:
416
-
417
- ```ts
418
- const docs = new Map<string, ReturnType<typeof makeFunctionalDoc<Node>>>()
419
-
420
- vscode.workspace.onDidOpenTextDocument(d => {
421
- docs.set(d.uri.toString(), makeFunctionalDoc(registry, 'Stylesheet', d.getText()))
422
- })
423
-
424
- vscode.workspace.onDidChangeTextDocument(event => {
425
- const uri = event.document.uri.toString()
426
- let doc = docs.get(uri)!
427
- for (const change of event.contentChanges) {
428
- doc = doc.edit(change.rangeOffset, change.rangeOffset + change.rangeLength, change.text)
429
- }
430
- docs.set(uri, doc)
431
- // walk doc.tree for diagnostics, folding ranges, semantic tokens, etc.
432
- })
433
- ```
434
-
435
- ### Extending grammars
436
-
437
- Don't copy a `rules()` factory to make a variant — export what stays the same and pass in (or wrap) what changes. `examples/json/` is the template:
438
-
439
- | File | What changes |
440
- |------|----------------|
441
- | `parser.ts` | Base grammar + `makeJSONParser(customWs)` |
442
- | `jsonc.ts` | Trivia only — `makeJSONParser(jsoncWs)` |
443
- | `jsonl.ts` | Document shape — `sepBy(jsonValue, '\n')` with tighter trivia |
444
-
445
- ```ts
446
- // jsonc.ts — same recursive grammar, different trivia
447
- export const jsoncValue = makeJSONParser(jsoncWs)
448
-
449
- // jsonl.ts — reuse jsonValue, wrap at the top
450
- export const jsonl = parser({ trivia: lineWs }, sepBy(jsonValue, literal('\n')))
451
- ```
452
-
453
- `makeJSONParser` in `parser.ts` is the knob: one `rules()` body, trivia passed in, `parser({ trivia }, value)` returned. Unchanged core → parameterize; unchanged rule, different document → wrap the export.
454
-
455
- ---
456
-
457
- ## Context-sensitive parsing
458
-
459
- `withCtx` and `guard` implement context-sensitive rules without mutating shared state, and they compose into `rules()` like any other combinator.
460
-
461
- `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.
462
-
463
- ```ts
464
- import { rules, withCtx, guard, many, sequence, choice, literal, regex, trivia, parser } from 'parseman'
465
- import type { Combinator } from 'parseman'
466
-
467
- const ws = trivia(regex(/\s*/))
468
-
469
- export const { Program } = rules<{ Program: Combinator<unknown> }>(g => {
470
- const expr = regex(/[a-z]+/)
471
- const ret = sequence(guard((ctx: { inFn?: boolean }) => ctx.inFn === true), literal('return'))
472
- const stmt = choice(ret, expr)
473
- const body = withCtx({ inFn: true }, many(sequence(stmt, ws)))
474
- return { Program: parser({ trivia: ws }, many(body)) }
475
- })
476
- ```
477
-
478
- `return` is only reachable inside a body because `guard` rejects it when `inFn` is not set. Incremental `edit()` replays the correct context because each node records the `ctx.state` snapshot (`node.state`) active when it was parsed.
479
-
480
- ---
481
-
482
- ## Error recovery
483
-
484
- `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.
485
-
486
- ```ts
487
- import { recover, scanTo, balanced, literal } from 'parseman'
488
-
489
- // Skip to ';' if a statement fails to parse
490
- const stmt = recover(g.Stmt, literal(';'))
491
-
492
- // Consume everything up to '}', skipping balanced () and [] pairs
493
- const block = scanTo(literal('}'), [balanced(literal('('), literal(')')), balanced(literal('['), literal(']'))])
494
- ```
495
-
496
- ### `scanTo` vs `balanced` — when to use each
497
-
498
- Both are **position arithmetic**: pure cursor-advance with zero CST allocation. Neither pushes terminals into the enclosing `node()`'s child list; only the final scanned span appears as a single leaf.
499
-
500
- **`scanTo(sentinel, opts?)`** — use when you need to consume an open-ended region whose boundary is a specific token. It scans character-by-character, stopping the moment `sentinel` matches. Pass `opts.skip` to declare patterns that should be treated as opaque blobs so their content never accidentally looks like the sentinel:
501
-
502
- ```ts
503
- // Consume a CSS at-rule prelude up to '{' or ';',
504
- // but don't stop inside parentheses or strings.
505
- const prelude = scanTo(choice(literal('{'), literal(';')), {
506
- skip: [balanced('(', ')'), singleStr, doubleStr],
507
- })
508
- ```
509
-
510
- **`balanced(open, close, opts?)`** — use when you want to match a single self-contained delimited region and get its full text back (delimiters included). This is the natural building block for `scanTo`'s `skip` list:
511
-
512
- ```ts
513
- const parenGroup = balanced('(', ')') // matches (a + b) including the parens
514
- const bracketExpr = balanced('[', ']') // matches [0] including the brackets
515
- ```
516
-
517
- `balanced` can itself accept a `skip` list for deeply nested structures:
518
-
519
- ```ts
520
- // Match (…) allowing strings inside to contain unbalanced parens
521
- const parenWithStrings = balanced('(', ')', { skip: [singleStr, doubleStr] })
522
- ```
523
-
524
- **The key difference**: `scanTo` scans *until* a boundary; `balanced` matches *across* a known delimited region. Use `balanced` inside `scanTo`'s `skip` to prevent the scanner from stopping at a sentinel that appears inside a nested structure.
525
-
526
- ---
527
-
528
- ## Line / column tracking
529
-
530
- ```ts
531
- const r = parse(myParser, 'hello\nworld', { trackLines: true })
532
-
533
- if (r.ok) {
534
- r.span.startLine // 1
535
- r.span.startColumn // 1
536
- r.span.endLine // 2
537
- r.span.endColumn // 6
538
- }
539
- ```
540
-
541
- Line lookup is O(log n) via binary search on a precomputed newline index built once per input string. When `trackLines` is false (the default), no index is built and spans carry only byte offsets.
542
-
543
- ---
544
-
545
- ## `compile()` — runtime compilation
546
-
547
- `compile()` runs the same optimizer as the plugin, but at runtime — handy when you're assembling a grammar dynamically, or just want the speed without a build step:
67
+ ## Benchmarks
548
68
 
549
- ```ts
550
- import { choice, literal, compile } from 'parseman'
69
+ **When parsing to JS values** — objects, row arrays, AST nodes — **Parséman's macro build is the fastest general-purpose JS parser we benchmark**, beating Peggy, Parsimmon, Chevrotain, Nearley, and Jison at every grammar and size. The only thing that edges it out is a purpose-built native like `JSON.parse`; for anything that *doesn't* have a built-in, Parséman is the one to beat.
551
70
 
552
- const compiled = compile(choice(literal('yes'), literal('no')))
553
- compiled.parse('yes', 0, { trackLines: false }) // { ok: true, value: 'yes', ... }
554
- compiled.source // generated JS source string
555
- compiled.inlineExpression // self-contained expression (what the plugin inlines)
556
- ```
71
+ For **syntax tree building**, the compiled CST path (macro build) beats [Lezer](https://lezer.codemirror.net/) on the JSON CST fixture too — while producing a richer object tree with spans and trivia. For **incremental re-parse**, the two trade places by edit type: Parséman's `makeFunctionalDoc` is ~300× faster than a full reparse on in-place value edits (and ahead of Lezer), while Lezer's buffer reuse wins on structural edits. Full breakdown in the [benchmarks guide](https://matthew-dean.github.io/parseman/guide/benchmarks).
557
72
 
558
- > **CSP:** `compile()` uses `new Function` under the hood, so it cannot run in environments with a strict [Content Security Policy](https://www.greadme.com/blog/security/what-is-content-security-policy-complete-guide) that blocks `'unsafe-eval'`. Use the interpreter or the macro build plugin instead.
73
+ Measured on Apple M2 Pro. Bars show µs per parse — shorter is faster. Refresh: `pnpm bench`, then `pnpm bench:svg` (updates `assets/bench-*.svg`).
559
74
 
560
- ---
75
+ 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/`).
561
76
 
562
- ## ParseResult types
77
+ ![JSON parsing benchmarks](https://raw.githubusercontent.com/matthew-dean/parseman/main/assets/bench-json.svg)
563
78
 
564
- ```ts
565
- type ParseOk<T> = { ok: true; value: T; span: Span }
566
- type ParseFail = { ok: false; expected: string[]; span: Span }
567
- type ParseResult<T> = ParseOk<T> | ParseFail
568
-
569
- type Span = {
570
- start: number // byte offset, inclusive
571
- end: number // byte offset, exclusive
572
- startLine?: number // 1-based; only when trackLines: true
573
- startColumn?: number
574
- endLine?: number
575
- endColumn?: number
576
- }
577
- ```
79
+ ![CSV parsing benchmarks](https://raw.githubusercontent.com/matthew-dean/parseman/main/assets/bench-csv.svg)
578
80
 
579
- ### Node and document types
81
+ ![GraphQL parsing benchmarks](https://raw.githubusercontent.com/matthew-dean/parseman/main/assets/bench-graphql.svg)
580
82
 
581
- Any AST your `transform` callbacks produce participates in incremental re-parsing as long as it satisfies `NodeLike` — that's the whole contract:
83
+ ![JSON CST parsing benchmarks](https://raw.githubusercontent.com/matthew-dean/parseman/main/assets/bench-cst-json.svg)
582
84
 
583
- ```ts
584
- type NodeLike = {
585
- readonly _tag: 'node'
586
- readonly type: string // the rule name — used as the registry key on re-parse
587
- readonly span: Span
588
- readonly state: unknown // ctx.state snapshot at parse time; replayed on edit
589
- readonly children: ReadonlyArray<{ readonly _tag: string }>
590
- }
591
-
592
- // makeFunctionalDoc<N>(registry, rootRule, input, opts?) → FunctionalDoc<N>
593
- type Registry<N> = Record<string, (input: string, pos: number, ctx: ParseContext) => ParseResult<N>>
594
- interface FunctionalDoc<N extends NodeLike> {
595
- readonly tree: N | null
596
- readonly errors: ParseFail[]
597
- readonly input: string
598
- edit(from: number, to: number, replacement: string): FunctionalDoc<N>
599
- }
600
- type FunctionalDocOptions<N> = {
601
- state?: unknown // initial ctx.state for the root parse
602
- rebuild?: (node: N, children: ReadonlyArray<unknown>) => N // override for class-instance ASTs
603
- }
604
- ```
85
+ 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.)
605
86
 
606
- `children` only needs items carrying a `_tag` so traversal can tell sub-nodes (`_tag: 'node'`) from anything else. The `type` string must match the rule name in the registry so `edit()` can re-parse the right rule.
87
+ On JSON, CSV, and GraphQL, Parséman macro beats every other library at every fixture size in the charts above — e.g. GraphQL large **154 µs** vs Peggy **423 µs**, JSON large **125 µs** vs Peggy **472 µs** / Chevrotain **1,946 µs**. Native `JSON.parse` is the one thing faster on JSON (**45 µs** large). Even the zero-setup interpreter beats all five libraries at large sizes. On the CST chart, macro build beats Lezer at every size (**344 µs** vs **587 µs** large, parse-only). Full write-up and how to refresh the charts: **[benchmarks guide](https://matthew-dean.github.io/parseman/guide/benchmarks)**. Grammar-level speed levers: [performance guide](https://matthew-dean.github.io/parseman/guide/performance); library-level codegen: [PERF_IDEAS.md](./PERF_IDEAS.md).
607
88
 
608
89
  ---
609
90
 
@@ -611,12 +92,13 @@ type FunctionalDocOptions<N> = {
611
92
 
612
93
  ```bash
613
94
  pnpm install
614
- pnpm test # Vitest — interpreter + compiler parity + ordered-choice semantics
615
- pnpm typecheck # TypeScript 7
616
- pnpm build # ESM + CJS + .d.ts → dist/
95
+ pnpm test # Vitest — interpreter + compiler parity + ordered-choice semantics
96
+ pnpm typecheck # TypeScript 7
97
+ pnpm build # ESM + CJS + .d.ts → dist/
617
98
  pnpm bench # Parséman vs Peggy, Parsimmon, Chevrotain, Nearley, Jison
618
99
  pnpm bench:svg # refresh assets/bench-*.svg after bench
619
100
  pnpm bench:compile-grammars # regenerate Peggy / Nearley / Jison parser output in bench/
101
+ pnpm docs:dev # run this documentation site locally
620
102
  ```
621
103
 
622
104
  ## License