Posted in Engineering

Parsing Explained: Recursive Descent vs Pratt Parsing for Hand-Written Parsers

Every compiler, interpreter, configuration-file reader, or query-language engine starts with the same job: turning a flat stream of tokens into a structured tree the rest of the program can walk. That job is parsing, and for hand-written parsers, two techniques dominate: recursive descent, which maps each grammar rule directly onto a function, and Pratt parsing, a specialized technique for expressions that handles operator precedence without a function explosion. Recursive descent is simple and works beautifully for most of a language’s grammar — statements, declarations, blocks — but it gets awkward exactly where arithmetic-style expressions live, because precedence and associativity don’t map cleanly onto “one function per rule.” Pratt parsing exists specifically to fix that. This explainer covers how each technique works, where recursive descent breaks down, and how Pratt parsing’s precedence-driven loop solves the same problem with far less code.

At a Glance

Recursive descentOne parsing function per grammar rule, calling each other top-down
Pratt parsingOne generic expression-parsing loop driven by per-token precedence (binding power)
Best suited forRecursive descent: statements/structure. Pratt: expressions with operators
Precedence handlingRecursive descent: a separate function per precedence level. Pratt: a precedence number per operator
Adding a new operatorRecursive descent: insert/renumber grammar functions. Pratt: register one new table entry
OriginPratt parsing comes from Vaughan Pratt’s 1973 paper “Top Down Operator Precedence”

What Problem This Solves

Parsing takes the token stream produced by a lexer (e.g. NUM(1), OP(+), NUM(2), OP(*), NUM(3) for the text 1 + 2 * 3) and builds an abstract syntax tree that reflects the language’s grammar rules — in particular, which operations bind more tightly than others. A parser that got this wrong would parse 1 + 2 * 3 as (1 + 2) * 3 instead of the mathematically correct 1 + (2 * 3). Both recursive descent and Pratt parsing are top-down parsing strategies — they start from the whole expression and work down into its parts — but they differ sharply in how they encode precedence, which is the crux of the problem this explainer is about.

How Recursive Descent Works

Recursive descent translates a grammar almost mechanically: each production rule becomes a function, and each function calls the functions for the rules it references. A grammar rule like statement -> if_statement | while_statement | expr_statement becomes a parse_statement() function that peeks at the next token and dispatches to parse_if(), parse_while(), or parse_expr_statement(). This is simple to write, simple to debug (a stack trace literally shows you the grammar path taken), and it maps cleanly onto most of a real language’s syntax — declarations, blocks, control flow. It struggles specifically with left-recursive rules (a rule that refers to itself as its own first symbol, which causes infinite recursion in a naive top-down implementation) and, most relevantly here, with operator precedence in expressions.

Why Operator Precedence Breaks Naive Recursive Descent

The standard recursive-descent fix for precedence is to write one function per precedence level, each calling the next tighter-binding level before trying to consume an operator at its own level. For a language with just +, -, *, and /, that’s already parse_additive() calling parse_multiplicative() calling parse_unary() calling parse_primary(). Real languages have far more precedence levels than that — in his widely cited article on the subject, developer Bob Nystrom points out that JavaScript’s expression grammar has roughly seventeen distinct precedence levels. Written the naive recursive-descent way, that means roughly seventeen near-identical functions, each differing only in which operator tokens it checks for and which function it calls next. Associativity (does a - b - c group as (a - b) - c or a - (b - c)?) has to be hand-coded into each of those functions separately, and adding a new operator at a new precedence level means inserting a new function into the chain and renumbering everything below it.

What Pratt Parsing Does Differently

Pratt parsing — sometimes called “top-down operator precedence parsing,” after Vaughan Pratt’s original 1973 title — replaces that chain of functions with a single loop and a table. Every token type that can start an expression (a number, a variable, a unary -, an opening parenthesis) gets a prefix handler. Every token type that can appear after an expression as an operator (+, *, etc.) gets an infix handler and a precedence number, sometimes called its “binding power.” The core function, often called parse_expression(min_precedence), works like this: parse one prefix expression to get a starting value, then repeatedly look at the next operator — as long as its precedence is at least as high as the minimum precedence the caller asked for, consume it, recursively parse its right-hand side (passing in that operator’s own precedence, which is what produces correct left-associativity), and fold the result into the running expression. Precedence is no longer encoded as “which function am I in” — it’s a plain number compared at one single point in the code, so adding a new operator means adding one entry to a table, not a new function in a chain.

