Skip to content

Grammars the way you'd write them anyway ​

Most parser generators ask you to meet the tool halfway: left-factor your rules, compute FIRST/FOLLOW sets, resolve shift/reduce conflicts, hand-order a lexer so the fast path is fast. Parséman asks for none of that. You write the grammar in priority order, alternatives spelled out plainly, and the compiler figures out how to make it fast. This page walks through the places where "the obvious way to write it" and "the fast way to run it" turn out to be the same thing — and names the mechanism behind each one, so you know its limits.

None of this is something you turn on. It's what compile() (and the macro build) do to the grammar you already wrote.

Ordered choice just works (PEG) ​

You list alternatives in the order you want them tried. First one that matches wins.

ts
const value = choice(Dimension, Number, Color, Keyword)

That's PEG ordered choice. There's no separate ambiguity phase, no conflict to resolve, and no need to left-factor two arms that happen to start the same way just to keep the grammar correct. If two arms can both match at a position, the earlier one wins.

When the arms don't overlap, Parséman notices and speeds things up on its own (next section). That's an optimization layered on top of semantics you already control by ordering — not something you have to arrange by hand.

The one deliberate divergence: longest literal wins

Between two bare literal arms where one is a prefix of the other, Parséman takes the longer one regardless of order:

ts
// [verify]
import { choice, literal, parse } from 'parseman'

// Pure PEG would take the earlier arm, '<', and leave '=' unconsumed:
parse(choice(literal('<'), literal('<=')), '<=').value
// → '<='

This is a considered choice, not an accident. An operator table or keyword set written in the obvious order almost never means "match < and strand the =" — and the only alternative is making every author hand-sort their literals by length, forever. Two mechanisms implement it: literalsLongestFirst reorders when every arm is a literal, and autoNot rejects a shorter literal arm when a later arm is a longer literal that also matches. Between them, the rule holds on both the literalsLongestFirst and firstMatch paths, in both engines.

It applies to literals only. The moment the longer alternative is anything else — a sequence, a node, a rule reference — strict ordered choice is back:

ts
// [verify]
import { choice, literal, sequence, parse } from 'parseman'

// The longer alternative is a sequence, not a bare literal → order wins:
parse(choice(literal('<'), sequence(literal('<='), literal('x'))), '<=x').value
// → '<'

So if you want the shorter arm to win, reordering won't get you there while every arm stays a bare literal — they're sorted longest-first before any is tried, so order is moot. Make one of the arms non-literal instead: once the choice is back on the ordered path, the first successful arm wins, so put the shorter arm first when the shorter arm must win. Everything else on this page is strict PEG ordering.

What you avoid: computing FIRST/FOLLOW sets yourself, and rewriting choice(a·x, a·y) into a·(x | y) just to satisfy the tool. Write the arms, order them, done.

The one place order defers to length ​

There's exactly one exception, and it's worth knowing precisely. A choice whose arms are all plain literals — or all literals plus a single regex arm that matches every one of them — resolves by longest match instead of by authored order. For an all-literal choice, whichever alternative consumes the most input wins, and authored order breaks a tie between two literals of equal length. For the one-regex shape, an exact literal match is classified as that literal regardless of arm order — there's no length tie to break. Both engines apply this rule, so the interpreter and the compiled parser agree.

ts
// [verify]
import { choice, literal, regex, parse } from 'parseman'

// All arms are literals: the longest match wins even though `<` is written first.
parse(choice(literal('<'), literal('<=')), '<=').value
// → '<='

// Literals plus one regex that matches them all: the regex's extent decides.
parse(choice(literal('tr'), regex(/[a-z]+/)), 'true').value
// → 'true'

// Any other mix is ordered choice: the first arm that matches wins.
parse(choice(literal('<'), regex(/<=/), literal('!')), '<=').value
// → '<'

Here's why: a bare set of operators or keywords is the one shape where strict PEG ordering is a trap rather than a tool. Under it, choice(literal('<'), literal('<=')) makes <= unreachable, and the only fix is to hand-sort the arms by descending length and keep them sorted forever. Longest-match removes that chore — and it's the same property that lets the whole choice collapse into a single scan (below).

The rule applies only to that narrow shape, so don't build a grammar's correctness on it — adding one sequence() or word() arm makes order load-bearing again. Write the longer arm first regardless, and where a shorter alternative must win, say so with a guard rather than with position. Ordered choice & keywords covers the practical patterns.

