Skip to content

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

Hard Primitives

A mini DSL: compile-time expression evaluation

Build an arithmetic macro with two precedence levels. The starter evaluates every operator from left to right, so calc!(2 + 3 * 4) produces 20. Change it to multiply first and produce 14.

Keep the supplied ANSWER constant and results() function. Implement the parsing in macro_rules!, with an additive tier for + and -, a multiplicative tier for * and /, and atom rules for integers and parentheses.

Invocation Expected 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

Operators at the same precedence level associate left to right. Division is integer division: 7 / 2 is 3. The exercise uses nonnegative integer literals, although subtraction can produce negative results. You do not need to support variables, function calls, unary operators or other Rust syntax. Assume intermediate values fit in i64 and divisors are nonzero.

Expansion builds the expression

The macro matches tokens and emits a Rust expression. Rust then evaluates that expression. In this declaration, evaluation must succeed at compile time:

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

The same macro can appear inside results(). That use does not itself require constant evaluation, even if the compiler optimizes the arithmetic away. Compiling a constant checks that its initializer is permitted in a constant context; the expected value still needs checking.

Split the grammar into tiers

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

The additive tier collects a whole term before passing it to the multiplicative tier. For 2 + 3 * 4, those terms are 2 and 3 * 4. The multiplicative tier splits the latter into factors 3 and 4. Each atom is a literal or a group that starts this process again.

A parenthesized group such as (2 + 3) is one token tree. Scanning it as a $head:tt keeps it intact, so the outer tier does not mistake its inner + for a separator. The supplied atom rule opens the group and recurses:

(@atom ($($inner:tt)+)) => { calc!($($inner)+) };
(@atom $n:literal) => { $n };

Carry the work through recursive calls

Give each tier two token buffers: completed output and the piece currently being collected. An additive call can have this shape:

calc!(@add [completed output] [current term] remaining tokens)

Handle three cases:

  1. At + or -, finish the nonempty current term through the multiplicative tier, append that result and the operator to the output, then clear the term.
  2. Otherwise, move one token tree into the current term and recurse.
  3. With no input left, finish the last term and emit the accumulated expression.

Use the same pattern for * and /, passing factors to the atom rules. Put operator-specific arms before the generic token-consuming arm. Keep the public catch-all arm last so it does not intercept internal @add or @mul calls. This builds on token-tree munchers.

Preserve operator order when appending output. A flat 10 - 3 - 2 expression evaluates left to right; explicitly generating 10 - (3 - 2) changes the answer to 9. Parentheses around each generated subexpression make the intended boundaries clear, but they do not fix an incorrectly grouped parse.

Keep expansion manageable

These recursive rules suit the small inputs here. Long inputs can hit the compiler's expansion recursion limit. Check for a catch-all arm intercepting internal calls before increasing the limit: a larger limit does not fix unbounded recursion. Expression-producing arms should emit expressions without a trailing semicolon inside the output delimiters.

The harness reads the fixed vector from results() and optionally selects indices. It does not parse arithmetic strings from JSON. The complete vector should be [14, 20, 5, 1, 9, 7, 21, 8, 14], including ANSWER at the end.