typed-peg: Type-safe PEG parser combinators

[ bsd3, library, parsing ] [ Propose Tags ] [ Report a vulnerability ]

A library for building Parsing Expression Grammars parsers with compile-time safety guarantees. A non-terminal reference is a key whose type is the result of the rule it names, so references are checked by the type checker in time linear in the size of the grammar; left recursion, a repetition that cannot consume input, an undefined non-terminal and a duplicate rule are reported when the grammar is spliced, naming the rule. . A quasi-quoter (PEG.QQ) allows writing grammars in a concrete DSL syntax. Indentation-sensitive parsing is supported natively via PEG.Indent. . Parsers run over any PEG.Stream instance: String, strict and lazy Text, and strict and lazy ByteString. A character class produces a chunk of the input stream, so matching [a-z]+ against a Text yields a slice rather than a [Char].


[Skip to Readme]

Downloads

Maintainer's Corner

Package maintainers

For package maintainers and hackage trustees

Candidates

  • No Candidates
Versions [RSS] 0.1.0.0, 0.2.0.0, 0.3.0.0, 0.4.0.0
Change log CHANGELOG.md
Dependencies base (>=4.18 && <5), bytestring (>=0.11 && <0.13), containers (>=0.6 && <0.8), template-haskell (>=2.19 && <2.24), text (>=2.0 && <2.2) [details]
Tested with ghc ==9.6.6 || ==9.10.3
License BSD-3-Clause
Author Rodrigo Ribeiro
Maintainer rodrigo.ribeiro@ufop.edu.br
Uploaded by rribeiro at 2026-09-14T00:21:58Z
Category Parsing
Home page https://github.com/rodrigogribeiro/typed-peg
Bug tracker https://github.com/rodrigogribeiro/typed-peg/issues
Source repo head: git clone https://github.com/rodrigogribeiro/typed-peg
Distributions
Downloads 13 total (13 in the last 30 days)
Rating (no votes yet) [estimated by Bayesian average]
Your Rating
  • λ
  • λ
  • λ
Status Docs available [build log]
Last success reported on 2026-09-14 [all 1 reports]

Readme for typed-peg-0.4.0.0

[back to package description]

typed-peg

Type-safe PEG (Parsing Expression Grammar) parser combinators for Haskell.

Grammar non-terminals are checked at the type level — a reference is a key whose type is the result of the rule it names — and left-recursive grammars are caught when the grammar is written rather than looping at runtime. Checking a grammar costs GHC time and memory linear in its size: a 1024-rule grammar type-checks in about a second.

Features

  • Non-terminal references checked at the type level
  • Left recursion, a repetition that cannot consume input, an undefined non-terminal and a duplicate rule reported at the splice, naming the rule
  • Indentation-sensitive parsing (PEG.Indent)
  • Quasi-quoter for concrete grammar syntax (PEG.QQ)
  • Parses any PEG.Stream: String, strict/lazy Text, strict/lazy ByteString

Input streams

A grammar is written once and runs over any stream:

import qualified Data.Text as T

parse arith "1+2*3"              -- Result String Exp
parse arith (T.pack "1+2*3")     -- Result Text   Exp

Character classes produce a chunk of the stream, not a [Char]: matching [a-z]+ against a Text yields a Text slice and copies nothing. Semantic actions that want a String ask for one:

number <- ds:[0-9]+   { Lit (read (chunkToString ds)) }
strlit <- '"' cs:[^"]* '"'   { cs }     -- :: s, no copy

Only unconsS has no default, so adding a stream is one method.

ByteString is read as Latin-1, like Data.ByteString.Char8: fast and correct for ASCII, wrong for multi-byte UTF-8. Decode to Text if that matters.

A Grammar is monomorphic in its stream. To reuse one across several, give it a forall s. Stream s => Grammar s (Env s) A signature — but note that makes it a function of a dictionary, so the compiled parser is no longer shared between calls. Bind a monomorphic parser where that matters:

arithString :: String -> Result String Exp
arithString = parse arith
{-# NOINLINE arithString #-}

Quick start

import PEG
import PEG.QQ (pegGrammar)

data Exp = Lit Int | Add Exp Exp | Mul Exp Exp

[pegGrammar|
  %name  arith
  %start expr

  expr   :: Exp <- t:term ts:(o:[+] u:term)*     { foldl addOp t ts }
  term   :: Exp <- f:factor fs:(o:[*] g:factor)* { foldl addOp f fs }
  factor :: Exp <- n:number / '(' e:expr ')'
  number :: Exp <- ds:[0-9]+ { Lit (read (chunkToString ds)) }
|]

That declares the grammar's key type, one constructor per rule,

data ArithEnv s a where
  ArithEnv_expr   :: ArithEnv s Exp
  ArithEnv_term   :: ArithEnv s Exp
  ...

a binding per rule (arith'expr :: Stream s => PExp s (ArithEnv s) Exp, ...), and arith :: Stream s => Grammar s (ArithEnv s) Exp. Run it with parse arith "1+2*3". The module needs GADTs.

A rule's result type is the one thing the grammar does not determine — it comes from the Haskell in the semantic action — which is what the :: T annotations are for. They are claims, and GHC checks them: each is the signature of that rule's binding, so an annotation that disagrees with what the body returns is a type error reported against the rule.

A grammar that needs a value from outside — a file name for error positions, a table of operators — takes it as a parameter:

[pegGrammar|
  %name   lang
  %stream String
  %param  file :: FilePath
  %start  program
  ...
|]
-- lang :: FilePath -> Grammar String (LangEnv String) Program

pegRules remains, for a rule set that is only part of a grammar or that is combined with hand-written PExp combinators. Its non-terminals are a type-level list written out by hand, and the grammar is a Grammar s (InEnv env) a; examples/Compat.hs and examples/Patterns.hs show that style. See examples/Arith.hs, examples/Layout.hs and examples/MiniPython.hs for the generated one.

Grammar size, and what is checked where

A grammar's size shows up as compile time, because every reference in it is a constraint GHC has to solve. How much each one costs depends on what the reference points into.

A declared key type — what pegGrammar generates — makes a reference a constructor, NT ArithEnv_term, whose type GHC checks without looking at the rest of the grammar. The cost is linear in the number of rules (ghc -fno-code, GHC 9.10; bench-compile/):

rules through a key type through a type-level list
64 0.44 s, 49 MiB 1.10 s, 375 MiB
128 0.50 s, 51 MiB 5.74 s, 2 298 MiB
256 0.57 s, 80 MiB exhausts 8 GB
1024 1.17 s, 159 MiB

The 27-rule MiniPython grammar in examples/ takes 0.43 s and 57 MiB.

A type-level list is what nt @"expr", pegRules and pegGrammar in expression position use. A reference carries a proof of where its rule sits in the list, There (There Here), and GHC's evidence for it grows with the rule's depth and the size of the list, so the total grows faster than the grammar. An entry of that list is a rule's name and the type it returns:

type CalcEnv =
  '[ '("expr" , 'EnvEntry Expr)
   , '("term" , 'EnvEntry Expr)
   , '("unary", 'EnvEntry Expr)
   , '("atom" , 'EnvEntry Expr)
   ]

Entries used to carry more: each rule's nullability and its FIRST set, the non-terminals that can begin it. That is what made left recursion a type error — an Acyclic constraint checked that no rule was in its own FIRST set — and it was also, measurably, the entire cost of compiling a large grammar. A FIRST set grows with the grammar, so the environment was quadratic in the number of rules, and each of the two reference constraints per rule was solved against the whole of it. Removing it took a 64-rule grammar from 15 s to 2 s, and a 128-rule one from more than two minutes to 5 s. bench-compile/ has the measurements.

