We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Macros, FFI and Type-Driven Design step 15 of 28
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:
- 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.
- Anything else → move one token tree from the input into the term buffer.
- 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.
Stuck?
Rust reference solution
Sign in to attempt this problem and reveal the reference solution.