Skip to content

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

Hard Primitives

Internal rules and push-down accumulation

Make a macro that writes a whole enum — plus an impl block to go with it.

build_enum! { Color: Red = 1, Green = 2, Blue = 4 }
build_enum! { Level: Low = 10, High = 20, }

pub fn variants() -> Vec<(String, u8)>

Each invocation must expand to a #[derive(Debug, …)] #[repr(u8)] pub enum with the given variants and discriminants, and an impl with an all() -> Vec<(&'static str, u8)> associated function listing them in order.

This is item 18.1’s third justification made concrete: functions cannot generate items. Macros can, and that is when they stop being a convenience.

Why an accumulator is unavoidable

A muncher (18.8) peels one item at a time. That works when each step can emit its own piece of output — 1 + count!(rest) glues fine because + is an expression operator.

Item generation cannot work that way. You cannot emit half an enum, recurse, and emit the other half; an enum is a single item and has to come out whole. So the recursion has to carry the finished work forward and emit everything on the last step:

build_enum!(@acc Color []                        Red = 1, Green = 2, Blue = 4)
build_enum!(@acc Color [Red = 1,]                Green = 2, Blue = 4)
build_enum!(@acc Color [Red = 1, Green = 2,]     Blue = 4)
build_enum!(@acc Color [Red = 1, Green = 2, Blue = 4,])      <- emits everything

That is push-down accumulation. Only the final rule produces code; every earlier one just moves a variant from the input into the accumulator.

Learners reliably try to “return” a value from the recursion — to have the inner call hand something back to the outer one. There is no such thing. Expansion goes one way, outwards, and the only channel between steps is the argument list of the next invocation.

The accumulator must be one token tree

Notice [...] around the accumulator. That is not cosmetic. Brackets make the whole accumulated list a single token tree, so the matcher can bind it with one [$($done:tt)*] and know exactly where it ends. Write it bare and the matcher cannot tell where accumulated output stops and pending input starts; you get “local ambiguity” errors that are very hard to read.

@ is a convention, not magic

@acc has no special meaning to the compiler. @ is simply a token that cannot begin any legitimate user invocation of this macro, which makes it a reliable marker for “this rule is internal”. Any such token works — ~, !!, a __private identifier. @ is what the ecosystem settled on, so use it.

Two ordering consequences, and both matter:

  • Internal rules go first. They are the specific ones; the public entry rule is the general one. The general rule must never get a chance to swallow an internal invocation (18.8 shows what that costs).
  • The tag is what makes internal rules unreachable by users. Someone writing build_enum!(@acc Color [] …) by hand is doing something wrong, and the sigil is the sign that says so.

The allow-by-default rustc lint unused_macro_rules is useful here: switch it on while developing and it will tell you which of your arms is never reached, which is the fastest way to find an ordering mistake.

The trailing-comma arm

build_enum! { Level: Low = 10, High = 20, } ends with a comma; the Color invocation does not. That needs two munching rules — one for $vn = $vv, with a comma, one for a final $vn = $vv without — and the second normalises it by appending a comma to the accumulator anyway. After that, the terminal rule only ever sees a uniform comma-terminated list, which is what lets its matcher be the clean [$($vn:ident = $vv:literal,)*]`. Normalising on the way in so the final rule can be simple is a pattern you will reuse constantly. ::: question The terminal rule matches `[$($vn:ident = $vv:literal,)*] — it takes the accumulator apart again. Why not just splice the tokens straight through?

Because the accumulated tokens have to be used in two places, in different shapes.

Splicing $($done)* into the enum body works. But all() needs the same variants written differently — as (stringify!($vn), $name::$vn as u8)` tuples — and there is no way to transform raw token soup into that. Re-matching the accumulator with a structured matcher recovers `$vn and $vv as separate metavariables, and once you have those you can emit them in as many shapes as you like.

This is the general move: accumulate loosely as tts while munching, then re-parse structurally in the terminal rule. It costs one extra matcher and buys you arbitrary output. :::

Your job

Write the two internal munching rules. The terminal rule and the public entry rule are already there; the starter fails to compile because nothing bridges them.