Recursive Descent vs Pratt Parsing

AspectRecursive Descent (naive)Pratt Parsing
Functions needed for N precedence levelsN separate functions1 function + N table entries
Adding a new operatorInsert a function, renumber the chainRegister one prefix/infix handler with a precedence number
AssociativityHand-coded per level (loop vs recursion choice)Controlled by passing precedence vs precedence+1 to the recursive call
Readability of the grammar structureVery explicit — one rule, one functionLess explicit — precedence lives in a table, not the control flow
Best fitStatements, declarations, overall language structureExpressions with many operators and precedence levels

Code Example: A Minimal Pratt Parser in Python

The example below parses simple arithmetic with +, -, *, /, and parentheses into a nested tuple AST, using the precedence-climbing form of Pratt parsing (a restricted version that handles binary operators without a full prefix/infix table). Calling parse("1 + 2 * 3") correctly returns ('+', 1, ('*', 2, 3)), and parse("(1 + 2) * 3") returns ('*', ('+', 1, 2), 3) — multiplication binds tighter than addition in the first case, while the parentheses override that in the second.

import re

TOKEN_RE = re.compile(r"\s*(?:(\d+)|(.))")
PRECEDENCE = {"+": 1, "-": 1, "*": 2, "/": 2}

def tokenize(text):
    tokens = []
    for num, op in TOKEN_RE.findall(text):
        if num:
            tokens.append(("NUM", int(num)))
        elif op:
            tokens.append(("OP", op))
    return tokens

class Parser:
    def __init__(self, tokens):
        self.tokens = tokens
        self.pos = 0

    def peek(self):
        return self.tokens[self.pos] if self.pos < len(self.tokens) else (None, None)

    def advance(self):
        tok = self.peek()
        self.pos += 1
        return tok

    def parse_expression(self, min_precedence=0):
        kind, value = self.advance()
        if kind == "NUM":
            left = value
        elif kind == "OP" and value == "(":
            left = self.parse_expression(0)
            self.advance()  # consume the matching ")"
        else:
            raise SyntaxError(f"Unexpected token: {value}")

        while True:
            kind, op = self.peek()
            if kind != "OP" or op not in PRECEDENCE:
                break
            precedence = PRECEDENCE[op]
            if precedence < min_precedence:
                break
            self.advance()  # consume the operator
            # precedence + 1 on the right side gives left-associativity
            right = self.parse_expression(precedence + 1)
            left = (op, left, right)
        return left

def parse(text):
    return Parser(tokenize(text)).parse_expression()

print(parse("1 + 2 * 3"))     # ('+', 1, ('*', 2, 3))
print(parse("(1 + 2) * 3"))   # ('*', ('+', 1, 2), 3)

Trade-Offs

  • Pro (Pratt): Adding an operator or changing precedence is a one-line table edit, not a restructuring of the function chain.
  • Pro (Pratt): Naturally handles prefix, infix, postfix, and mixfix operators (like a ? b : c) within the same uniform mechanism.
  • Pro (Recursive descent): The control flow directly mirrors the grammar, which makes it easier for newcomers to read and debug, especially outside of pure expression parsing.
  • Con (Pratt): Precedence lives in a table rather than in the code’s structure, which can make the grammar less self-documenting to someone unfamiliar with the technique.
  • Con (Recursive descent): Scales poorly as the number of precedence levels grows — real language grammars (e.g. JavaScript’s ~17 levels) turn into long chains of near-duplicate functions.
  • Shared limitation: Both are typically hand-rolled for expressions; for very large or ambiguous grammars, parser-generator tools (LALR, PEG) may be a better fit than either.

Bottom Line

Recursive descent and Pratt parsing aren’t competitors so much as a division of labor: use recursive descent for the overall shape of a language — statements, declarations, blocks — and drop into a Pratt-style parse_expression(precedence) loop the moment you hit arithmetic-like expressions with multiple operators and precedence levels. Nearly every production hand-written parser for a language with infix operators — including real-world interpreters and compilers — uses exactly this hybrid, because it avoids both recursive descent’s function explosion and the complexity of a full parser-generator toolchain.