Automatic first-set dispatch ​

When the alternatives in a choice start with distinct characters, trying them in order would mean "test arm 1's first char, miss, test arm 2's first char, miss, …" and so on. Parséman skips that at compile time: it computes each arm's first set, confirms they're pairwise-disjoint, and emits single-code-point disjoint dispatch — one input.codePointAt(pos) read and a jump straight to the only arm that can match.

ts
// You write ordinary ordered alternatives:
const combinator = choice(literal('>'), literal('+'), literal('~'), literal('|'))
js
// Codegen emits one read + a jump table (planDisjointDispatch):
const _code = pos < input.length ? (input.codePointAt(pos) ?? -1) : -1
switch (_code) {
  case 62: /* > */ ...
  case 43: /* + */ ...
  case 126: /* ~ */ ...
  case 124: /* | */ ...
  default: /* fail */
}

You didn't design a lexer or write a dispatch table — you wrote four alternatives. The interpreter builds the same thing at runtime as a 128-entry ASCII table (buildAsciiDispatch); the compiler bakes it into a switch or an if/else range chain.

When it kicks in, and where it doesn't:

  • The arms need pairwise-disjoint first sets — no two can start with the same character. If they overlap, the choice stays on ordered firstMatch. Read that as "not O(1)," not as "back to speculative scanning" — see what ordered actually costs below.
  • No arm may match the empty string. A nullable arm matches at any position, so first-char dispatch can't represent it; that choice stays on firstMatch too.
  • The switch jump-table form only kicks in when the arms key off a handful of discrete code points (roughly 3 to 48 cases — SWITCH_MIN_CASES/SWITCH_MAX_CASES/ SWITCH_RANGE_LIMIT in codegen.ts). A wide char-class arm like [a-z]+ would explode into dozens of case labels, so those get the if/else range-comparison form instead.

What ordered actually costs ​

"Falls back to ordered firstMatch" sounds like a cliff. It reads like one if you picture the arms being tried one after another — but that's not what happens in compiled output, which is what ships. compile() and the macro give every arm a single-character guard, computed from that arm's own first set: the same test disjoint dispatch would have used, just asked per arm instead of resolved by one jump. An arm whose first set excludes the current character is skipped on one integer comparison — no entering the arm, no allocation, no rollback mark. And once an arm succeeds, the rest aren't even guard-tested.

The interpreter (parse()) doesn't do this. Its firstMatch loop calls each arm in turn, taking a capture mark before and rolling it back on failure. So the guard costs below describe the compiled engine — the one you measure and ship — not a parse() call in a test.

Measured over four real-world CSS-superset dialect grammars (jess) — 427 choice sites, 363 of them ordered — an ordered choice costs on average 3.8–5.4 guard comparisons per visit and enters 1.02–1.47 arms. Even doubling every guard chain in the compiled output — an upper bound on what any smarter dispatch could give back — doesn't rise above run-to-run noise on a 156 KB stylesheet. Ordered dispatch turns out to be a constant-factor difference, not a category change.

Then what is the cliff?

An arm whose first set is any can't be guarded, so it gets entered speculatively at every position. That's the cost worth chasing, and it's exactly what the gating diagnostic reports. In those grammars, 36% of the arms in ordered choices fall into this category, and they dominate everything the dispatch strategy does. A leading not(...), a nullable prefix, a scanTo, or a rule reference that resolves to any are the usual causes — fix those and the choice gets faster whether or not it ever reaches O(1) dispatch.

Prefix-trie dispatch — grouping arms that collide on character 1 and discriminating on character 2 — was measured against these grammars and not built. The collisions it would resolve are already rare: in 179 of the 333 plain-firstMatch sites the worst character reaches at most 2 guarded arms, and in 87 of those it reaches exactly one. The comparisons it would save are already below the noise floor, and it wouldn't touch the any-arm cost that actually dominates.

Fail-fast without hand-optimizing ​

Ordered PEG has a reputation for doing wasted work: a "try this, else that" grammar speculatively enters an arm, does some setup, then discovers on the first byte that it never had a chance. Parséman removes that cost, so you can write the natural choice(a, b) without pre-optimizing away the misses.

