Skip to content

← Macros, FFI and Type-Driven Design step 15 of 28

Hard Primitives

A mini DSL: compile-time expression evaluation

The capstone of the macro_rules! half of this track: an arithmetic evaluator that runs entirely at compile time.

pub const ANSWER: i64 = calc!(2 + 3 * 4);

pub fn results() -> Vec<i64>

calc! must handle + - * / with correct precedence, and parentheses:

invocation value
calc!(2 + 3 * 4) 14
calc!((2 + 3) * 4) 20
calc!(10 - 3 - 2) 5
calc!(20 / 4 / 5) 1
calc!(2 * (3 + 4) - 5) 9
calc!(((8))) 8

The const binding is the proof: a const initialiser must be evaluable at compile time, so if this compiles at all, the arithmetic really happened during compilation.

Anyone who finishes this understands why syn exists.

The constraint that shapes everything

macro_rules! has no arithmetic, no comparison, and no mutable state.

It cannot add two numbers. It cannot ask whether one token is * and another is + and branch on the answer. It has exactly one control-flow mechanism: whether a matcher matches. So precedence cannot be computed — it has to be encoded structurally, as separate rule tiers, the way a hand-written recursive-descent parser encodes it.

The grammar you are implementing:

expr   := term  (('+' | '-') term)*
term   := factor (('*' | '/') factor)*
factor := literal | '(' expr ')'

Two tiers plus atoms. The additive tier never touches *; the multiplicative tier never touches +. Precedence falls out of which tier owns which operator.

A parenthesised group is one tt

This is the fact that makes the whole thing tractable. (2 + 3) is a single token tree, so when the additive tier scans for a top-level +, the + inside the parentheses is invisible to it — it is buried inside the group.

That means the atom rule can be beautifully simple:

(@atom ($($inner:tt)+)) => { calc!($($inner)+) };   // recurse into the group
(@atom $n:literal)      => { $n };

A parenthesised subexpression gets its own dedicated arm, and the recursion handles arbitrary nesting — including ((8)).

The shape of each tier

Each tier is a muncher with two accumulators (18.8, 18.9):

calc!(@add [what we have summed so far] [tokens of the term being collected] rest…)

Three kinds of rule per tier:

  1. The current token is this tier’s operator, and the term buffer is non-empty → close the term (hand it to the tier below), append it plus the operator to the sum accumulator, clear the buffer, continue.
  2. Anything else → move one token tree from the input into the term buffer.
  3. Input exhausted → close the final term and emit the whole accumulated expression.

Rule 1 must come before rule 2, or the generic muncher swallows the operators. Rule 3 comes last within the tier.

And the public entry rule — ($($t:tt)+), which matches everything — must be the last rule in the entire macro, for the reason item 18.8 made painful.

Where atomicity does the work

When the additive tier finishes it emits something like

(calc!(@mul 2)) + (calc!(@mul 3 * 4))

Each calc!(@mul …) expands to an expression, and each is wrapped in parentheses in the transcriber. That is not superstition — it is item 18.4’s lesson applied deliberately. The tier below produces a token stream, and without the parentheses the tiers’ operators would re-associate against each other and 10 - 3 - 2 would come out as 9.

Watch the recursion limit

Munchers cost one recursion level per token, and this macro runs two tiers plus atoms, so a long expression multiplies up quickly. The default limit is 128 and the inputs here are small. If you ever hit “recursion limit reached while expanding”, the fix is

#![recursion_limit = "512"]

as the very first line of the crate.

One more hazard specific to expression-producing macros: if a transcriber ends with a stray ; while the macro is used in expression position, you trip semicolon_in_expressions_from_macros — a deny-by-default, future-incompatible lint. Keep the tier transcribers as bare expressions.

::: question This works. So why does anyone need a procedural macro?

Because you have just spent a page encoding a grammar that a proc macro would express as a while loop.

Count what was unavailable: no arithmetic on tokens, no comparison, no mutable state, no loops, no way to look at the type of anything, no way to build an identifier from two pieces. Every one of those had to be simulated with pattern structure and recursion, and the result is O(n²) to compile and essentially unreadable to anyone who did not write it.

A procedural macro is an ordinary Rust program that receives a TokenStream and returns a TokenStream. It can loop, allocate, call a real parser, and build a new identifier by concatenating strings. syn is that real parser — it turns a TokenStream into a proper syntax tree — and quote! builds the output. A calc! proc macro would be a shunting-yard implementation of about forty lines.

The price is real: a compile-time dependency on syn, arbitrary code executed on the developer’s machine during the build, worse IDE support, and a separate crate because a proc macro cannot live in the crate it transforms. Items 18.17 to 18.20 work through when that trade is worth making. What matters is that you now know the ceiling from the inside, rather than being told about it. :::

Your job

The starter has the atom rules and one flat tier that evaluates strictly left to right — so 2 + 3 * 4 comes out as 20. Split it into two tiers.