FAQ

Is Pratt parsing a replacement for recursive descent?
No — it’s typically combined with it. Recursive descent handles the overall grammar; a Pratt-style loop is dropped in specifically for expression parsing.

What does “binding power” mean?
It’s another name for an operator’s precedence value inside the Pratt algorithm — how strongly it “pulls” neighboring operands into its own sub-expression versus letting an outer operator claim them.

What are “nud” and “led”?
Terms from Vaughan Pratt’s original 1973 paper for a token’s “null denotation” (how it parses with nothing to its left — i.e. a prefix handler) and “left denotation” (how it parses with an already-parsed expression to its left — i.e. an infix handler). Modern explanations, including Bob Nystrom’s, often use the friendlier terms “prefix” and “infix” parselets instead.

Does Pratt parsing handle right-associative operators like exponentiation?
Yes — passing the operator’s own precedence (rather than precedence + 1) to the recursive call on the right-hand side produces right-associativity instead of left.

Why doesn’t the code example handle left recursion directly?
It doesn’t need to — the while loop inside parse_expression handles left-leaning chains of operators (like a - b - c) iteratively, which is precisely how Pratt parsing avoids the infinite-recursion problem that a naive left-recursive grammar rule would cause in straight recursive descent.

Sources

Posted in Engineering

Memory Safety in Systems Programming: How Rust’s Borrow Checker Prevents Entire Bug Classes

For four decades, the dominant systems programming languages — C and C++ — have made a trade that most developers only notice when it fails: they hand full control of memory to the programmer and trust the programmer to get it right every single time. That trust is expensive. Microsoft has reported that roughly 70 percent of the security vulnerabilities it fixes each year, across more than a decade of patches, are memory-safety issues, and Google has independently reported the same figure for serious Chrome security bugs. Rust was designed specifically to make these bug classes — use-after-free, double-free, buffer overflows, and data races — unrepresentable in a program that compiles, without adding a garbage collector or a runtime safety net. It does this through a compile-time analysis called the borrow checker, built on three interlocking ideas: ownership, borrowing, and lifetimes. This explainer walks through that model and shows, with a real compiler error and its fix, exactly how it catches a bug before the program ever runs.

At a Glance

Core guaranteeNo use-after-free, double-free, or data races in “safe” Rust code
Enforcement mechanismStatic analysis at compile time (the borrow checker) — no garbage collector
Ownership ruleEvery value has exactly one owner at any given time
Borrowing ruleEither one mutable reference, or any number of immutable references — never both at once
Lifetime ruleA reference can never outlive the data it points to
Runtime cost of the checksNone — all analysis happens before the binary is produced
Escape hatchunsafe blocks, which opt back into manual, unchecked memory operations

What Problem This Solves

“Memory safety” refers to a cluster of related bug classes that all come from the same root cause: a program accessing memory it should not be allowed to access. The usual suspects are use-after-free (reading or writing through a pointer after the memory it points to has been freed), double-free (releasing the same block of memory twice, corrupting the allocator’s internal state), buffer overflows and out-of-bounds reads/writes (accessing an array or buffer past its allocated bounds), and data races (two threads reading and writing the same memory without synchronization, producing undefined results). MITRE’s 2024 CWE Top 25 Most Dangerous Software Weaknesses list ranks Out-of-bounds Write (CWE-787) at #2, Out-of-bounds Read (CWE-125) at #6, and Use After Free (CWE-416) at #8 — these are not rare, exotic bugs, they are consistently among the most frequently exploited weaknesses in software shipped today.

The scale is what makes this a systemic problem rather than a handful of unlucky bugs. In a 2019 MSRC blog post, Microsoft stated that “~70% of the vulnerabilities Microsoft assigns a CVE each year continue to be memory safety issues,” based on an analysis of roughly a decade of its own patch data. Google reached a nearly identical conclusion independently: in a September 2021 post on its Security Blog, the Chrome team wrote that “more than 70% of our severe security bugs are memory safety problems.” In November 2022, the U.S. National Security Agency published a Cybersecurity Information Sheet explicitly citing both figures and recommending that organizations move toward memory-safe languages where feasible. When two unrelated engineering organizations, analyzing two unrelated codebases, arrive at the same number, it stops looking like coincidence and starts looking like a property of the tools.