A speculative construct rejects on its first character, before it allocates or mutates anything:

  • many/oneOrMore — at the iteration that terminates the loop, the body's first set is checked before the iteration's collector arrays are allocated.
  • node() — a capture rejects on the first byte before swapping in the CST-capture context and allocating its child/leaf/trivia buffers.
  • attempt(inner) — checks first before taking its rollback marks.
  • optional(inner) — same check, same condition.

The guard applies only where it's sound: the body must have a discrete (non-any) first set and be unable to match empty, so a first-set miss genuinely cannot match. Bailing early is then behavior-identical — it records the same expected token a normal start-failure would, so your error messages don't change. It's skipped under error-recovery mode, where a swallowed failure still needs to feed the completions probe.

Both engines do this. The interpreter's combinators and the compiled output apply the same check on the same soundness condition and the same probe/recovery skip-gates, and they produce byte-identical results.

Literal-heavy choices collapse to one scan ​

Write a pile of keyword alternatives the obvious way, and Parséman recognizes the shape and — in the compiled output — collapses the whole choice to a single scan-and-classify, no arm-by-arm backtracking. The interpreter detects the same two shapes and skips backtracking's rollback machinery too, but it still calls each sorted literal in turn, so a shared prefix can be read more than once there; the single-scan guarantee is specifically a compiled-engine property. It detects two shapes (detectStrategy):

All arms are literals → literalsLongestFirst. Sorted longest-first (so >= beats >), tried without re-scanning shared prefixes.

ts
const op = choice(literal('<='), literal('<'), literal('>='), literal('>'))

One regex arm subsumes a set of literal arms → greedyClassify. You have a general token (say an identifier regex) plus a few keywords that are special cases of it. Parséman runs the regex once, then classifies the matched text by string equality against the literals — one parse call total, zero backtracking.

ts
// `ident` matches everything the keywords match, and more:
const word = choice(literal('true'), literal('false'), ident)
// Runs `ident` once; if the text is exactly "true"/"false" it's that arm, else ident.

Detection here is conservative: greedyClassify requires exactly one regex arm that provably matches every literal arm's value exactly, with every other arm a literal. Anything else falls back to ordered firstMatch.

Shared leading prefixes ​

Sometimes several alternatives genuinely begin with the same token. In a real grammar that shared prefix is usually buried inside your ordinary node(...), trivia (parser({ trivia })), and helper wrappers, not sitting out as a bare literal. Reach for this shape when the shared opener is just syntax common to the arms, and each arm still needs its own ordinary continuation grammar:

ts
// Both arms start by scanning `%%` — but each is wrapped in its own node + trivia:
const BlockDirective = node('BlockDirective',
  parser({ trivia }, sequence(literal('%%'), name, literal('{'), body, literal('}'))),
  buildBlock)

const LineDirective = node('LineDirective',
  parser({ trivia }, sequence(literal('%%'), name, args, literal(';'))),
  buildLine)

const directive = choice(BlockDirective, LineDirective)

Their first sets overlap, so disjoint dispatch can't split them. Left to a naïve ordered firstMatch, every directive would enter the first arm's node() frame, scan %%, discover that continuation doesn't match, roll back, enter the second arm, and scan %% again.

Parséman detects this shape automatically (the sharedPrefix strategy in detectStrategy), recognizes the shared prefix once, and lets each arm replay that already-recognized token instead of re-scanning it. You keep the two readable arms; the compiler does the factoring. You never rewrite them into %%·(block | line) with a hand-merged builder.

This is a scan dedup, not a guaranteed speed-up. How much it saves scales with how expensive the shared prefix is and how many arms would otherwise re-scan it. For a cheap, short prefix like %%, the saving may sit below the noise floor. It starts to matter when the shared prefix does real work — a long literal, or a regex that scans a meaningful token run — and several arms would re-scan it before one wins. Think of it as a correctness-preserving factoring that removes redundant work where redundant work is actually expensive, not a blanket optimization.

When alternatives start by recognizing the same broad lexical family and then branch on the value that comes back, reach for dispatch instead. At-rules, identifier-or-function values, pseudos, contextual keywords, and dialect-specific extensions usually want one opener combinator followed by when(...) arms, not sibling choice(...) arms that rediscover the same token family.

What it sees through. To find the shared leading term, the detector peels away the wrappers that don't consume input or skip trivia before the sequence's first term — node, parser/grammar (any trivia/captureTrivia config), transform, and label. So all of these group, because their inner sequences share a leading terminal:

ts
choice(sequence(literal('%%'), a), sequence(literal('%%'), b))                 // bare
choice(node('A', sequence(literal('%%'), a), rA), node('B', sequence(literal('%%'), b), rB))
choice(node('A', parser({ trivia }, sequence(literal('%%'), a)), rA),        // node + trivia
       node('B', parser({ trivia }, sequence(literal('%%'), b)), rB))
choice(transform(sequence(literal('--'), a), fA), sequence(literal('--'), b))

It's all-or-nothing across the whole choice. detectSharedPrefix requires every arm to be a (wrapped) sequence leading with the same concrete literal or regex. One arm that leads with something else — or isn't a sequence at all — and the entire choice falls back to firstMatch; there's no per-group factoring of the arms that do share. That keeps the strategy easy to reason about and byte-identical, at the cost of missing partial groups. It's rarer than it sounds: across those four dialect grammars, only 7 of the 333 plain-firstMatch sites contain a shared-prefix subgroup at all, and the largest such subgroup is 3 arms.

detectStrategy also picks exactly one strategy per choice. A choice never gets, say, sharedPrefix factoring and a separate optimization for an unrelated group of arms — the first shape that matches, checked in the order greedyClassify → literalsLongestFirst → sharedPrefix, is the one that runs.

How it stays byte-identical. Only the scan is shared. Each arm is otherwise emitted by the ordinary firstMatch machinery, unchanged — it enters its own node() frame, runs its own trivia, builds its own subtree, and rolls back on failure exactly as it would un-factored. The one difference is that the arm's leading terminal replays the once-computed end position and value, and pushes an identical leaf into that arm's own capture scope. So your reducer's children[0] (value and span), the node's trivia log, every other span, and the choice's failure expected set all come out bit-for-bit what the un-factored grammar would have produced — in both the interpreter and the compiled output.

Where the dedup applies. It's a compiled-output transform (both compile() and the macro build), and it stays enabled for linkable/compose fusion (deferFirstSetRefs) too — the shared prefix is always a concrete literal or regex, never a ref, so it's scanned once even in the fused form. The interpreter runs the ordinary firstMatch loop and re-scans the prefix per arm: identical output, natural authoring, no dedup. Replaying the prefix at runtime would mean threading a cache through the core parse() dispatch of every combinator, and the free byte-identity of the firstMatch fallback is worth more than a runtime win on a path that exists mainly for tests and REPLs.

Because the strategy is a faithful specialization of firstMatch, it falls back to plain firstMatch in two cases where the rewrite can't apply: the scope-unsafe case (a grouped arm hoisted into its own function — see the same-scope limit below), and compiles carrying extra per-arm bookkeeping the rewrite doesn't reproduce (coverage tracing and error-recovery). Every fallback is byte-identical to the un-factored choice.

What it conservatively skips. Correctness comes first, so the detector only fires where the factoring is provably behavior-identical:

  • Every arm must peel to a sequence of ≥2 terms whose first term is a bare, case-sensitive literal or a regex. If any arm doesn't (e.g. a bare literal arm, a quoted-string alternative, an arm wrapped in attempt/optional/many/choice), the whole choice stays on firstMatch.
  • Arms group only when their leading terms are byte-equal — the same literal string, or the same regex source and flags.
  • Differently-spelled-but-equivalent prefixes are not unified. regex(/%{2}/) in one arm and literal('%%') in a sibling accept the same strings, but proving that equivalence (and that both produce the same leaf) is not attempted — those arms are left on firstMatch. Likewise a cluster that shares only a first character through differently-spelled tokens (e.g. an @-led at-rule family, one arm literal('@'), another regex(/@media…/)) is not factored.
  • Prefixes hidden behind a rule reference (choice(g.RuleA, g.RuleB), where the shared leading terminal lives one level down inside each referenced rule) are not factored — the detector runs at grammar-construction time and does not resolve refs.
  • Arms that would compile into separate function bodies are not factored. The prefix is recognized once into a variable in the choice's function; if a grouped arm is a shared subtree hoisted into its own _pf function, or a named rule in the linkable/fused form, its replayed prefix would reference that variable out of scope. So the strategy fires only for self-contained, single-function shared-prefix choices; when the arms span a function boundary the choice falls back to firstMatch.

These are places the factoring is skipped, not places where output could diverge. Anything the detector isn't sure about falls back to the ordered firstMatch you'd have had anyway.

Released under the MIT License. Commercial support available on request.