packages feed

typed-peg-0.2.0.0: CHANGELOG.md

# Changelog

## Unreleased — compile time of large grammars

Checking a grammar was **exponential in the size of its FIRST sets**.  On a
chain of `n` mutually referring rules, GHC needed 0.7 s at `n = 8`, 12 s at
`n = 12`, and more than five minutes at `n = 15`; anything the size of a real
language front end never finished.  The same grammars now check in
milliseconds-to-seconds and the curve is polynomial: `n = 12` takes 0.5 s,
`n = 30` 1.7 s, `n = 60` 14 s.

One limit is new rather than fixed: the union of two FIRST sets nests one
type-family reduction per element of the result, so a FIRST set of more than
about a hundred non-terminals now reports `Reduction stack overflow` instead
of being slow.  `-freduction-depth=0` lifts it, and a union of two 128-element
sets then takes about 0.3 s.

### Fixed

- **`Union` and `ConsIfAbsent` were exponential.**  `ConsIfAbsent x xs`
  expanded to `If (Elem x xs) xs (x ': xs)`, naming `xs` three times.  In
  `Union (x ': xs) ys = ConsIfAbsent x (Union xs ys)` that `xs` is an
  unreduced `Union`, so each step left GHC three copies of the pending
  computation to reduce and each of those tripled again: `3^n` reductions for
  a union of two `n`-element sets.  Both families now dispatch on an
  already-computed `Ordering` in a helper whose every right-hand side names
  each argument — and in particular the recursive call — exactly once.
- **`Lookup` threaded the whole environment through its recursion** so that
  the not-found case could list the available non-terminals.  An environment
  of `n` rules is `O(n^2)` type nodes, because every entry carries a FIRST
  set, and there is one lookup per occurrence of every non-terminal.  The
  search now carries only the tail it has still to scan; the environment is
  named once, in the branch that reports the error.
- **`Lookup` matched through `CmpSymbol` and a dispatch family**, two
  type-family reductions per entry scanned.  It now matches on a non-linear
  pattern — the name appears twice in the clause — so GHC decides each entry
  by syntactic equality and apartness, in one reduction.  The trick is
  `Data.Type.Map`'s, from `type-level-sets`.  Worth 1.4x-1.6x on a large
  grammar, since the search runs once per occurrence of every non-terminal.
- **`nt` and `PExp`'s `NT` made GHC search the environment several times per
  occurrence.**  Their constraint was
  `KnownMember n env (TyOf (Lookup n env)) (ResOf (Lookup n env))`, and
  resolving `KnownMember` walks `env` one instance at a time, re-normalising
  every index at each step.  Both now name the entry once, through a
  `Lookup n env ~ 'EnvEntry ty a` equality, and pass the resulting rigid
  types to `KnownMember`.

### Changed

- **Breaking.  A FIRST set is now written in alphabetical order**, and a
  declared environment that lists one in any other order is a type error
  naming the first position that disagrees.  Sortedness is what makes a set
  have a single spelling, which is what lets `Union` be one merge pass.
  Migration is mechanical: sort each `'[...]` in your `Env`, so
  `'["term", "factor", "number"]` becomes `'["factor", "number", "term"]`.
- **Breaking.** `Member` and `KnownMember` lose their `Ty` index:
  `Member s env a` and `KnownMember s env a`.  Every index of a class is
  carried along and re-normalised at each step of the instance chain that
  walks the environment, and a `Ty` carries a FIRST set — so an index for it
  made each step cost `O(|env|)`.  Nothing needed it; `Here` binds the
  entry's `ty` existentially, which is enough to pull a rule out of a rule
  table.
- `PEG.Syntax` exports `NTGo`, the `Ty` of a reference to a non-terminal
  whose own `Ty` is already known.  `NTTy n env` is now defined as
  `NTGo n (TyOf (Lookup n env))` and keeps working in signatures.

`SeqTy` and `ChoiceTy` are deliberately **unchanged**.  They duplicate their
operands across their right-hand sides too, but measurement says that costs
nothing here, and writing them as type synonyms is what makes them reduce to a
`'MkTy` head while their operands are still abstract — which is what lets a
polymorphic combinator such as `lexeme` compose without its caller having to
get the nesting of `SeqTy` exactly right.

## 0.2.0.0 — 2026-09-04

This release is **not source-compatible with 0.1.0.0**: `PExp`, `Rules`,
`Grammar`, `Result` and `PState` all gain a leading stream type parameter, and
a rule whose result is a character-class repetition changes result type. See
*Changed* below for the migration.