How C and C++ Leave This to the Runtime

C and C++ give the programmer raw pointers and manual allocation (malloc/free, new/delete) with no compiler-enforced tracking of who is responsible for freeing what, or whether a pointer is still valid. Freeing memory does not erase it or invalidate every pointer to it — it just marks the block available for reuse. A pointer that still refers to that address (“dangling”) will often still “work” until the memory is reused for something else, at which point reads and writes through it silently corrupt unrelated data. The compiler does not reject this code; the behavior is classified as “undefined,” meaning anything from a crash to a silent miscalculation to, in the worst case, attacker-controlled code execution. CVE-2014-0160 (“Heartbleed”), an out-of-bounds read in OpenSSL’s C codebase, is one of the most widely documented real-world examples of exactly this class of bug reaching production for years before detection. None of this requires programmer carelessness in the usual sense — it requires only that a large, evolving codebase have a single lapse anywhere in its call graph.

Ownership: Rust’s Core Model

Rust’s starting rule is simple: every value has exactly one owner at a time. When you assign a heap-allocated value like a String to a new variable, ownership moves to that variable, and the original variable becomes invalid — the compiler will refuse to compile any later use of it. When the owner goes out of scope, Rust automatically calls the value’s destructor and frees its memory, deterministically, with no garbage collector deciding when to run. Because exactly one piece of code is ever responsible for freeing a given value, double-free becomes structurally impossible in safe Rust: there is never a second owner left around to free the same memory again.

Borrowing and the Aliasing Rule

Moving ownership every time you want to use a value would make most programs unworkable, so Rust also lets you borrow a value through a reference without taking ownership of it. The rule governing borrows is where the real payoff shows up: at any given point in the program, you may have either one mutable reference (&mut T) or any number of immutable references (&T) to a value — never both kinds alive at the same time. This is sometimes called the “aliasing XOR mutability” rule. It directly prevents data races: if only one piece of code can ever hold a mutable reference while all other references are read-only, there is no way for two pieces of code to simultaneously read and write — or write and write — the same memory without going through an explicit, checked synchronization type such as Mutex or RwLock. The same rule also prevents the classic “reading through a reference after the underlying collection resized and moved” bug, as the code example below demonstrates.

Lifetimes: Making “How Long” Part of the Type System

Every reference in Rust has a lifetime: the span of the program during which it is guaranteed to point at valid data. Most of the time the compiler infers lifetimes automatically. When a function’s signature is ambiguous about how long an input and output reference should be related — for example, a function that takes two string references and returns whichever is longer — the programmer writes an explicit lifetime annotation such as 'a to tell the compiler “the reference I return lives no longer than the shorter of these two inputs.” Lifetime annotations don’t change how long anything actually lives in memory; they only describe relationships the compiler then checks. The practical effect is that a function can never hand back a reference to data that is about to be destroyed — the kind of mistake that, in C or C++, produces a dangling pointer discovered only when something crashes much later.

How the Borrow Checker Catches This at Compile Time

Modern Rust (since the 2018 edition) uses an analysis called non-lexical lifetimes (NLL), which ends a borrow at its last actual use in the code, rather than at the end of its enclosing block. This makes the checker precise enough to accept common, correct patterns while still rejecting the unsafe ones. The example below is a well-known case: borrowing the first element of a growable vector, then trying to push a new element onto that same vector while the borrow is still “alive.” Pushing can force the vector to reallocate and move its entire contents to a new memory address, which would leave first pointing at freed memory — a use-after-free. The borrow checker rejects this at compile time, before any such reallocation could ever happen at runtime.

// This does NOT compile. The immutable borrow `first` is still
// considered "alive" at the point where `v.push(4)` tries to
// take a mutable borrow of `v` to grow it.
fn broken() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];
    v.push(4);
    println!("{}", first);
}

