Viscosity
Review
Part X · Drip in Rust · Day 38

151. A Pratt compiler in Rust, part 1

The Pratt parser’s table moves to Rust almost row for row. Each cell becomes a function pointer, the precedence becomes an ordered enum, and the type checker has one small surprise about functions.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Read drip-rs’s ParseRule and ParseFn types and say what a function pointer is.
  • Explain why the rule table is a match, and why its arms need a type annotation.
  • Compare precedence as numbers (TypeScript, C) with an enum that derives Ord.
  • Trace parse_precedence on an expression, using the table.

The table you already wrote

In chapter 48 your TypeScript compiler parsed expressions with a Pratt parser driven by a table. Each token type maps to how it starts an expression (prefix), how it continues one (infix), and how tightly it binds:

const RULES = {
  '-': { prefix: unary, infix: binary,
         prec: Prec.TERM },
  '+': { prefix: null, infix: binary,
         prec: Prec.TERM },
  '*': { prefix: null, infix: binary,
         prec: Prec.FACTOR },
  // ...
};
const getRule = (t) => RULES[t] ?? NO_RULE;

drip-rs keeps the same idea, the same rows and the same algorithm. What changes is how a cell holds a function, and how precedences compare.

Intuition

Think of a phrasebook with one page per word. Each page answers three questions: can this word start a sentence, and how? Can it carry on a sentence someone else started, and how? And how strongly does it pull its neighbours towards itself? The parser never thinks about grammar. It looks up the page and does what it says.

A cell is a function pointer

From src/compiler.rs:

type ParseFn<'src, 'a> =
    fn(&mut Compiler<'src, 'a>, bool);

struct ParseRule<'src, 'a> {
    prefix: Option<ParseFn<'src, 'a>>,
    infix: Option<ParseFn<'src, 'a>>,
    precedence: Precedence,
}

fn(&mut Compiler, bool) is a function pointer type: it points at some function taking those arguments, and which function needn’t be known at compile time[2]. Every parse function in drip-rs is a method like fn binary(&mut self, can_assign: bool), and a method is just a function whose first parameter is self. So Self::binary fits. The bool says whether an = may follow (chapter 48’s canAssign). The lifetimes are there because Compiler borrows the source and the heap (chapter 152).

Option stands in for TypeScript’s null. It costs nothing: on this machine size_of::<Option<fn(&mut u8, bool)>>() is 8, the same as the bare pointer. Function pointers are assumed never to be null[3], so None can use the null pattern. A whole ParseRule measured 24 bytes.

There is one more difference. A TypeScript parse function is a closure that reads previous and current from the enclosing compile. A function pointer captures nothing (chapter 134’s closures can, but then they aren’t plain fn pointers). Everything a parse function needs arrives through its &mut Compiler argument. That is chapter 152’s subject.

The table is a match

TypeScript looks the rule up in an object, and clox indexes an array that has one row for every token type[1]. drip-rs builds the row with a match on the TokenKind enum (chapter 146). Adapted from Compiler::rule:

use Precedence as P;
use TokenKind as T;
let (prefix, infix, precedence):
    (Option<ParseFn>, Option<ParseFn>, _) =
    match kind {
        T::Minus => (Some(Self::unary),
            Some(Self::binary), P::Term),
        T::Plus => (None,
            Some(Self::binary), P::Term),
        T::Slash | T::Star => (None,
            Some(Self::binary), P::Factor),
        T::Number => (Some(Self::number),
            None, P::None),
        // ... 12 more arms
        _ => (None, None, P::None),
    };
ParseRule { prefix, infix, precedence }

T::Slash | T::Star is one arm for two tokens. The _ arm is ?? NO_RULE: every token not listed can neither start nor continue an expression. (The price of _ is that a new token kind gets an empty rule without a word from the compiler, where clox’s array has a row for every token.)

Why the type annotation on the tuple? In Rust, each function has its own unique type, and Self::unary is not the same type as Self::number. Leave the annotation off, and rustc 1.97 says (trimmed):

error[E0308]: `match` arms have
  incompatible types
  _ => (Some(Self::b), None, 0),
       ^^^^^^^^^^^^^^^^^^^^^^^^
       expected fn item, found a
       different fn item

Annotating the tuple as Option<ParseFn> tells rustc to turn each function into the shared function-pointer type.

Definition

A parse-rule table maps each token kind to a prefix parse function, an infix parse function (either may be absent) and an infix precedence. In drip-rs it is Compiler::rule, a match on TokenKind returning a ParseRule of Option<fn(&mut Compiler, bool)> cells.

Precedence is an ordered enum

TypeScript’s precedences are numbers, NONE: 0 up to PRIMARY: 10. drip-rs’s are an enum, lowest first:

#[derive(Clone, Copy, PartialEq, Eq,
    PartialOrd, Ord)]
enum Precedence {
    None, Assignment, Or, And, Equality,
    Comparison, Term, Factor, Unary,
    Call, Primary,
}

Deriving PartialOrd and Ord on an enum orders the variants by their position: the first is the smallest[4]. So Precedence::Term < Precedence::Factor, and <= works between precedences exactly as it did between numbers. What doesn’t work is + 1. The TypeScript binary parses its right operand at getRule(op).prec + 1, and clox casts the enum to an integer and adds one. drip-rs writes a method, next(), that is a match from each variant to the one above it. Primary.next() stays Primary.