### Added — parsing any stream, not just `String`

`PEG.Stream` introduces a `Stream` class, with instances for `String`, strict
and lazy `Data.Text.Text`, and strict and lazy `Data.ByteString.ByteString`.
A grammar written once runs over any of them.

The genericity reaches the *results*, not just the input: a character-class
repetition such as `cs:[a-zA-Z0-9_]+` now produces a **chunk of the input
stream** — a real `Text` slice — instead of unpacking into a `[Char]`.  Two new
`PExp` constructors, `Span` and `Span1`, carry this; the quasi-quoter emits
them for `[...]*`, `[...]+`, `'c'*`, `'c'+`, `.*` and `.+`.

`ByteString` is read as Latin-1, exactly as `Data.ByteString.Char8` does: fast,
correct for ASCII, and wrong for multi-byte UTF-8.  `PEG.Stream`'s Haddock
states this as a law rather than a footnote.

Only `unconsS` has no default, so a user instance is one method.  It returns an
unboxed sum rather than `Maybe (Char, s)` on purpose — behind a class
dictionary the boxed version would allocate a `Just` and a pair for every
character, losing the zero-allocation terminal path.

### Changed

- **Breaking.** `PExp`, `Rules` and `Grammar` take a leading stream parameter:
  `PExp s env ty a`, `Rules s env defs`, `Grammar s env ty a`.  `Result` and
  `PState` likewise: `Result s a`, `PState s`.
- **Breaking.** A rule whose result is a character-class repetition now has
  result type `s`, so its `Env` synonym takes a parameter.  Semantic actions
  that fed such a result to something expecting a `String` need
  `chunkToString`: `number <- ds:[0-9]+ { Lit (read (chunkToString ds)) }`.
- **Breaking.** The symbol variable in `PExp`'s `NT`, in `nt`, and in
  `Rules`'s `RCons` is now named `n`; `s` is the stream.  `nt @"name"` is
  unaffected — the name is deliberately still the first quantified variable.
- `PEG.Semantics.Simple`'s unrelated `Stream` class is renamed
  `SimpleStream`, to leave the name to `PEG.Stream`.
- `PState`'s input field is now strict.

A `Grammar` is monomorphic in its stream.  Reusing one across stream types
needs a `forall s. Stream s => Grammar s env ty a` signature, which turns the
value into a function of a dictionary and so stops the compiled parser being
shared between calls.  Give parsers a monomorphic top-level binding where that
matters; `PEG.Parse`'s Haddock spells this out.

### Performance

Measured on the benchmark suite, bytes allocated per input byte, against the
previous release of the evaluator:

| grammar | before (String) | String | Text | ByteString | megaparsec |
|---|---|---|---|---|---|
| arith  | 990 | 943 | 1127 |  969 | 1239 |
| csv    | 834 | 787 |  951 |  805 | 1035 |
| json   | 459 | 404 |  583 |  452 |  782 |
| nested | 265 | 312 |  481 |  336 | 1283 |
| quoted `(!'"' .)*` | 162 | 209 | 320 | 250 | 128 |

`ByteString` is the cheapest column on five of the seven grammars and beats
megaparsec on six.  `Text` costs more than `String` throughout — the same
result the earlier study found for megaparsec, and worth knowing before
reaching for it.

Two grammars regressed on `String` (`nested` +18%, the `(!'"' .)*` idiom
+29%).  Both are dominated by single-character steps rather than bulk scans,
where `unconsS` is one indirect call that the previous direct cons-cell match
did not need.  The five grammars that do any bulk scanning improved by 5-12%.

The `idents` and `quoted [^"]*` groups are not in the table because their
grammars changed: `ident` moved from `c:[a-zA-Z_] cs:[a-zA-Z0-9_]*` to
`&[a-zA-Z_] cs:[a-zA-Z0-9_]+` so that it returns a chunk rather than consing a
character onto one, and `Bench.Mega`'s `identP` moved to `takeWhile1P` to keep
the comparison like-for-like.  On the new grammars typed-peg allocates 100
B/byte over `String` and 84 over `ByteString`, against megaparsec's 179.

### Performance