// rustc's error (abridged):
//   cannot borrow `v` as mutable because it is also
//   borrowed as immutable

// This compiles. Thanks to non-lexical lifetimes, the compiler
// sees that `first` is never used again after the println!, so
// its borrow has already ended before `v.push(4)` runs.
fn fixed() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];
    println!("{}", first);  // last use of `first` — borrow ends here
    v.push(4);                // OK: no active borrow when we mutate `v`
}

Trade-Offs

  • Pro: Use-after-free, double-free, and unsynchronized data races are caught at compile time, not discovered in production or by an attacker.
  • Pro: All of this is static analysis — there is no garbage collector, no runtime borrow-tracking, and no performance penalty in the compiled binary.
  • Pro: The same rules that prevent memory bugs also tend to surface certain logic bugs (like aliased mutation) earlier in development.
  • Con: The learning curve is real — “fighting the borrow checker” is a common experience for programmers used to C, C++, Java, or Python, especially around self-referential data structures and graphs.
  • Con: Some valid, safe patterns (e.g., certain graph or GUI widget structures) require extra machinery like Rc<RefCell<T>> or arena allocators to satisfy the checker.
  • Con: Memory safety is not the same as correctness — Rust does not prevent logic errors, memory leaks (which are safe but still wasteful), or vulnerabilities unrelated to memory, such as injection or authentication flaws.
  • Con: unsafe blocks are sometimes necessary (for FFI, certain low-level optimizations, or OS interfaces) and reintroduce the possibility of memory-safety bugs within that scope.

Bottom Line

Rust’s borrow checker is not a style preference or a lint — it is a formal set of rules about ownership and aliasing that, enforced at compile time, make an entire class of bugs responsible for a documented majority of serious security vulnerabilities in memory-unsafe languages structurally impossible to write in safe Rust. That is precisely why the NSA, alongside Microsoft’s and Google’s own internal data, now points to memory-safe languages as a strategic mitigation rather than a nice-to-have. For teams building new systems-level software — network services, parsers, embedded firmware, browser engines — the upfront cost of learning ownership and borrowing is traded for the removal of one of the largest sources of critical vulnerabilities in the industry’s own data.

FAQ

Does Rust eliminate all security vulnerabilities?
No. It eliminates memory-safety bugs in safe Rust code specifically. Logic errors, injection flaws, authentication bypasses, and vulnerabilities inside unsafe blocks or in C libraries linked via FFI are all still possible.

What is unsafe Rust, and doesn’t it defeat the whole point?
unsafe is a keyword that lets a specific, explicitly marked block of code perform operations the borrow checker can’t verify, such as raw pointer dereferencing or calling into C. It’s intentionally rare, explicit, and auditable — the rest of the codebase stays checked.

Is Rust slower than C or C++ because of all these checks?
No. Ownership, borrowing, and lifetime checks happen entirely at compile time and leave no trace in the compiled binary — there is no runtime borrow-tracking overhead, unlike a garbage-collected language’s runtime.

Can the borrow checker reject programs that are actually safe?
Yes — it is deliberately conservative. Some patterns that a human can reason are safe (certain data structures with internal aliasing) aren’t provable by the checker’s current rules, and need wrapper types like RefCell or restructuring to compile.

What’s the difference between ownership and borrowing?
Ownership determines who is responsible for freeing a value and lasts until that owner goes out of scope. Borrowing lets other code read (or, exclusively, write) a value temporarily without taking that responsibility.

Why are Microsoft and Google talking about this if they mostly ship C and C++?
Because their own vulnerability data — patches issued over many years across Windows and Chrome respectively — showed memory safety bugs as the dominant category of serious security fixes, which is why both now invest in memory-safe language adoption (including Rust) for new and rewritten components.

Sources

Posted in Engineering

Parsing Explained: Recursive Descent, Parser Combinators, and PEG Grammars

Every compiler, interpreter, linter, config-file loader, and query engine starts with the same problem: turning a flat stream of characters or tokens into a structured representation a program can act on. That’s parsing. Three approaches dominate how working engineers solve it today: hand-written recursive descent, parser combinators, and PEG (Parsing Expression Grammar) tooling. Each trades off control, declarativeness, and performance differently, and picking the wrong one for a project tends to show up later as unreadable code or incomprehensible error messages.

