Embedded expression languages appear in many products: search filters, alerting rules, discount conditions, and access-control configurations. A user writes a string such as:
price * qty > limit && region == "eu"
The program must understand that multiplication happens before comparison, comparison happens before logical &&, and region == "eu" forms the right-hand side of the entire condition.
Evaluation is still a long way off. First, the string must become a tree whose structure already expresses every precedence and associativity rule. A mistake at this stage does not necessarily produce an error message: the parser may build a different valid tree, and the program will quietly evaluate the wrong expression.
An AST, or abstract syntax tree, stores the meaningful structure of an expression. Parentheses and whitespace may disappear, but the links between nodes must make it unambiguous which operands belong to each operator.
Ordinary recursive descent solves this literally: each precedence level gets its own function, and call order replaces a table. A Pratt parser takes a different approach: it keeps one expression-parsing function and represents precedence with two numbers.
Recursive descent is a parsing technique in which grammar rules are implemented as separate functions that call one another. Pratt parsing uses one function to join operators and operands according to their numeric binding powers.
To see the difference without trusting a ready-made formula, we will build both parsers on top of the same lexer and AST model. Then we will count calls and recursion depth, add a new operator to both implementations, and compare their trees across one hundred thousand identical generated expressions. The complete source for the lexer, both parsers, and the differential harness is collected in the appendix.
One Lexer Defines the Shared Boundary
Comparing two parsers only makes sense when they receive identical input. Each token therefore contains exactly two things: a kind and a byte range in the original string.
pub struct Span {
pub start: usize,
pub end: usize,
}
pub struct Token {
pub kind: TokenKind,
pub span: Span,
}
The token does not copy its text. An identifier, number, or operator can always be recovered by slicing the original input at its span.
A span is a half-open byte range,
start..end: the first byte belongs to the token, while the byte atenddoes not. This teaching language accepts ASCII only, so byte offsets match terminal columns; a UTF-8 interface would need to convert bytes into visible positions separately.
For a valid expression, the lexer produces nine tokens. On invalid input, the same range places a caret under the exact fragment:
$ cargo run --release --quiet
expr: price * qty > limit && region == "eu"
kind span text
ident 0..5 price
* 6..7 *
ident 8..11 qty
> 12..13 >
ident 14..19 limit
&& 20..22 &&
ident 23..29 region
== 30..32 ==
string 33..37 "eu"
9 tokens
expr: price @ 2
error: unexpected character '@'
price @ 2
^ bytes 6..7
expr: region == "eu
error: unterminated string literal
region == "eu
^^^ bytes 10..13
The error position originates in the lexer. Later stages can preserve it, but they cannot reconstruct it after it has been lost. From this point on, both parsers receive the same token array with the same spans.
Recursive Descent Turns Precedence into a Call Chain
The language has fifteen binary operators across eight precedence levels, two prefix operators, and parentheses. From weakest to strongest, the route looks like this:
parse_coalesce ??
parse_or ||
parse_and &&
parse_equality == !=
parse_comparison < <= > >=
parse_additive + -
parse_multiplicative * / %
parse_unary - ! (prefix)
parse_power **
parse_primary literals, parentheses
Recursive descent represents each grammar rule as a function. The function for the current level first calls the next stronger level to obtain its left operand, then consumes the operators that belong to it:
fn parse_or(&mut self, depth: u64) -> Result<Expr> {
self.note(depth);
let mut left = self.parse_and(depth + 1)?;
while self.eat(TokenKind::OrOr) {
let right = self.parse_and(depth + 1)?;
left = binary(BinaryOp::Or, left, right);
}
Ok(left)
}
Precedence is not written as a number here. && binds more tightly than || because parse_or calls parse_and, not the other way around. Reaching a literal requires the entry function to descend through the entire chain.
Left-associative operators are assembled in a loop. For example, a - b - c first becomes (- a b), after which the next minus wraps the completed left-hand tree.
Exponentiation with ** is right-associative, so its function has a different shape:
fn parse_power(&mut self, depth: u64) -> Result<Expr> {
self.note(depth);
let left = self.parse_primary(depth + 1)?;
if self.eat(TokenKind::StarStar) {
let right = self.parse_unary(depth + 1)?;
return Ok(binary(BinaryOp::Pow, left, right));
}
Ok(left)
}
The right operand passes through parse_unary again and can encounter another exponentiation operator. As a result, 2 ** 3 ** 2 is parsed as 2 ** (3 ** 2). The same route permits a sign in the exponent: 2 ** -3.
The parser passes eighteen tests covering precedence, associativity, parentheses, and errors:
$ cargo test --release --quiet
running 18 tests
..................
test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
The lexer's span survives all the way to a syntax error:
$ cargo run --release --quiet -- 'price > > limit'
descent: the grammar ladder, one function per precedence level
expr: price > > limit
error: expected an expression, found '>'
price > > limit
^ bytes 8..9
Functionally, recursive descent already solves the problem. Now we can examine the work created by the parser's structure itself.
Even One Literal Traverses Every Level
Every parsing function calls note, which counts calls and records the maximum recursion depth. The tree is printed as an s-expression so that operator grouping is visible directly in the result.
An s-expression writes a tree in prefix form: the operator comes first, followed by its operands inside parentheses. For example,
a + b * cbecomes(+ a (* b c)), so the representation needs no additional precedence rules.
Let us compare one literal, the same literal inside eight pairs of parentheses, and the original filter expression:
$ cargo run --release --quiet -- 42 '((((((((42))))))))' \
'price * qty > limit && region == "eu"'
descent: the grammar ladder, one function per precedence level
expr: 42
sexpr: 42
calls: 10 max depth: 10
expr: ((((((((42))))))))
sexpr: 42
calls: 90 max depth: 90
expr: price * qty > limit && region == "eu"
sexpr: (&& (> (* price qty) limit) (== region "eu"))
calls: 31 max depth: 10
total: 3 expressions, 131 parse calls, deepest recursion 90
The literal 42 contains no operators, yet it costs 10 calls and ten stack levels. Each function checks whether an operator from its level follows, then passes control onward.
Parentheses restart parsing from the weakest level. Eight nested pairs turn the same ten-step ladder into 90 calls and a depth of 90. The stack now reflects not only the expression's real nesting but also the number of grammar levels.
The filter expression takes 31 calls even though its AST contains only four binary operators. Across the complete demonstration set of twelve expressions, recursive descent performs 336 calls.
This is not an implementation bug. It follows directly from the architecture: precedence is encoded by which function calls which.
Pratt Stores Precedence as Numbers
A Pratt parser starts from the same token, but instead of walking a fixed ladder it receives a numeric threshold named min_bp. For every binary operator, a table returns the operator itself and two binding powers, left and right:
fn binding_power(kind: TokenKind) -> Option<(BinaryOp, u8, u8)> {
let row = match kind {
TokenKind::QuestionQuestion => (BinaryOp::Coalesce, 2, 1),
TokenKind::OrOr => (BinaryOp::Or, 3, 4),
TokenKind::AndAnd => (BinaryOp::And, 5, 6),
TokenKind::EqEq => (BinaryOp::Eq, 7, 8),
TokenKind::BangEq => (BinaryOp::Ne, 7, 8),
TokenKind::Lt => (BinaryOp::Lt, 9, 10),
TokenKind::LtEq => (BinaryOp::Le, 9, 10),
TokenKind::Gt => (BinaryOp::Gt, 9, 10),
TokenKind::GtEq => (BinaryOp::Ge, 9, 10),
TokenKind::Plus => (BinaryOp::Add, 11, 12),
TokenKind::Minus => (BinaryOp::Sub, 11, 12),
TokenKind::Star => (BinaryOp::Mul, 13, 14),
TokenKind::Slash => (BinaryOp::Div, 13, 14),
TokenKind::Percent => (BinaryOp::Rem, 13, 14),
TokenKind::StarStar => (BinaryOp::Pow, 16, 15),
_ => return None,
};
Some(row)
}
The larger the number, the more strongly an operator holds on to the adjacent operand. One function performs the entire parse:
fn parse_expr(&mut self, min_bp: u8, depth: u64) -> Result<Expr> {
self.note(depth);
let token = self.advance()?;
let mut left = match token.kind {
TokenKind::Number => Expr::Number(self.slice(token.span).to_string()),
TokenKind::Str => Expr::Str(self.inner_str(token.span)),
TokenKind::Ident => Expr::Ident(self.slice(token.span).to_string()),
TokenKind::Minus => unary(UnaryOp::Neg, self.parse_expr(PREFIX_BP, depth + 1)?),
TokenKind::Bang => unary(UnaryOp::Not, self.parse_expr(PREFIX_BP, depth + 1)?),
TokenKind::LParen => {
let inner = self.parse_expr(0, depth + 1)?;
self.expect(TokenKind::RParen)?;
inner
}
kind => {
return Err(spanned(
format!("expected an expression, found '{kind}'"),
token.span,
));
}
};
loop {
let Some(next) = self.peek() else { break };
let Some((op, left_bp, right_bp)) = binding_power(next.kind) else {
break;
};
if left_bp < min_bp {
break;
}
self.pos += 1;
let right = self.parse_expr(right_bp, depth + 1)?;
left = binary(op, left, right);
}
Ok(left)
}
The first part of the function parses anything that may begin an expression: a literal, identifier, prefix operator, or parenthesized expression. The loop then examines the next binary operator.
If its left binding power is at least min_bp, the current call consumes the operator and recursively parses its right operand with a new threshold. If the binding power is lower, the operator remains for the caller.
All precedence handling is concentrated in one comparison:
if left_bp < min_bp {
break;
}The Threshold Keeps a Weak Operator from Taking an Operand
Follow the expression a + b * c. The outer parse_expr(0) call consumes a and sees a plus with the pair (11, 12). Its left binding power of 11 clears the threshold of 0, so plus becomes the current operator and its right-hand side is parsed with a threshold of 12.
The inner call consumes b and sees multiplication with (13, 14). Its left binding power of 13 clears the threshold of 12, so multiplication takes b and c and returns this tree:
(* b c)
Plus receives that completed tree as its right operand:
(+ a (* b c))
Now reverse the operators: a * b + c. The right operand of multiplication is parsed with a threshold of 14. The inner call consumes b, but plus has a left binding power of 11 and does not clear that threshold. The call therefore returns b to multiplication, leaving plus for the outer loop:
(+ (* a b) c)
Recursive descent spreads the same decision across parse_additive and parse_multiplicative. In Pratt parsing, it follows from comparing two numbers.
A Pair of Numbers Defines Associativity
For left-associative addition, the table stores (11, 12): the right threshold is higher than the left binding power. The next plus cannot enter the right operand, so a + b + c groups as (a + b) + c.
For right-associative exponentiation, the pair is reversed: (16, 15). Another exponentiation operator with a left binding power of 16 clears the threshold of 15, so 2 ** 3 ** 2 becomes 2 ** (3 ** 2).
Associativity determines how operators with equal precedence are grouped. Left associativity parses
a - b - cas(a - b) - c; right associativity parsesa ** b ** casa ** (b ** c).
The prefix operators - and ! use the constant PREFIX_BP = 15. It is stronger than multiplication but weaker than the left binding power of exponentiation. Therefore -2 ** 2 means -(2 ** 2), while 2 ** -3 remains valid.
Parentheses call parse_expr(0). The zero threshold permits every operator inside, and after the closing parenthesis the outer call resumes with its previous restriction.
Precedence, associativity, prefixes, and parentheses need no separate routes through a chain of functions. They are expressed by the table and the value of min_bp.
The Same Trees Take Fewer Calls
Both implementations pass the same set of eighteen tests:
$ cargo test --release --quiet
running 18 tests
..................
test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
On the same three inputs, Pratt produces the same s-expressions:
$ cargo run --release --quiet -- 42 '((((((((42))))))))' \
'price * qty > limit && region == "eu"'
pratt: one parse function and one binding-power table
expr: 42
sexpr: 42
calls: 1 max depth: 1
expr: ((((((((42))))))))
sexpr: 42
calls: 9 max depth: 9
expr: price * qty > limit && region == "eu"
sexpr: (&& (> (* price qty) limit) (== region "eu"))
calls: 5 max depth: 3
total: 3 expressions, 15 parse calls, deepest recursion 9
The literal now costs one call instead of 10. Eight pairs of parentheses produce a depth of 9 instead of 90: recursion grows with real nesting, not with nesting multiplied by the number of grammar levels. The filter expression takes 5 calls instead of 31.
Across the complete set of twelve expressions, Pratt performs 49 calls compared with 336.
The architectural difference is visible directly in the implementation: recursive descent uses ten parsing functions, while Pratt uses one parse_expr. Token traversal, end-of-input checks, and span handling remain the same.
The Pratt parser still has a table. What disappeared is the expansion of precedence into program architecture. Precedence became data that can be read and changed in one place.
A New Operator Changes a Row, Not a Route
Let us add ??, a right-associative operator with the lowest precedence. The lexer and AST change identically in both versions, so only the parsers need to be compared.
In recursive descent, a new level means a new function:
fn parse_coalesce(&mut self, depth: u64) -> Result<Expr> {
self.note(depth);
let left = self.parse_or(depth + 1)?;
if self.eat(TokenKind::QuestionQuestion) {
let right = self.parse_coalesce(depth + 1)?;
return Ok(binary(BinaryOp::Coalesce, left, right));
}
Ok(left)
}
Declaring the function is not enough. It must become the grammar entry point and be called whenever parentheses restart the grammar:
let expr = parser.parse_coalesce(1)?;
TokenKind::LParen => {
let expr = self.parse_coalesce(depth + 1)?;
self.expect(TokenKind::RParen)?;
Ok(expr)
}
The first line changes the grammar entry point; the second branch restarts the new top level inside parentheses. Forgetting that call breaks (a ?? b) even though the code still compiles.
In the Pratt parser, the same operator adds one row to the table:
TokenKind::QuestionQuestion => (BinaryOp::Coalesce, 2, 1),
The pair (2, 1) defines both the lowest precedence and right associativity. The entry point and parentheses already call parse_expr(0), so the route needs no additional connections.
One Hundred Thousand Inputs Compare Both Implementations
Eighteen tests cover selected cases, but not every combination of fifteen operators, prefixes, and parentheses. For broader coverage, one program runs both implementations over the same input stream and compares their results.
First, both parsers process twelve expressions chosen to exercise characteristic combinations. The descent and pratt columns show calls and recursion depth:
$ cargo run --release --quiet -- table
expr s-expr (identical from both parsers) descent pratt
42 42 10/10 1/1
((((((((42)))))))) 42 90/90 9/9
price * qty > limit && region == "eu" (&& (> (* price qty) limit) (== region "eu")) 31/10 5/3
a + b * c (+ a (* b c)) 17/10 3/3
(a + b) * c (* (+ a b) c) 27/20 4/3
2 ** 3 ** 2 (** 2 (** 3 2)) 16/14 3/3
-2 ** 2 (- (** 2 2)) 14/13 3/3
2 ** -3 (** 2 (- 3)) 14/13 3/3
!paid || price - fee < 0 (|| (! paid) (< (- price fee) 0)) 28/11 5/3
primary ?? fallback ?? 0 (?? primary (?? fallback 0)) 30/12 3/3
a < b == c < d (== (< a b) (< c d)) 26/10 4/3
price * (qty - 1) ** 2 / rate (/ (* price (** (- qty 1) 2)) rate) 33/20 6/4
12 expressions, every AST identical; cost columns are calls/depth
Both implementations group the right-associative ** and ?? operators, unary minus relative to exponentiation, parentheses, and mixed comparisons identically.
A deterministic xorshift64 generator then creates only valid expressions with nesting up to four levels. Both parsers receive the same string, and the program compares the resulting ASTs.
Differential testing sends identical inputs to multiple independent implementations and compares their results. It does not designate either implementation as the oracle, but every disagreement proves that at least one of them interprets the input differently.
$ cargo run --release --quiet -- fuzz --count 100000
seed 0x2a11, nesting <= 4: 100000 generated expressions, 3141495 bytes
descent: 100000 parsed, 3887015 parse calls, deepest recursion 50, 118 ms
pratt: 100000 parsed, 594970 parse calls, deepest recursion 11, 97 ms
verdict: identical ASTs on all 100000 inputs, 0 mismatches
ladder tax: 6.53x parse calls for the same trees
Across 100000 expressions and 3141495 bytes of source text, both parsers built identical trees without a single mismatch.
Recursive descent made 3887015 calls; Pratt made 594970. For the same trees, the grammar ladder required 6.53 times more calls. Maximum recursion depth was 50 versus 11.
The timing difference was much smaller: 118 ms versus 97 ms in the displayed run. Across four runs, recursive descent consistently took 118 ms, while Pratt took 93–97 ms. The end-to-end improvement was therefore about 1.2 times, not 6.53.
The reason follows from the work involved. Both implementations perform the same lexing and allocate the same Vec and Box nodes for their trees; an extra function call is cheap by itself. Pratt's main advantage here is a compact grammar representation and shallower stack use, not a several-fold speedup of the entire parsing pipeline.
Call counts are deterministic under the fixed seed and repeat exactly. Wall-clock time depends on the machine's current state, so the conclusion uses a range of runs rather than one millisecond value.
Precedence Becomes Data
Both parsers correctly handled the original filter:
(&& (> (* price qty) limit) (== region "eu"))
Recursive descent reached that tree through a chain of ten functions. Even the literal 42 paid for every level, and each pair of parentheses restarted the route. Adding the ?? level required a separate function and changes at two integration points.
The Pratt parser kept one function, parse_expr(min_bp). The left binding power decides whether the current call may consume an operator; the right binding power becomes the threshold for its right operand. The order of those two numbers defines associativity, while a zero threshold inside parentheses enables the entire table again.
This does not eliminate the grammar or make the parser automatically correct. The binding-power table must still describe the language precisely, prefix operators need a deliberate threshold, and errors must preserve their spans.
What changed is where those rules live. In recursive descent, precedence is expressed by call architecture. In Pratt parsing, it is data in one table.
That is why one hundred thousand different strings produced identical ASTs but required 3887015 calls in one implementation and 594970 in the other.
Appendix: Full Source Files
pratt-parser/src/main.rs — 201 lines
//! Scene 04: proof. Both parsers live in one binary — `descent.rs`
//! and `pratt.rs` are byte-identical copies of the two crates'
//! `parser.rs` (sha256 them). The table makes precedence visible; the
//! fuzz run turns "the parsers agree" into a number.
mod ast;
mod descent;
mod generate;
mod lexer;
mod pratt;
use anyhow::{Error, Result, ensure};
use clap::{Parser, Subcommand};
use std::time::Instant;
use crate::{
ast::Expr,
generate::Generator,
lexer::{SpanError, render_caret},
};
/// The demo set: the same list feeds the descent crate, the Pratt
/// crate and the proof binary, so the cost columns stay comparable.
const DEMO: &[&str] = &[
"42",
"((((((((42))))))))",
"price * qty > limit && region == \"eu\"",
"a + b * c",
"(a + b) * c",
"2 ** 3 ** 2",
"-2 ** 2",
"2 ** -3",
"!paid || price - fee < 0",
"primary ?? fallback ?? 0",
"a < b == c < d",
"price * (qty - 1) ** 2 / rate",
];
/// Compare the descent and the Pratt parser on identical inputs.
#[derive(Parser)]
struct Args {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Both parsers over the demo set: s-expressions must be identical
Table,
/// Generated expressions: identical ASTs required on every input
Fuzz {
/// How many expressions to generate
#[arg(long, default_value_t = 100_000)]
count: u64,
/// Seed for the xorshift stream
#[arg(long, default_value_t = 0x2A11)]
seed: u64,
/// Maximum structural nesting of generated expressions
#[arg(long, default_value_t = 4)]
depth: u32,
},
}
fn main() -> Result<()> {
match Args::parse().command {
Command::Table => table(),
Command::Fuzz { count, seed, depth } => fuzz(count, seed, depth),
}
}
/// The dozen expressions of the article, through both parsers at once.
fn table() -> Result<()> {
println!(
"{:<38} {:<46} {:>10} {:>8}",
"expr", "s-expr (identical from both parsers)", "descent", "pratt"
);
for src in DEMO {
let (descent_expr, descent_stats) = descent::parse(src)?;
let (pratt_expr, pratt_stats) = pratt::parse(src)?;
let form = descent_expr.to_sexpr();
ensure!(
descent_expr == pratt_expr,
"{src}: descent {form} vs pratt {}",
pratt_expr.to_sexpr()
);
let descent_cost = format!("{}/{}", descent_stats.calls, descent_stats.max_depth);
let pratt_cost = format!("{}/{}", pratt_stats.calls, pratt_stats.max_depth);
println!("{src:<38} {form:<46} {descent_cost:>10} {pratt_cost:>8}");
}
println!();
println!(
"{} expressions, every AST identical; cost columns are calls/depth",
DEMO.len()
);
Ok(())
}
/// Totals of one parser over a whole corpus.
struct CorpusRun {
trees: Vec<Expr>,
calls: u64,
max_depth: u64,
elapsed_ms: f64,
}
fn run_corpus(
name: &str,
sources: &[String],
parse: fn(&str) -> Result<(Expr, descent::ParseStats)>,
) -> Result<CorpusRun> {
let started = Instant::now();
let mut trees = Vec::with_capacity(sources.len());
let mut calls = 0;
let mut max_depth = 0;
for src in sources {
let (expr, stats) = match parse(src) {
Ok(pair) => pair,
Err(error) => return Err(annotate(name, src, error)),
};
calls += stats.calls;
max_depth = max_depth.max(stats.max_depth);
trees.push(expr);
}
let elapsed_ms = started.elapsed().as_secs_f64() * 1e3;
Ok(CorpusRun {
trees,
calls,
max_depth,
elapsed_ms,
})
}
/// A parser refusing a generated input is a bug worth a caret.
fn annotate(name: &str, src: &str, error: Error) -> Error {
let span = error
.downcast_ref::<SpanError>()
.map(|span_error| span_error.span);
match span {
Some(span) => error.context(format!(
"{name} refused a generated input:\n{}",
render_caret(src, span)
)),
None => error.context(format!("{name} refused a generated input: {src}")),
}
}
/// M random expressions; the two parsers must produce M equal trees.
fn fuzz(count: u64, seed: u64, depth: u32) -> Result<()> {
ensure!(count > 0, "count must be greater than zero");
let mut generator = Generator::new(seed);
let sources: Vec<String> = (0..count).map(|_| generator.expr(depth)).collect();
let bytes: usize = sources.iter().map(String::len).sum();
println!("seed {seed:#x}, nesting <= {depth}: {count} generated expressions, {bytes} bytes");
let descent_run = run_corpus("descent", &sources, descent_parse)?;
println!(
"descent: {count} parsed, {} parse calls, deepest recursion {}, {:.0} ms",
descent_run.calls, descent_run.max_depth, descent_run.elapsed_ms
);
let pratt_run = run_corpus("pratt", &sources, pratt_parse)?;
println!(
"pratt: {count} parsed, {} parse calls, deepest recursion {}, {:.0} ms",
pratt_run.calls, pratt_run.max_depth, pratt_run.elapsed_ms
);
let mut mismatches = 0;
for (index, (descent_tree, pratt_tree)) in
descent_run.trees.iter().zip(&pratt_run.trees).enumerate()
{
if descent_tree != pratt_tree {
mismatches += 1;
println!("mismatch on {}:", sources[index]);
println!(" descent: {}", descent_tree.to_sexpr());
println!(" pratt: {}", pratt_tree.to_sexpr());
}
}
ensure!(mismatches == 0, "{mismatches} mismatching ASTs");
println!("verdict: identical ASTs on all {count} inputs, {mismatches} mismatches");
println!(
"ladder tax: {:.2}x parse calls for the same trees",
descent_run.calls as f64 / pratt_run.calls as f64
);
Ok(())
}
/// Both parsers export the same shape; these two wrappers only exist
/// because each module declares its own `ParseStats` twin.
fn descent_parse(src: &str) -> Result<(Expr, descent::ParseStats)> {
descent::parse(src)
}
fn pratt_parse(src: &str) -> Result<(Expr, descent::ParseStats)> {
let (expr, stats) = pratt::parse(src)?;
Ok((
expr,
descent::ParseStats {
calls: stats.calls,
max_depth: stats.max_depth,
},
))
}pratt-parser/src/lexer.rs — 246 lines
//! Lexer for the expression language: every token carries a byte span.
//!
//! Spans are the running theme of the whole box. The lexer is the only
//! stage that sees source offsets, so it must record them here — no
//! later stage can restore a position the lexer dropped. A token is a
//! kind plus a span; the text itself stays in the source string.
use anyhow::{Error, Result, anyhow, ensure};
use std::fmt::{self, Display, Formatter};
use strum::Display as StrumDisplay;
use thiserror::Error as ThisError;
/// Byte range `start..end` of a token inside the source line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
pub start: usize,
pub end: usize,
}
impl Display for Span {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
// `pad` keeps table columns honest when a width is requested.
formatter.pad(&format!("{}..{}", self.start, self.end))
}
}
/// An error that knows which bytes it is about. Both the lexer and the
/// parsers build their errors through this type, so every message in
/// the pipeline can point back at the offending character.
#[derive(Debug, ThisError)]
#[error("{message} at bytes {span}")]
pub struct SpanError {
pub message: String,
pub span: Span,
}
/// Shorthand: an `anyhow` error carrying a span, downcastable later.
pub fn spanned(message: impl Into<String>, span: Span) -> Error {
anyhow!(SpanError {
message: message.into(),
span
})
}
/// Two lines for a terminal: the source and a caret run under the span.
pub fn render_caret(src: &str, span: Span) -> String {
let width = span.end.saturating_sub(span.start).max(1);
let pad = " ".repeat(span.start);
let carets = "^".repeat(width);
format!(" {src}\n {pad}{carets} bytes {span}")
}
/// Everything the language can spell. `Display` prints the surface form.
#[derive(Debug, Clone, Copy, PartialEq, Eq, StrumDisplay)]
pub enum TokenKind {
#[strum(serialize = "number")]
Number,
#[strum(serialize = "string")]
Str,
#[strum(serialize = "ident")]
Ident,
#[strum(serialize = "||")]
OrOr,
#[strum(serialize = "&&")]
AndAnd,
#[strum(serialize = "??")]
QuestionQuestion,
#[strum(serialize = "==")]
EqEq,
#[strum(serialize = "!=")]
BangEq,
#[strum(serialize = "<")]
Lt,
#[strum(serialize = "<=")]
LtEq,
#[strum(serialize = ">")]
Gt,
#[strum(serialize = ">=")]
GtEq,
#[strum(serialize = "+")]
Plus,
#[strum(serialize = "-")]
Minus,
#[strum(serialize = "*")]
Star,
#[strum(serialize = "/")]
Slash,
#[strum(serialize = "%")]
Percent,
#[strum(serialize = "**")]
StarStar,
#[strum(serialize = "!")]
Bang,
#[strum(serialize = "(")]
LParen,
#[strum(serialize = ")")]
RParen,
}
/// A token: what it is and where it sits. Nothing else — the text is a
/// slice of the source, recoverable from the span at any time.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Token {
pub kind: TokenKind,
pub span: Span,
}
/// Tokenize one source line. ASCII only, so byte offsets are also
/// column numbers — one less thing between a span and a caret.
pub fn lex(src: &str) -> Result<Vec<Token>> {
ensure!(src.is_ascii(), "only ascii input is supported in this demo");
let bytes = src.as_bytes();
let mut tokens = Vec::new();
let mut pos = 0;
while pos < bytes.len() {
let start = pos;
let byte = bytes[pos];
let kind = match byte {
b' ' | b'\t' => {
pos += 1;
continue;
}
b'0'..=b'9' => {
pos = scan_number(bytes, pos);
TokenKind::Number
}
b'a'..=b'z' | b'A'..=b'Z' | b'_' => {
pos = scan_ident(bytes, pos);
TokenKind::Ident
}
b'"' => {
pos = scan_string(bytes, pos)?;
TokenKind::Str
}
// These four operators only exist doubled: || && ?? ==
b'|' | b'&' | b'?' | b'=' => {
if bytes.get(pos + 1) != Some(&byte) {
let span = Span {
start,
end: start + 1,
};
let symbol = byte as char;
return Err(spanned(format!("unexpected character '{symbol}'"), span));
}
pos += 2;
match byte {
b'|' => TokenKind::OrOr,
b'&' => TokenKind::AndAnd,
b'?' => TokenKind::QuestionQuestion,
_ => TokenKind::EqEq,
}
}
b'!' => two_or_one(bytes, &mut pos, b'=', TokenKind::BangEq, TokenKind::Bang),
b'<' => two_or_one(bytes, &mut pos, b'=', TokenKind::LtEq, TokenKind::Lt),
b'>' => two_or_one(bytes, &mut pos, b'=', TokenKind::GtEq, TokenKind::Gt),
b'*' => two_or_one(bytes, &mut pos, b'*', TokenKind::StarStar, TokenKind::Star),
b'+' => single(&mut pos, TokenKind::Plus),
b'-' => single(&mut pos, TokenKind::Minus),
b'/' => single(&mut pos, TokenKind::Slash),
b'%' => single(&mut pos, TokenKind::Percent),
b'(' => single(&mut pos, TokenKind::LParen),
b')' => single(&mut pos, TokenKind::RParen),
other => {
let span = Span {
start,
end: start + 1,
};
let symbol = other as char;
return Err(spanned(format!("unexpected character '{symbol}'"), span));
}
};
tokens.push(Token {
kind,
span: Span { start, end: pos },
});
}
Ok(tokens)
}
fn single(pos: &mut usize, kind: TokenKind) -> TokenKind {
*pos += 1;
kind
}
/// `<=`-style pairs: consume two bytes when the second matches.
fn two_or_one(
bytes: &[u8],
pos: &mut usize,
second: u8,
pair: TokenKind,
one: TokenKind,
) -> TokenKind {
if bytes.get(*pos + 1) == Some(&second) {
*pos += 2;
return pair;
}
*pos += 1;
one
}
fn scan_digits(bytes: &[u8], mut pos: usize) -> usize {
while pos < bytes.len() && bytes[pos].is_ascii_digit() {
pos += 1;
}
pos
}
/// Digits, then optionally `.` followed by at least one digit.
fn scan_number(bytes: &[u8], pos: usize) -> usize {
let mut pos = scan_digits(bytes, pos);
let dot = bytes.get(pos) == Some(&b'.');
if dot && bytes.get(pos + 1).is_some_and(|byte| byte.is_ascii_digit()) {
pos = scan_digits(bytes, pos + 2);
}
pos
}
fn scan_ident(bytes: &[u8], mut pos: usize) -> usize {
while pos < bytes.len() {
let byte = bytes[pos];
let tail = byte.is_ascii_alphanumeric() || byte == b'_';
if !tail {
break;
}
pos += 1;
}
pos
}
/// From the opening quote to just past the closing one. No escapes in
/// this language; an unterminated literal is an error with a span that
/// covers everything the string swallowed.
fn scan_string(bytes: &[u8], start: usize) -> Result<usize> {
let mut pos = start + 1;
while pos < bytes.len() {
if bytes[pos] == b'"' {
return Ok(pos + 1);
}
pos += 1;
}
let span = Span {
start,
end: bytes.len(),
};
Err(spanned("unterminated string literal", span))
}pratt-parser/src/ast.rs — 102 lines
//! The AST and its parenthesized (s-expression) form.
//!
//! `to_sexpr` is the proof format of this demo: every precedence and
//! associativity decision becomes a visible parenthesis. Two parsers
//! agree exactly when their s-expressions match byte for byte.
use strum::Display;
/// Prefix operators.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display)]
pub enum UnaryOp {
#[strum(serialize = "-")]
Neg,
#[strum(serialize = "!")]
Not,
}
/// Infix operators, printed exactly as written in the source.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display)]
pub enum BinaryOp {
#[strum(serialize = "??")]
Coalesce,
#[strum(serialize = "||")]
Or,
#[strum(serialize = "&&")]
And,
#[strum(serialize = "==")]
Eq,
#[strum(serialize = "!=")]
Ne,
#[strum(serialize = "<")]
Lt,
#[strum(serialize = "<=")]
Le,
#[strum(serialize = ">")]
Gt,
#[strum(serialize = ">=")]
Ge,
#[strum(serialize = "+")]
Add,
#[strum(serialize = "-")]
Sub,
#[strum(serialize = "*")]
Mul,
#[strum(serialize = "/")]
Div,
#[strum(serialize = "%")]
Rem,
#[strum(serialize = "**")]
Pow,
}
/// Expression tree. Grouping parens do not survive parsing: structure
/// is the only thing the tree remembers, which is the whole point.
#[derive(Debug, Clone, PartialEq)]
pub enum Expr {
/// Numeric literal kept as source text: `2.50` stays `2.50`.
Number(String),
/// String literal without its quotes.
Str(String),
/// A name to be resolved against a context at evaluation time.
Ident(String),
Unary {
op: UnaryOp,
expr: Box<Expr>,
},
Binary {
op: BinaryOp,
left: Box<Expr>,
right: Box<Expr>,
},
}
pub fn unary(op: UnaryOp, expr: Expr) -> Expr {
Expr::Unary {
op,
expr: Box::new(expr),
}
}
pub fn binary(op: BinaryOp, left: Expr, right: Expr) -> Expr {
Expr::Binary {
op,
left: Box::new(left),
right: Box::new(right),
}
}
impl Expr {
/// Lisp-style prefix form: `(&& (> (* price qty) limit) ...)`.
pub fn to_sexpr(&self) -> String {
match self {
Expr::Number(text) => text.clone(),
Expr::Str(text) => format!("\"{text}\""),
Expr::Ident(name) => name.clone(),
Expr::Unary { op, expr } => format!("({op} {})", expr.to_sexpr()),
Expr::Binary { op, left, right } => {
format!("({op} {} {})", left.to_sexpr(), right.to_sexpr())
}
}
}
}pratt-parser/src/descent.rs — 277 lines
//! Recursive descent: the grammar table spelled as a ladder of functions.
//!
//! One function per precedence level. Each level first parses the
//! next-tighter level, then loops on its own operators. The entry point
//! is the loosest level, so every atom — even a bare `42` — pays a full
//! trip down all the rungs. The counters below make that cost a number.
use crate::{
ast::{BinaryOp, Expr, UnaryOp, binary, unary},
lexer::{Span, Token, TokenKind, lex, spanned},
};
use anyhow::{Error, Result};
/// Counters filled while parsing: how much work the *shape* of the
/// parser causes, separately from the size of the input.
#[derive(Debug, Clone, Copy)]
pub struct ParseStats {
/// Parse-function invocations.
pub calls: u64,
/// Deepest recursion reached (the entry function is depth 1).
pub max_depth: u64,
}
/// Parse one source line into an expression tree.
pub fn parse(src: &str) -> Result<(Expr, ParseStats)> {
let tokens = lex(src)?;
let mut parser = Parser {
src,
tokens: &tokens,
pos: 0,
calls: 0,
max_depth: 0,
};
let expr = parser.parse_coalesce(1)?;
parser.expect_end()?;
let stats = ParseStats {
calls: parser.calls,
max_depth: parser.max_depth,
};
Ok((expr, stats))
}
struct Parser<'a> {
src: &'a str,
tokens: &'a [Token],
pos: usize,
calls: u64,
max_depth: u64,
}
impl Parser<'_> {
// --- the ladder: one function per precedence level ---------------
/// Level 1: `??` — right-associative through self-recursion.
fn parse_coalesce(&mut self, depth: u64) -> Result<Expr> {
self.note(depth);
let left = self.parse_or(depth + 1)?;
if self.eat(TokenKind::QuestionQuestion) {
let right = self.parse_coalesce(depth + 1)?;
return Ok(binary(BinaryOp::Coalesce, left, right));
}
Ok(left)
}
/// Level 2: `||`.
fn parse_or(&mut self, depth: u64) -> Result<Expr> {
self.note(depth);
let mut left = self.parse_and(depth + 1)?;
while self.eat(TokenKind::OrOr) {
let right = self.parse_and(depth + 1)?;
left = binary(BinaryOp::Or, left, right);
}
Ok(left)
}
/// Level 3: `&&`.
fn parse_and(&mut self, depth: u64) -> Result<Expr> {
self.note(depth);
let mut left = self.parse_equality(depth + 1)?;
while self.eat(TokenKind::AndAnd) {
let right = self.parse_equality(depth + 1)?;
left = binary(BinaryOp::And, left, right);
}
Ok(left)
}
/// Level 4: `==` and `!=`.
fn parse_equality(&mut self, depth: u64) -> Result<Expr> {
self.note(depth);
let mut left = self.parse_comparison(depth + 1)?;
loop {
let op = if self.eat(TokenKind::EqEq) {
BinaryOp::Eq
} else if self.eat(TokenKind::BangEq) {
BinaryOp::Ne
} else {
return Ok(left);
};
let right = self.parse_comparison(depth + 1)?;
left = binary(op, left, right);
}
}
/// Level 5: `<`, `<=`, `>`, `>=`.
fn parse_comparison(&mut self, depth: u64) -> Result<Expr> {
self.note(depth);
let mut left = self.parse_additive(depth + 1)?;
loop {
let op = if self.eat(TokenKind::Lt) {
BinaryOp::Lt
} else if self.eat(TokenKind::LtEq) {
BinaryOp::Le
} else if self.eat(TokenKind::Gt) {
BinaryOp::Gt
} else if self.eat(TokenKind::GtEq) {
BinaryOp::Ge
} else {
return Ok(left);
};
let right = self.parse_additive(depth + 1)?;
left = binary(op, left, right);
}
}
/// Level 6: `+` and `-`.
fn parse_additive(&mut self, depth: u64) -> Result<Expr> {
self.note(depth);
let mut left = self.parse_multiplicative(depth + 1)?;
loop {
let op = if self.eat(TokenKind::Plus) {
BinaryOp::Add
} else if self.eat(TokenKind::Minus) {
BinaryOp::Sub
} else {
return Ok(left);
};
let right = self.parse_multiplicative(depth + 1)?;
left = binary(op, left, right);
}
}
/// Level 7: `*`, `/`, `%`.
fn parse_multiplicative(&mut self, depth: u64) -> Result<Expr> {
self.note(depth);
let mut left = self.parse_unary(depth + 1)?;
loop {
let op = if self.eat(TokenKind::Star) {
BinaryOp::Mul
} else if self.eat(TokenKind::Slash) {
BinaryOp::Div
} else if self.eat(TokenKind::Percent) {
BinaryOp::Rem
} else {
return Ok(left);
};
let right = self.parse_unary(depth + 1)?;
left = binary(op, left, right);
}
}
/// Level 8: prefix `-` and `!`; stacked prefixes recurse here.
fn parse_unary(&mut self, depth: u64) -> Result<Expr> {
self.note(depth);
let op = if self.eat(TokenKind::Minus) {
Some(UnaryOp::Neg)
} else if self.eat(TokenKind::Bang) {
Some(UnaryOp::Not)
} else {
None
};
match op {
Some(op) => Ok(unary(op, self.parse_unary(depth + 1)?)),
None => self.parse_power(depth + 1),
}
}
/// Level 9: `**` — right-associative, and its right side may carry
/// a fresh sign, so `-2 ** 2` is `-(2 ** 2)` and `2 ** -3` works.
fn parse_power(&mut self, depth: u64) -> Result<Expr> {
self.note(depth);
let left = self.parse_primary(depth + 1)?;
if self.eat(TokenKind::StarStar) {
let right = self.parse_unary(depth + 1)?;
return Ok(binary(BinaryOp::Pow, left, right));
}
Ok(left)
}
/// Level 10: atoms, plus grouping parens that restart the ladder.
fn parse_primary(&mut self, depth: u64) -> Result<Expr> {
self.note(depth);
let token = self.advance()?;
match token.kind {
TokenKind::Number => Ok(Expr::Number(self.slice(token.span).to_string())),
TokenKind::Str => Ok(Expr::Str(self.inner_str(token.span))),
TokenKind::Ident => Ok(Expr::Ident(self.slice(token.span).to_string())),
TokenKind::LParen => {
let expr = self.parse_coalesce(depth + 1)?;
self.expect(TokenKind::RParen)?;
Ok(expr)
}
kind => Err(spanned(
format!("expected an expression, found '{kind}'"),
token.span,
)),
}
}
// --- shared plumbing ----------------------------------------------
fn note(&mut self, depth: u64) {
self.calls += 1;
self.max_depth = self.max_depth.max(depth);
}
fn peek(&self) -> Option<Token> {
self.tokens.get(self.pos).copied()
}
fn advance(&mut self) -> Result<Token> {
let token = self.peek().ok_or_else(|| self.end_of_input())?;
self.pos += 1;
Ok(token)
}
fn eat(&mut self, kind: TokenKind) -> bool {
if self.peek().is_some_and(|token| token.kind == kind) {
self.pos += 1;
return true;
}
false
}
fn expect(&mut self, kind: TokenKind) -> Result<()> {
match self.peek() {
Some(token) if token.kind == kind => {
self.pos += 1;
Ok(())
}
Some(token) => Err(spanned(
format!("expected '{kind}', found '{}'", self.slice(token.span)),
token.span,
)),
None => Err(self.end_of_input()),
}
}
fn expect_end(&mut self) -> Result<()> {
match self.peek() {
None => Ok(()),
Some(token) => Err(spanned(
format!(
"unexpected trailing input starting with '{}'",
self.slice(token.span)
),
token.span,
)),
}
}
fn end_of_input(&self) -> Error {
let end = Span {
start: self.src.len(),
end: self.src.len(),
};
spanned("unexpected end of input", end)
}
fn slice(&self, span: Span) -> &str {
&self.src[span.start..span.end]
}
/// String literal text without the surrounding quotes.
fn inner_str(&self, span: Span) -> String {
self.src[span.start + 1..span.end - 1].to_string()
}
}pratt-parser/src/pratt.rs — 180 lines
//! Pratt parsing: one function and a table of binding powers.
//!
//! `parse_expr(min_bp)` replaces the whole ladder. The loop keeps
//! consuming operators while they bind at least as tightly as the
//! caller allows; recursing with the operator's right power decides
//! associativity. Precedence lives in one table, not in call structure.
use crate::{
ast::{BinaryOp, Expr, UnaryOp, binary, unary},
lexer::{Span, Token, TokenKind, lex, spanned},
};
use anyhow::{Error, Result};
/// Counters filled while parsing: same meaning as in the descent
/// crate, so the two parsers can be compared number to number.
#[derive(Debug, Clone, Copy)]
pub struct ParseStats {
/// Parse-function invocations.
pub calls: u64,
/// Deepest recursion reached (the entry function is depth 1).
pub max_depth: u64,
}
/// Prefix `-` and `!` bind tighter than `*` but looser than the left
/// side of `**`, so `-2 ** 2` reads as `-(2 ** 2)` — same as descent.
const PREFIX_BP: u8 = 15;
/// The whole grammar table: an operator, how hard it holds its left
/// operand and how hard it holds its right one. Left < right makes the
/// operator left-associative; `??` and `**` flip the pair and become
/// right-associative. Adding an operator is adding one row.
fn binding_power(kind: TokenKind) -> Option<(BinaryOp, u8, u8)> {
let row = match kind {
TokenKind::QuestionQuestion => (BinaryOp::Coalesce, 2, 1),
TokenKind::OrOr => (BinaryOp::Or, 3, 4),
TokenKind::AndAnd => (BinaryOp::And, 5, 6),
TokenKind::EqEq => (BinaryOp::Eq, 7, 8),
TokenKind::BangEq => (BinaryOp::Ne, 7, 8),
TokenKind::Lt => (BinaryOp::Lt, 9, 10),
TokenKind::LtEq => (BinaryOp::Le, 9, 10),
TokenKind::Gt => (BinaryOp::Gt, 9, 10),
TokenKind::GtEq => (BinaryOp::Ge, 9, 10),
TokenKind::Plus => (BinaryOp::Add, 11, 12),
TokenKind::Minus => (BinaryOp::Sub, 11, 12),
TokenKind::Star => (BinaryOp::Mul, 13, 14),
TokenKind::Slash => (BinaryOp::Div, 13, 14),
TokenKind::Percent => (BinaryOp::Rem, 13, 14),
TokenKind::StarStar => (BinaryOp::Pow, 16, 15),
_ => return None,
};
Some(row)
}
/// Parse one source line into an expression tree.
pub fn parse(src: &str) -> Result<(Expr, ParseStats)> {
let tokens = lex(src)?;
let mut parser = Parser {
src,
tokens: &tokens,
pos: 0,
calls: 0,
max_depth: 0,
};
let expr = parser.parse_expr(0, 1)?;
parser.expect_end()?;
let stats = ParseStats {
calls: parser.calls,
max_depth: parser.max_depth,
};
Ok((expr, stats))
}
struct Parser<'a> {
src: &'a str,
tokens: &'a [Token],
pos: usize,
calls: u64,
max_depth: u64,
}
impl Parser<'_> {
/// The single parse function: a prefix position, then a loop that
/// takes every operator binding at least as hard as `min_bp`.
fn parse_expr(&mut self, min_bp: u8, depth: u64) -> Result<Expr> {
self.note(depth);
let token = self.advance()?;
let mut left = match token.kind {
TokenKind::Number => Expr::Number(self.slice(token.span).to_string()),
TokenKind::Str => Expr::Str(self.inner_str(token.span)),
TokenKind::Ident => Expr::Ident(self.slice(token.span).to_string()),
TokenKind::Minus => unary(UnaryOp::Neg, self.parse_expr(PREFIX_BP, depth + 1)?),
TokenKind::Bang => unary(UnaryOp::Not, self.parse_expr(PREFIX_BP, depth + 1)?),
TokenKind::LParen => {
let inner = self.parse_expr(0, depth + 1)?;
self.expect(TokenKind::RParen)?;
inner
}
kind => {
return Err(spanned(
format!("expected an expression, found '{kind}'"),
token.span,
));
}
};
loop {
let Some(next) = self.peek() else { break };
let Some((op, left_bp, right_bp)) = binding_power(next.kind) else {
break;
};
if left_bp < min_bp {
break;
}
self.pos += 1;
let right = self.parse_expr(right_bp, depth + 1)?;
left = binary(op, left, right);
}
Ok(left)
}
// --- shared plumbing ----------------------------------------------
fn note(&mut self, depth: u64) {
self.calls += 1;
self.max_depth = self.max_depth.max(depth);
}
fn peek(&self) -> Option<Token> {
self.tokens.get(self.pos).copied()
}
fn advance(&mut self) -> Result<Token> {
let token = self.peek().ok_or_else(|| self.end_of_input())?;
self.pos += 1;
Ok(token)
}
fn expect(&mut self, kind: TokenKind) -> Result<()> {
match self.peek() {
Some(token) if token.kind == kind => {
self.pos += 1;
Ok(())
}
Some(token) => Err(spanned(
format!("expected '{kind}', found '{}'", self.slice(token.span)),
token.span,
)),
None => Err(self.end_of_input()),
}
}
fn expect_end(&mut self) -> Result<()> {
match self.peek() {
None => Ok(()),
Some(token) => Err(spanned(
format!(
"unexpected trailing input starting with '{}'",
self.slice(token.span)
),
token.span,
)),
}
}
fn end_of_input(&self) -> Error {
let end = Span {
start: self.src.len(),
end: self.src.len(),
};
spanned("unexpected end of input", end)
}
fn slice(&self, span: Span) -> &str {
&self.src[span.start..span.end]
}
/// String literal text without the surrounding quotes.
fn inner_str(&self, span: Span) -> String {
self.src[span.start + 1..span.end - 1].to_string()
}
}pratt-parser/src/generate.rs — 65 lines
//! Deterministic random expression generator: xorshift64, valid
//! output only.
//!
//! The generator emits surface strings, not trees. Both parsers get
//! exactly the same bytes, and neither one serves as the reference
//! for the other — agreement of the two ASTs is the verdict.
/// Splittable-nothing, dependency-free xorshift64 stream.
pub struct Generator {
state: u64,
}
const IDENTS: &[&str] = &["price", "qty", "limit", "region", "rate", "paid"];
const STRINGS: &[&str] = &["eu", "us", "sale"];
const PREFIXES: &[&str] = &["-", "!"];
const OPERATORS: &[&str] = &[
"??", "||", "&&", "==", "!=", "<", "<=", ">", ">=", "+", "-", "*", "/", "%", "**",
];
impl Generator {
/// Seed zero would freeze xorshift, so the low bit is forced on.
pub fn new(seed: u64) -> Self {
Self { state: seed | 1 }
}
fn next(&mut self) -> u64 {
self.state ^= self.state << 13;
self.state ^= self.state >> 7;
self.state ^= self.state << 17;
self.state
}
fn pick<'a>(&mut self, options: &'a [&'a str]) -> &'a str {
let index = (self.next() % options.len() as u64) as usize;
options[index]
}
/// One random expression with structural nesting up to `depth`.
/// Every produced string is valid in the demo grammar.
pub fn expr(&mut self, depth: u32) -> String {
if depth == 0 {
return self.atom();
}
match self.next() % 10 {
0..=4 => {
let left = self.expr(depth - 1);
let op = self.pick(OPERATORS);
let right = self.expr(depth - 1);
format!("{left} {op} {right}")
}
5 => format!("{}{}", self.pick(PREFIXES), self.expr(depth - 1)),
6 | 7 => format!("({})", self.expr(depth - 1)),
_ => self.atom(),
}
}
fn atom(&mut self) -> String {
match self.next() % 4 {
0 => self.pick(IDENTS).to_string(),
1 => format!("{}", self.next() % 1000),
2 => format!("{}.{:02}", self.next() % 100, self.next() % 100),
_ => format!("\"{}\"", self.pick(STRINGS)),
}
}
}pratt-parser/Cargo.toml — 15 lines
[package]
name = "expr-proof"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.98"
clap = { version = "4.5.40", features = ["derive"] }
strum = { version = "0.27.1", features = ["derive"] }
thiserror = "2.0.12"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1