The evaluator was rewritten twice: once around a compilation step, once around
an unboxed step result.  On the benchmark suite in `bench/` (see `cabal bench`),
measured against megaparsec 9.8 in the same run, typed-peg went from taking
2.1x-58x the time megaparsec takes to taking 0.95x-1.17x of it — and it now
allocates less than megaparsec on six of the seven grammars.  The one grammar
where it still loses is the `(!'"' .)*` idiom, which scans every character
twice by construction; written as `[^"]*` it costs 1.23x-1.35x.

- A compiled step returns an unboxed sum, `(# (# #) | (# a, PState #) #)`,
  rather than `Maybe (a, PState)`.  The two are isomorphic, but the unboxed
  sum travels in registers, so a step that succeeds no longer allocates a
  `Just` *and* a pair on top of the new state, and a step that fails
  allocates nothing at all.  This makes `Seq` and `Map` — the two
  constructors the quasi-quoter emits for every grammar item — completely
  allocation-free, and cuts total allocation by a further 9–53%.
- String literals match in a single loop that builds one `PState`, rather
  than one per character, whenever the grammar does not use layout.
- `PEG.Parse` now *compiles* a `Grammar` into a closure once, instead of
  walking the `PExp` GADT and the rule list on every step.  Resolving a
  non-terminal is now one indirect call rather than a linear scan of the rule
  environment.  `parseWith opts g` is written so that partially applying it
  yields the compiled parser; bind it to a name to reuse it.
- Character classes compile to a single `Sat` node holding a `PEG.CharSet`
  (a 256-bit bitmap), instead of expanding into a chain of ordered choices.
  Matching one character of `[a-zA-Z0-9_]` used to cost 63 parser steps.
- String literals compile to a single `Str` node instead of a chain of
  `Seq`/`Map`/`Term`.
- The parser no longer builds a `[(Char, Int)]` copy of the input; the column
  of the current character is carried in the state and updated incrementally.
- Terminals take a fast path that skips all interval arithmetic when the
  ambient column relation is total (`anyR`), which is the case for every
  grammar that does not use layout.  The new `rdTotal` field of `RelD` records
  this.
- `parse` returns the unconsumed suffix in `O(1)` instead of recomputing it
  with two `length` calls and a `drop`.
- `Star` no longer builds a chain of selector thunks.

### Added

- Negated character classes in the quasi-quoter: `[^"]` matches any character
  other than a quote.  Previously the only way to write this was
  `(!'"' .)`, which scans every character twice — once for the lookahead and
  once for the dot.  On the `quoted` benchmark the class form halves the
  allocation.
- `PEG.CharSet`: compact character sets, re-exported from `PEG`.
- `PEG.Syntax.Sat` / `PEG.Syntax.Str` constructors, and the `sat`,
  `charClass` and `notCharClass` smart constructors.
- `PEG.Parse.compileGrammar` and the `Step` and `Res` types, for callers that
  want the compiled parser directly.
- `PEG.Indent.rdTotal`.
- A criterion benchmark suite comparing typed-peg with megaparsec
  (`bench/`, run with `cabal bench`).
- `examples/Compat.hs`: a differential battery used to check that the
  optimisation work did not change any observable behaviour.

### Changed

- **Breaking.** `PState` now holds the remaining input as a `String` plus the
  current column and offset (`stInput`, `stCol`, `stOff`), rather than a
  precomputed `[(Char, Int)]`.  `PEG.Parse.Input`, `PEG.Parse.columns` and
  `PEG.Parse.eval` are gone; use `compileGrammar` instead of `eval`.
- **Breaking.** `RelD` has a new `rdTotal` field.
- **Breaking.** `PEG.Parse.Step` now returns the unboxed sum `Res a` instead
  of `Maybe (a, PState)`.  This only affects code that called
  `compileGrammar` directly; `parse` and `parseWith` are unchanged.
- **Breaking.** In a quasi-quoted grammar, a `^` immediately after `[` now
  negates the class instead of standing for itself; write `[\^]` for a class
  containing a caret.
- The `template-haskell` upper bound now admits the version shipped with
  GHC 9.10 (`< 2.24`).

## 0.1.0.0 — 2026-08-28

### Added

- Initial release.
- Type-safe PEG parser combinators with compile-time left-recursion detection
  via type families (`PEG.Grammar`).
- FIRST-set and nullability information tracked at the type level (`PEG.Type`,
  `PEG.TyLevel`).
- Indentation-sensitive parsing primitives (`PEG.Indent`).
- Quasi-quoter `pegRules` for writing grammars in a concrete DSL (`PEG.QQ`).
- Simple semantics interpreter (`PEG.Semantics.Simple`).