At a Glance

ApproachStrengthsBest For
Recursive DescentFull control over control flow and diagnostics; no dependencies; fast; easy to debugProduction compilers/interpreters that need custom, high-quality error messages
Parser CombinatorsComposable and declarative; parsers are ordinary values you can test in isolation; stays inside the host languageRapid prototyping, embedded DSLs, binary/text protocol parsers (nom, Parsec-style libraries)
PEG GrammarsGrammar lives in a separate, declarative file; unambiguous by construction; generated code is typically fastConfig languages, DSLs, and projects that want a formal grammar artifact (Pest, PEG.js)

What Parsing Actually Does

A parser takes a sequence of tokens (produced by a lexer/tokenizer) and builds a tree-shaped representation, usually an Abstract Syntax Tree (AST), that reflects the grammar’s structure and operator precedence. The three approaches below differ mainly in how that grammar gets expressed: as imperative code, as composed function values, or as a separate declarative grammar file.

Recursive Descent Parsing

Recursive descent is, as Robert Nystrom puts it in Crafting Interpreters, “the simplest way to build a parser.” It’s a top-down, predictive technique: each grammar rule becomes a function, terminals become code that matches and consumes a token, alternatives (|) become if/switch statements, and repetition (*, +) becomes a loop. When a rule refers to itself, that becomes a recursive function call — hence the name: the parser “walks down” the grammar. Nystrom notes that recursive descent parsers are “fast, robust, and can support sophisticated error handling,” and that production compilers including GCC, V8, and Roslyn use it.

