parseman 0.4.0 → 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.
Files changed (2) hide show
  1. package/README.md +33 -6
  2. package/package.json +7 -3
package/README.md CHANGED
@@ -8,7 +8,9 @@ Write parsers in TypeScript — fast enough to run as-is, and blazing fast when
8
8
 
9
9
  ## Benchmarks
10
10
 
11
- 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/`).
12
14
 
13
15
  ![JSON parsing benchmarks](https://raw.githubusercontent.com/matthew-dean/parsecraft/main/assets/bench-json.svg)
14
16
 
@@ -16,9 +18,9 @@ Measured on Apple M2 Pro. Bars show µs per parse — shorter is faster.
16
18
 
17
19
  ![GraphQL parsing benchmarks](https://raw.githubusercontent.com/matthew-dean/parsecraft/main/assets/bench-graphql.svg)
18
20
 
19
- 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.)
20
22
 
21
- 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.
22
24
 
23
25
  ---
24
26
 
@@ -136,6 +138,7 @@ A combinator reads from the input at the current position and succeeds or fails
136
138
  | `sepBy(combinator, sep)` | Zero or more combinator matches separated by `sep`. |
137
139
  | `transform(combinator, fn)` | Map the result: `fn(value, span) → newValue`. |
138
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). |
139
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)`. |
140
143
  | `ref<T>()` | Low-level forward declaration slot (use `rules()` in most cases). |
141
144
  | `not(combinator)` | Negative lookahead — succeeds (consuming nothing) when the combinator fails. |
@@ -153,6 +156,7 @@ A combinator reads from the input at the current position and succeeds or fails
153
156
  | `makeWord(boundary?)` | Returns `(str) => Combinator` with a fixed word-boundary class. Not a combinator — see [keyword disambiguation](#ordered-choice-and-keyword-disambiguation). |
154
157
  | `rules(factory)` | Named, mutually-recursive rule bundle. See [Named and recursive rules](#named-and-recursive-rules). |
155
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. |
156
160
 
157
161
  ---
158
162
 
@@ -177,6 +181,8 @@ Two takeaways:
177
181
 
178
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.
179
183
 
184
+ For library-level codegen and macro optimizations (not grammar authoring), see [PERF_IDEAS.md](./PERF_IDEAS.md).
185
+
180
186
  ---
181
187
 
182
188
  ## Whitespace and comment skipping
@@ -205,6 +211,25 @@ const blockComment = sequence(literal('/*'), scanTo(literal('*/'), []))
205
211
  const ws = trivia(many(choice(regex(/\s+/), lineComment, blockComment)))
206
212
  ```
207
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
+
208
233
  ---
209
234
 
210
235
  ## Ordered choice and keyword disambiguation
@@ -314,7 +339,7 @@ value.define(choice(object, array, str, num, bool, nil))
314
339
 
315
340
  ## Grammars that build an AST
316
341
 
317
- 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, …]` triples in `triviaLog`, 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.
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.
318
343
 
319
344
  ```ts
320
345
  import { rules, parser, node, regex, literal, sequence, many, trivia } from 'parseman'
@@ -338,7 +363,7 @@ Expr.parse('1 + 2 + 3', 0, { trackLines: false })
338
363
 
339
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.
340
365
  - **`rawChildren`** — structural children only (same items as `children`, without trivia tokens).
341
- - **`triviaLog`** — flat `[start, end, insertIdx, …]` triples 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`).
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`).
342
367
 
343
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.
344
369
 
@@ -561,7 +586,9 @@ pnpm install
561
586
  pnpm test # Vitest — interpreter + compiler parity + ordered-choice semantics
562
587
  pnpm typecheck # TypeScript 7
563
588
  pnpm build # ESM + CJS + .d.ts → dist/
564
- pnpm bench # Parséman vs Peggy vs Parsimmon vs Chevrotain
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/
565
592
  ```
566
593
 
567
594
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parseman",
3
- "version": "0.4.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,8 +35,10 @@
35
35
  "test:watch": "vitest",
36
36
  "typecheck": "tsc",
37
37
  "bench": "node --import tsx/esm bench/run.ts",
38
+ "bench:baseline": "node --import tsx/esm bench/update-parseman-baseline.ts",
39
+ "bench:svg": "node --import tsx/esm bench/gen-svg.ts",
38
40
  "build:chevrotain-bench": "node --import tsx/esm scripts/build-chevrotain-bench.mjs",
39
- "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/graphqlite-parser.js bench/graphqlite.pegjs"
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"
40
42
  },
41
43
  "keywords": [
42
44
  "parser",
@@ -51,7 +53,9 @@
51
53
  "@types/node": "~26.0.0",
52
54
  "@types/parsimmon": "~1.10.9",
53
55
  "chevrotain": "~12.0.0",
54
- "esbuild": "~0.28.1",
56
+ "jison": "~0.4.18",
57
+ "moo": "~0.5.3",
58
+ "nearley": "~2.20.1",
55
59
  "parsimmon": "~1.18.1",
56
60
  "peggy": "~5.1.0",
57
61
  "tsup": "~8.5.1",