Nullability and FIRST sets are still computed — by PEG.Analysis, in ordinary Haskell, when the quasi-quoter runs, in time linear in the grammar. It is what reports left recursion, a nullable repetition, an undefined non-terminal or a duplicate rule from the splice, naming the rule and the chain of head references that closes the cycle:

Arith.hs:8:13: error: [GHC-39584]
    • pegRules:
      left-recursive non-terminal: expr
        the cycle is expr -> term -> factor -> expr
        a PEG cannot backtrack into a committed choice, so this rule
        would not consume input before calling itself

A PEG does not memoise, so A B / A C parses A twice when B fails. The quasi-quoters factor such alternatives into A (B / C) when they are consecutive, which keeps a grammar whose precedence levels are written that way linear in the input rather than exponential in its nesting.

So the checks divide like this:

what checked by when
a reference names a rule that exists, at the right type GHC, or the splice for pegGrammar every compilation
a rule's :: T annotation matches what its body returns GHC every compilation
left recursion, nullable repetition, duplicate rule PEG.Analysis at the splice

The second half of that table is the trade. A Rules chain assembled by hand from RCons, without a quasi-quoter, is checked for reference errors only: a rule that begins with itself compiles and loops. And pegRules analyses its block open-world, since two blocks can be combined, so left recursion that closes across two blocks is reported by neither it nor GHC. Writing the grammar as one pegGrammar closes both gaps — it is closed-world, so every reference resolves and every cycle is visible — and it is also the fastest to compile, because it declares a key type instead of a list.

Since nothing recomputes what PEG.Analysis concludes, the typed-peg-analysis test-suite checks it against a separate statement of what nullability and a FIRST set mean, over the grammars in examples/ and a few hundred generated ones.

Patterns

peg-patterns.md works through patterns for specifying languages with PEGs and this library, following Willis and Wu's Design Patterns for Parser Combinators (Haskell 2021) and noting where a PEG differs — committed choice, left recursion as a type error, keywords as negative lookahead — and where typed-peg cannot yet follow. Every fragment in it compiles, in examples/Patterns.hs.

Building

cabal build

Examples

cabal test typed-peg-examples

Benchmarks

bench/ holds a criterion suite that measures typed-peg against megaparsec on seven grammars (arithmetic expressions, CSV, identifier lists, a mini JSON, deeply nested parentheses, and quoted strings spelled two ways) written twice, rule for rule. Both libraries consume byte-identical inputs, and the suite cross-checks that they produce the same result before timing anything.

cabal bench

cabal bench --benchmark-options=--alloc prints bytes allocated per parse instead of running criterion; allocation is the number that separates the two libraries most clearly once the algorithmic differences are gone.

On GHC 9.10.3 against megaparsec 9.8.1, bytes allocated per input byte on the largest input of each group:

grammar typed-peg String Text ByteString megaparsec String
arithmetic 943 1127 969 1239
CSV 787 951 805 1035
identifiers 100 190 84 179
JSON 404 583 452 782
nested parens 312 481 336 1283
'"' [^"]* '"' 90 167 65 128
'"' (!'"' .)* '"' 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 study found for megaparsec, so reach for it for interoperability rather than for speed.

Allocation is deterministic and reproduces exactly. Time is the noisier measurement: on a machine with heterogeneous cores, unpinned runs of the same megaparsec binary varied by up to 1.8x, so only the ratio taken within one run is meaningful.

The reference implementation is Bench.Peg; its megaparsec twin is Bench.Mega. Since PEG ordered choice backtracks unconditionally while megaparsec's <|> does not, every megaparsec alternative that can consume input before failing is wrapped in try, so the two are recognising the same language.

Parsing many inputs

parseWith opts grammar traverses the grammar and returns a compiled closure. Bind it once and reuse it, rather than calling parse grammar input inline in a loop:

myParser :: String -> Result Exp
myParser = parse myGrammar

License

BSD-3-Clause. See LICENSE.