The heart of the parser

parse_precedence is almost line for line the TypeScript one:

fn parse_precedence(&mut self,
    precedence: Precedence) {
    self.advance();
    let kind = self.previous.kind;
    let rule = Self::rule(kind);
    let Some(prefix) = rule.prefix else {
        self.error("Expect expression.");
        return;
    };
    let can_assign = precedence
        <= Precedence::Assignment;
    prefix(self, can_assign);
    while precedence
        <= Self::rule(self.current.kind)
            .precedence
    {
        self.advance();
        let k = self.previous.kind;
        let infix = Self::rule(k).infix
            .expect("infix rule");
        infix(self, can_assign);
    }
}

(Adapted. The real one goes on to report “Invalid assignment target.”) prefix(self, can_assign) calls through the pointer, handing over the compiler itself. rule has no self: it’s an associated function, so it borrows nothing and can be called in the middle of the loop condition. The expect records a promise the table keeps: every token with a precedence above None has an infix rule.

Step through -1 + 2 * 3:

Step throughThe table drives the parse
Tokens, the parse-rule table, the calls in progress and the emitted bytecode-1+2*3;tokenprefixinfixprecedence-unarybinaryTerm+—binaryTerm*—binaryFactorNumbernumber—None;——NoneCALLS IN PROGRESS (innermost last)parse_precedence(Assignment)EMITTED
expression() calls parse_precedence with the lowest real precedence, Assignment. The next token is -.
1/9
Under the hood

In binary, the right operand is parsed at the operator’s precedence .next(), one step tighter. That one step makes 8 - 3 - 2 group as (8 - 3) - 2: the second - has the same precedence as the first, which is not tighter, so it can’t join the right operand and is left for the outer loop. Pass the operator’s own precedence instead, and the second - would join, giving 8 - (3 - 2). Chapter 152’s exercise makes you get this right.

Your turn

ExerciseA precedence table

Here is a cut-down Prec enum, lowest first.

1. Give it the derives it needs, so that precedences can be copied, compared with == and ordered with < and <= by their position, and printed with {:?}. 2. infix_prec(c): + and - are Term, * and / are Factor, anything else is None. 3. next(): one level tighter. Unary is the top, and stays Unary. 4. binds(min, c): may the operator c continue an expression being parsed at min? That is drip-rs's loop condition: min <= infix_prec(c).

enum Prec {
    None,
    Term,
    Factor,
    Unary,
}

fn infix_prec(c: char) -> Prec {
    todo!()
}

impl Prec {
    fn next(self) -> Prec {
        todo!()
    }
}

fn binds(min: Prec, c: char) -> bool {
    todo!()
}
Open in Rust Playground ↗
Extra practiceA table of function pointers

Write four functions add, sub, mul and div of type BinFn, a table op_fn(c) that returns the right one for + - * / (and None for anything else), and apply(c, a, b), which looks the function up and calls it. One test checks that your table's cells cost no more than a bare pointer.

type BinFn = fn(f64, f64) -> f64;

fn op_fn(c: char) -> Option<BinFn> {
    todo!()
}

fn apply(c: char, a: f64, b: f64)
    -> Option<f64> {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • A parse-rule table cell is an Option<fn(&mut Compiler, bool)>: a function pointer or nothing. Option adds no size, and a pointer captures nothing, so state arrives through &mut Compiler.
  • Compiler::rule is a match on TokenKind. Its arms need a ParseFn annotation, because every function has its own type.
  • Precedence is an enum deriving Ord, so variants compare by position. next() replaces + 1.
  • parse_precedence calls the prefix rule, then infix rules while the next token binds at least as tightly as the given precedence.

Check your understanding

0/6
  1. 1.

    What is ParseFn in drip-rs?

  2. 2.

    Without the tuple annotation, match kind { T::Minus => (Some(Self::unary), ...), T::Number => (Some(Self::number), ...), ... } fails. Why?

  3. 3.

    With #[derive(PartialOrd, Ord)] enum Precedence { None, Assignment, Or, ..., Primary }, which is true?

  4. 4.

    In parse_precedence(Precedence::Factor), the next token is + (precedence Term). What happens?

  5. 5.

    How big is Option<fn(&mut u8, bool)> on the book’s 64-bit machine?

  6. 6.

    Why does drip-rs’s binary parse its right operand at precedence.next()?

References

  1. [1] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)
  2. [2] The Rust Reference contributors. Function pointer types. The Rust Reference. doc.rust-lang.org/reference/types/function-pointer.html (checked 2026-10-31)
  3. [3] The Rust Project Developers. Primitive type fn (function pointers). Rust standard library documentation (library/core/src/primitive_docs.rs). doc.rust-lang.org/std/primitive.fn.html (checked 2026-10-31)
  4. [4] The Rust Project Developers. Trait Ord (and PartialOrd): derivable ordering. Rust standard library documentation (library/core/src/cmp.rs). doc.rust-lang.org/std/cmp/trait.Ord.html (checked 2026-10-31)