This document captures the current design for converting pest grammars into marser parser code.
Build a tool that:
- Parses a pest grammar file using the marser meta-grammar in
src/grammar.rs. - Validates and normalizes the grammar into a semantic representation.
- Resolves pest matching semantics: rule references, implicit whitespace, modifiers, recursion, and builtins.
- Emits marser grammar code as Rust source.
v1 target: strict language equivalence for a useful subset of pest. For the chosen entry rule, the generated parser should accept and reject the same complete inputs as pest. Output type is () everywhere. Pest-like parse tree shape is out of scope initially.
The entry rule is explicit. The CLI/user supplies it; do not infer it from SOI / EOI usage or from rule order.
PUSH/POP/DROP/PEEK/PEEK_ALLstack features.- Pest
Pairoutput,Ruleenum output, spans, tags, or token preservation. - Error message parity with pest.
- Generated helper functions per rule.
- Generated
.memoized()calls.
Unsupported constructs that can affect matching are hard conversion errors, not warnings.
pest source
-> strict AST parse (parse_pest_grammar + end_of_input)
-> rule table + duplicate-name validation
-> explicit entry rule validation
-> raw AST -> normalized Expr tree
-> builtin / unsupported feature validation
-> progress analysis and pest-style repetition validation
-> WHITESPACE / COMMENT validation
-> left-recursion validation
-> effective context propagation (NormalWs / AtomicNoWs)
-> specialized symbol graph
-> SCC analysis (Tarjan) + condensation topo order
-> marser codegen (one grammar() function)
The current parser AST may stay as the syntax-level representation. All semantic passes and codegen should use the normalized expression tree.
The raw AST stores expressions as flat terms + infix_ops. Normalize this into a semantic tree before validation and codegen.
Pest precedence, low to high:
|choice~sequence- prefix
&/!predicates - postfix
?*+{n...} - grouping
Suggested normalized shape:
enum Expr {
Empty,
Builtin(Builtin),
RuleRef(String),
Literal(String),
InsensitiveLiteral(String),
Range { start: char, end: char },
Sequence(Vec<Expr>),
Choice(Vec<Expr>),
Prefix { op: PrefixOp, expr: Box<Expr> },
Postfix { expr: Box<Expr>, op: PostfixOp },
}leading_choice: true becomes an empty alternative: | a | b is Choice([Empty, a, b]).
Tags can be preserved in the normalized tree if useful for future phases, but v1 output is (), so tags do not affect matching.
Pest has implicit whitespace and context-sensitive rule behavior. Marser requires explicit whitespace parsers and has no pest atomic/silent primitives.
For v1, track only matching behavior, not output/token behavior. There are two effective contexts:
| Context | Meaning |
|---|---|
NormalWs |
implicit whitespace is active where pest inserts it |
AtomicNoWs |
implicit whitespace is disabled |
Rule modifiers map to v1 matching contexts as follows:
| Pest modifier | v1 matching behavior |
|---|---|
| none | inherit caller context |
_ silent |
inherit caller context; output suppression is irrelevant for () |
@ atomic |
force AtomicNoWs and cascade into normal/silent callees |
$ compound atomic |
same as @ for v1 accept/reject behavior |
! non-atomic |
force NormalWs, stopping atomic cascade |
$ and @ differ for pest token output, but not for v1 language equivalence.
- Inserted only if the grammar defines
WHITESPACEand/orCOMMENT. - Inserted only in
NormalWs. - Inserted between
~sequence operands. - Inserted between repetitions of
*,+, and ranged repetitions when the repeated item appears more than once. - Not inserted at the start or end of a rule body.
- Not automatically inserted inside the generated
ws()loop itself.
WHITESPACE and COMMENT are unit rules. Pest repeats them implicitly. Generate a synthetic whitespace parser equivalent to:
many(one_of((whitespace_unit, comment_unit)))Do not lower a WHITESPACE / COMMENT rule as though it already consumes unbounded whitespace unless the user explicitly wrote that, and reject definitions that can succeed without consuming input.
| Pest | Marser sketch |
|---|---|
a ~ b |
capture!((a, ws(), b) => ()) when NormalWs |
a ~ b |
capture!((a, b) => ()) when AtomicNoWs |
item* |
zero matches or item (ws item)*; no leading/trailing ws |
item+ |
item (ws item)*; no leading/trailing ws |
item? |
optional(item) |
{n} / {n,} / {,n} / {m,n} |
generate explicit repetition helpers or expansions with the same whitespace-between-items rule |
| Pest | Marser |
|---|---|
~ |
sequence, with explicit ws() between operands in NormalWs |
| ` | ` |
& / ! prefix |
positive_lookahead / negative_lookahead |
^"..." |
ASCII-only case-insensitive matcher |
'a'..'z' |
'a'..='z' |
ANY |
AnyToken |
SOI |
start_of_input() |
EOI |
end_of_input() |
Avoid commit_on in generated code unless proven semantics-preserving. Pest uses ordered choice with backtracking.
Treat supported pest builtins as reserved symbols unless the user defines a rule with the same name. User-defined rules win.
Supported v1 builtins:
SOIEOIANY
Also support common ASCII builtins by injecting/lowering synthetic rules only when referenced:
NEWLINEASCII_DIGITASCII_NONZERO_DIGITASCII_BIN_DIGITASCII_OCT_DIGITASCII_HEX_DIGITASCII_ALPHA_LOWERASCII_ALPHA_UPPERASCII_ALPHAASCII_ALPHANUMERIC
Unknown builtins and unsupported grammar-extras are hard conversion errors.
The converter should match pest's validator intent, not merely rely on marser runtime safeguards.
Compute expression properties such as:
can_match_emptycan_succeed_without_progressmust_consume_on_success
Use these to reject repeated expressions that can succeed without consuming in a way that makes repetition unsafe. Marser's many and one_or_more stop on no progress, but generated parsers should represent pest-valid grammars only.
Always reject WHITESPACE and COMMENT rules that can succeed without consuming input, because pest implicitly repeats them.
Reject direct and indirect left recursion in v1. Report the recursion cycle when possible, for example expr -> term -> expr.
Right recursion and non-left mutual recursion can be supported through marser recursive / recursiveN.
After normalization and validation, propagate effective matching context from the explicit entry rule.
Specialized graph nodes are (rule_name, context) where context is NormalWs or AtomicNoWs, unless the rule's definition modifier forces one context:
- none /
_: inherit call-site context @/$: forceAtomicNoWs!: forceNormalWs
Generate only the specialized variants that are reachable from the chosen entry rule. If a normal rule is used only from atomic context, only its atomic variant is needed.
Equivalent trivial variants may be collapsed later as an optimization, but correctness should come from context propagation, not from heuristic duplication.
At call sites, reference the correct specialized binding directly. Do not generate a runtime context parameter.
Run Tarjan on the specialized symbol graph. The condensation graph is a DAG and should be emitted in dependency order.
| SCC shape | Codegen |
|---|---|
| Single node, no self-loop | Plain local let binding |
| Single node, self-loop | recursive |
| Size 2 through 12 | recursive2 through recursive12 |
| Size greater than 12 | hard conversion error in v1 |
Mutual recursion cannot be split into unrelated top-level definitions. All members of a cyclic SCC must be made available through recursive handles.
For SCCs larger than marser's recursive12, report a clear v1 limitation. Nested recursive-block codegen may be explored later, but it is not part of the initial implementation.
v1 emits one public function per converted pest grammar:
pub fn grammar<'src>() -> impl Parser<'src, &'src str, Output = ()> + Clone {
// 1. synthetic builtin helpers, if referenced
// 2. ws / comment unit parsers and synthetic ws()
// 3. acyclic specialized symbols needed by recursive bodies
// 4. recursive SCC blocks
// 5. acyclic specialized symbols that depend on recursive symbols
// 6. exact selected entry rule
}grammar() should represent the exact chosen pest entry rule. It should not automatically wrap the entry rule in SOI / EOI; marser's Parser::parse_str and parse_whole_input already enforce whole-input parsing for callers that use those APIs. If the pest grammar explicitly contains SOI or EOI, lower them as ordinary anchors.
- Emit local bindings inside
grammar()for v1. - Use
.erase_types()proactively at generated rule / specialized-symbol boundaries if it compiles cleanly with marser. - Do not emit
.memoized()anywhere in v1. - Sanitize generated Rust identifiers, including Rust keywords and duplicate names introduced by specialization.
- Preserve
//!////docs as Rust comments when practical.
Helper functions per rule are a future readability/compile-time optimization, not part of v1.
Accumulate validation errors where practical and report them together. Stop a phase early only when later analysis would be ambiguous or misleading.
Validation should cover:
- duplicate rule names
- missing or unknown explicit entry rule
- undefined rule references
- supported builtins vs unsupported builtins
- unsupported stack constructs and other unsupported pest features
- invalid or non-progressing repetitions
- nullable
WHITESPACE/COMMENT - left-recursion cycles
- reachability from the explicit entry rule
Warnings:
- specialization split a rule into multiple contexts
- suspicious but valid
WHITESPACE/COMMENTdefinitions that consume more than one unit per invocation
Layered tests:
- Meta-parser: pest grammar parses to AST and consumes all input.
- Normalization: flat
terms+infix_opsbecome the expectedExprtree. - Builtin validation/lowering: supported builtins and injected ASCII helpers.
- Whitespace pass: exact insertion positions for small expressions.
- Progress validation: unsafe repeats and nullable
WHITESPACE/COMMENT. - Left-recursion validation: direct and indirect cycles.
- Specialization: rules reached in normal and atomic contexts.
- SCC/codegen: recursive and mutually recursive clusters, including nested
recursiveN. - Generated-code compile tests: small grammars compile as Rust.
- End-to-end equivalence: converted grammar vs pest on accept/reject corpora.
Start with tiny fixtures before JSON/calc-size grammars.
- Parse pest grammar -> raw AST
- Require full input consumption in
parse_pest_grammar - Rule table + duplicate-name validation
- Explicit entry rule handling
- Raw AST -> normalized
Expr - Builtin classification and synthetic ASCII builtins
- Unsupported feature validation
- Progress analysis and repetition validation
- Left-recursion validation
- Strict
WHITESPACE/COMMENTvalidation and lowering - Two-context modifier model (
NormalWs,AtomicNoWs) - Specialization analysis from the explicit entry rule
- SCC analysis on specialized graph
- Single-function codegen with
.erase_types()boundaries - Hard error for specialized SCCs larger than 12
- Default output type
() - End-to-end equivalence tests
- Typed output.
- Tags and captures.
- Pest-like spans /
Pairtree shape. - Helper functions per generated rule.
- Selective
.memoized()for performance. - Nested recursive-block codegen for SCCs larger than 12.
- Broader grammar-extras / Unicode builtin support.