Here is a minimal, correct recursive descent parser for arithmetic expressions with standard precedence (*// binds tighter than +/-), written in JavaScript and verified to run:

// Grammar:
// expr   -> term (("+" | "-") term)*
// term   -> factor (("*" | "/") factor)*
// factor -> NUMBER | "(" expr ")"

function parse(tokens) {
  let pos = 0;
  const peek = () => tokens[pos];
  const next = () => tokens[pos++];

  function parseExpr() {
    let left = parseTerm();
    while (peek() === "+" || peek() === "-") {
      const op = next();
      const right = parseTerm();
      left = { type: op === "+" ? "Add" : "Sub", left, right };
    }
    return left;
  }

  function parseTerm() {
    let left = parseFactor();
    while (peek() === "*" || peek() === "/") {
      const op = next();
      const right = parseFactor();
      left = { type: op === "*" ? "Mul" : "Div", left, right };
    }
    return left;
  }

  function parseFactor() {
    const token = next();
    if (token === "(") {
      const expr = parseExpr();
      next(); // consume ")"
      return expr;
    }
    return { type: "Num", value: Number(token) };
  }

  return parseExpr();
}

const tokens = ["2", "+", "3", "*", "(", "4", "-", "1", ")"];
console.log(parse(tokens));
// Evaluating the resulting tree gives 11, matching 2 + 3 * (4 - 1)

Notice the loop in parseTerm and parseExpr instead of a recursive call back into themselves at the start of the rule — that’s the standard fix for left recursion in hand-written recursive descent, and it’s exactly the technique Crafting Interpreters uses for the same reason: a rule that calls itself as its very first action would recurse forever and overflow the stack.

Parser Combinators

Parser combinators express a grammar as ordinary functions (or values) that you compose with higher-order combinators like sequence, choice, and many. Instead of writing a function per grammar rule by hand, you build a parser for “digit” and combine it with “many” to get “number,” then combine that with “plus” and “number” to get “sum.” This style originates in functional programming (Haskell’s Parsec is the canonical example) and is popular in Rust via the nom crate, which documents itself as a parser-combinator library built around byte- and bit-level parsers. The appeal is composability and testability: each small parser is a first-class value you can unit-test on its own and reuse across grammars.

The trade-off is that stack traces and error messages come from the combinator library’s generic machinery rather than code you wrote for that specific rule, so by default errors tend to be less tailored than a hand-rolled recursive descent parser’s — libraries like nom mitigate this with dedicated error-context combinators, but it’s extra work layered on top of the base approach.

PEG Grammars

A Parsing Expression Grammar looks like a context-free grammar (BNF-style rules) but is interpreted differently: alternatives are tried in order and the first one that matches wins — “ordered choice” — which makes PEGs unambiguous by construction, unlike classic CFGs where multiple derivations can be possible. Pest, a widely used PEG parser generator for Rust, describes PEGs on its own documentation site as “eager, ordered, and unambiguous,” and compiles a separate .pest grammar file into a Rust parsing function at build time. PEG.js and similar tools apply the same idea to JavaScript. A toy version of the arithmetic grammar above, written in Pest’s grammar syntax, looks like this:

expr   = { term ~ (("+" | "-") ~ term)* }
term   = { factor ~ (("*" | "/") ~ factor)* }
factor = { number | "(" ~ expr ~ ")" }
number = { ASCII_DIGIT+ }

The grammar is now a declarative artifact, separate from the code that walks the resulting parse tree. The catch is left recursion: standard PEGs cannot express a rule like expr = expr ~ "+" ~ term | term directly, because the rule would try to match itself at the same input position before consuming anything and loop forever. Academic work on this problem — notably Warth, Douglass, and Millstein’s “Left Recursion in Parsing Expression Grammars” — proposes algorithms to support it, and some modern PEG tools implement variants of those algorithms, but plain PEG tooling generally expects you to rewrite left recursion as repetition, exactly as in the recursive descent example above.

Comparison Table

DimensionRecursive DescentParser CombinatorsPEG Grammars
PerformanceFast; no abstraction overheadGood, but combinator/closure overhead can add up without optimizationFast; generated code is comparable to hand-written parsers
ReadabilityReads like the grammar, but grammar and code are interleavedReads like the grammar if combinators are named well; can get denseGrammar is separate and declarative, often the clearest artifact
Left recursionMust be manually rewritten as loops/repetitionMust be manually rewritten; combinators don’t solve it for youNot supported by default; needs a rewrite or an extended algorithm
Error messagesBest in class — you write exactly the diagnostic you wantDepends on the library; often needs extra “context” combinatorsGenerated; quality depends on the tool (Pest reports expected rules)

Trade-offs

  • Recursive descent costs the most up-front code but gives the most control, which is why production compilers favor it when error-message quality matters.
  • Parser combinators minimize boilerplate and maximize reuse, at the cost of needing to learn a combinator vocabulary and sometimes fighting generic error output.
  • PEG grammar tools give you a formal, swappable grammar file and strong tooling (syntax highlighting, grammar linting), but you depend on the generator’s runtime and its specific left-recursion and error-reporting behavior.

Bottom Line

If you’re building a language or tool where error messages are a feature — a compiler users will stare at all day — hand-written recursive descent is still the pragmatic default, which is exactly why GCC, V8, and Roslyn use it. If you’re parsing something smaller and want composability and quick iteration inside an existing codebase, reach for a parser-combinator library like nom. If you’d rather keep the grammar as a separate, formal artifact and let a generator produce the parser, PEG tools like Pest or PEG.js are the right fit — just plan for the left-recursion rewrite up front.

FAQ

Is a lexer the same thing as a parser? No. A lexer (tokenizer) turns raw characters into a flat stream of tokens; a parser turns that token stream into a tree. All three approaches in this article assume a token stream already exists.

Do any of these three handle left recursion automatically? Not by default. Hand-written recursive descent, basic parser combinators, and standard PEG tools all require you to rewrite left-recursive rules as repetition. Specialized algorithms exist for left-recursive PEGs, but they’re an extension, not the baseline behavior.

Why do PEGs guarantee no ambiguity while classic context-free grammars don’t? Because PEG alternation (|) is ordered choice — the first alternative that matches wins, full stop — rather than a set of possibilities a general CFG parser must disambiguate.

Which approach is fastest? Hand-written recursive descent and generated PEG parsers are typically the fastest, since both compile down to direct, specialized code. Parser combinators can match that speed but depend on how well the library inlines and avoids allocation.

Can I mix approaches? Yes — it’s common to hand-write the top-level recursive descent structure for a language’s statements while using a small PEG or combinator-based parser for an embedded sub-language like a format string or query expression.

Sources