Advanced Declarative Macros: Repetition, Recursion, and the Limits of Pattern Matching
macro_rules! can only match on the syntactic structure of tokens — it cannot inspect types, evaluate expressions, or make semantic decisions. This constraint keeps macro expansion predictable and bounded.
vec![1, 2, 3] handles three elements. vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10] handles ten. vec![] handles zero. The declarative macro does not know in advance how many arguments it will receive — yet it generates correct, type-checked code every time. How? Repetition. But repetition reveals something deeper about what macro_rules! is and is not allowed to do. It can match any number of tokens — but only by their syntactic structure. It cannot inspect types, evaluate expressions, or make semantic decisions. This boundary is the structural recursion invariant: macro_rules! expansion is confined to syntax, which makes it predictable, traceable, and bounded.
The Predictability Guarantee
Why does this constraint matter? Because a macro you cannot predict is a macro you cannot trust. If macro_rules! could inspect types or evaluate expressions, you would need to understand the full type-checking state of your program to know what a macro expands to. By confining it to syntactic pattern matching, Rust guarantees something powerful: you can always trace what a macro_rules! macro does by reading its patterns. No hidden type-dependent branching. No runtime values leaking into compilation. The patterns are the complete truth about what the macro will do.
Every feature in this post — repetition, recursion, TT munching — operates within this boundary. And every limit — the inability to inspect types, to generate new names, to evaluate expressions — is what keeps the guarantee intact.
Repetition: Compile-Time Unrolling Under Contract
Repetition lets a macro match a variable number of tokens. The count is determined entirely by the input structure — not by runtime values, not by types:
1
2
3
4
5
6
7
8
9
10
11
macro_rules! say_all {
($($msg:expr),*) => {
$(
println!("{}", $msg);
)*
};
}
fn main() {
say_all!("hello", "world", "from", "a", "macro");
}
1
2
3
4
5
hello
world
from
a
macro
$($msg:expr),* matches zero or more expressions separated by commas. In the expansion, $( println!("{}", $msg); )* repeats the body once per captured expression. The * means zero or more; + means one or more.
The invariant at work: the compiler knows the repetition count at compile time because it is determined by the tokens in the invocation. Five expressions in, five println! calls out. The expansion invariant then checks each generated call — if any expression is not Display, the type checker catches it.
Require at least one argument with +:
1
2
3
4
5
6
7
8
9
10
11
macro_rules! say_all_nonempty {
($($msg:expr),+) => {
$(
println!("{}", $msg);
)+
};
}
fn main() {
say_all_nonempty!(); // ERROR — the contract requires at least one expression
}
1
2
3
4
5
6
7
8
error: unexpected end of macro invocation
--> src/main.rs:9:5
|
1 | macro_rules! say_all_nonempty {
| ----------------------------- when calling this macro
...
9 | say_all_nonempty!();
| ^^^^^^^^^^^^^^^^^^^ missing tokens in macro arguments
The + is an input contract: the macro refuses to expand without at least one match. The error comes before any expansion happens.
Rebuilding vec!: Every Generated Line Is Checked
Rebuilding vec! from scratch shows how the expansion invariant and repetition work together:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
macro_rules! my_vec {
() => {
Vec::new()
};
($($element:expr),+ $(,)?) => {
{
let mut v = Vec::new();
$(
v.push($element);
)+
v
}
};
($element:expr; $count:expr) => {
vec![$element; $count]
};
}
fn main() {
let empty: Vec<i32> = my_vec![];
let numbers = my_vec![1, 2, 3, 4, 5];
let trailing = my_vec![10, 20, 30,]; // trailing comma allowed
let repeated = my_vec![0; 5];
println!("{:?}", empty); // []
println!("{:?}", numbers); // [1, 2, 3, 4, 5]
println!("{:?}", trailing); // [10, 20, 30]
println!("{:?}", repeated); // [0, 0, 0, 0, 0]
}
Three arms, three syntactic patterns. The second arm generates one v.push($element); per captured expression. The $(,)? allows an optional trailing comma — another syntactic contract. The expansion is wrapped in a block { ... } so it evaluates to a single Vec.
Here is the invariant at work: each generated v.push($element) is type-checked individually. Push a String into a Vec<i32> and the compiler catches it in the expansion — the macro generated the code, the compiler rejected it. No exemption because a macro wrote it.
Nested Repetition: Structural Matching at Every Level
Repetitions can nest. The macro matches structure at every level, and the compiler checks the expansion at every level:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use std::collections::HashMap;
macro_rules! hashmap {
($($key:expr => $value:expr),* $(,)?) => {
{
let mut map = HashMap::new();
$(
map.insert($key, $value);
)*
map
}
};
}
fn main() {
let scores = hashmap! {
"Alice" => 100,
"Bob" => 85,
"Carol" => 92,
};
for (name, score) in &scores {
println!("{}: {}", name, score);
}
}
The pattern $($key:expr => $value:expr),* captures key-value pairs. The => is a literal token separator — not the macro arrow, but a token the macro expects in the input. $key and $value expand in lockstep: they must have been captured the same number of times. This is enforced structurally, not semantically.
For deeper nesting:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
macro_rules! matrix {
($([$($val:expr),* $(,)?]),* $(,)?) => {
vec![
$(
vec![$($val),*],
)*
]
};
}
fn main() {
let grid = matrix![
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
];
for row in &grid {
println!("{:?}", row);
}
}
1
2
3
[1, 2, 3]
[4, 5, 6]
[7, 8, 9]
The outer repetition matches rows. The inner matches elements. The compiler generates nested vec![] calls and type-checks every one. The macro sees structure; the compiler verifies semantics.
Recursive Macros: Bounded Expansion
Macros can invoke themselves — but Rust enforces a recursion limit (128 by default) to guarantee that expansion terminates:
1
2
3
4
5
6
7
8
9
10
11
macro_rules! count {
() => { 0usize };
($head:tt $($tail:tt)*) => {
1usize + count!($($tail)*)
};
}
fn main() {
let n = count!(a b c d e);
println!("count: {}", n); // count: 5
}
The first arm is the base case: zero tokens, zero count. The second peels off one token and recurses on the rest. Each call reduces the input by one token — structural recursion that always makes progress toward the base case.
This is the bounded expansion guarantee: the macro cannot loop forever because it must consume tokens to recurse, and the token list is finite. Hit the recursion limit and the compiler stops you:
1
2
3
4
5
#![recursion_limit = "8"]
fn main() {
let n = count!(a b c d e f g h i j);
}
1
error: recursion limit reached while expanding `count!`
The limit is a safety net. Hitting it usually means the macro’s design needs rethinking — not that the limit needs raising.
TT Munching: Processing Tokens Under Structural Constraints
TT munching processes tokens one at a time from the front of the input. It combines pattern matching with recursion:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
macro_rules! comma_separated {
() => {};
($single:expr) => {
print!("{}", $single);
};
($head:expr, $($tail:expr),+) => {
print!("{}, ", $head);
comma_separated!($($tail),+);
};
}
fn main() {
comma_separated!(1, 2, 3, 4, 5);
println!();
}
1
1, 2, 3, 4, 5
Three arms: empty, single item, and head-plus-tail. Each step consumes one expression. The term “TT munching” comes from tt — the token tree fragment specifier that captures any single token or delimited group. The macro “munches” through them, and the structural constraint guarantees progress.
Internal Rules: Separating Contract from Implementation
When macros grow complex, the @ convention separates the public interface from internal implementation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
macro_rules! sorted_list {
($($item:expr),* $(,)?) => {
sorted_list!(@build Vec::new(), $($item),*)
};
(@build $vec:expr, $head:expr, $($tail:expr),+) => {
sorted_list!(@build { let mut v = $vec; v.push($head); v }, $($tail),+)
};
(@build $vec:expr, $last:expr) => {
{
let mut v = $vec;
v.push($last);
v.sort();
v
}
};
(@build $vec:expr,) => {
{
let mut v = $vec;
v.sort();
v
}
};
}
fn main() {
let items = sorted_list![3, 1, 4, 1, 5, 9, 2, 6];
println!("{:?}", items); // [1, 1, 2, 3, 4, 5, 6, 9]
}
Users call sorted_list![...]. The @build arms are implementation details — the macro’s internal contract with itself. This mirrors how modules separate public API from private implementation, except the boundary is convention rather than compiler enforcement.
What macro_rules! Cannot Do — And Why
Here is where the structural recursion invariant becomes most visible. These are not missing features. They are the boundaries that keep the predictability guarantee intact.
Cannot inspect types. A macro cannot behave differently for i32 versus String:
1
2
3
4
5
6
7
// IMPOSSIBLE with macro_rules!
macro_rules! type_name {
($val:expr) => {
// Cannot inspect the type of $val
// Cannot branch on whether $val is an integer or a string
};
}
If macros could inspect types, you would need to run the type checker to know what a macro expands to. The expansion would depend on semantic information, not just syntactic structure. Predictability would break.
Cannot generate new identifiers. You cannot concatenate get_ and a field name to produce get_name:
1
2
3
4
5
6
7
8
// IMPOSSIBLE with macro_rules!
macro_rules! make_getter {
($field:ident) => {
fn get_$field(&self) -> ... { // Cannot concatenate tokens
self.$field
}
};
}
Cannot evaluate expressions. A macro cannot compute 2 + 3 and use 5 in its expansion. It can produce the tokens 2 + 3, but it cannot reason about values.
Cannot inspect struct fields. Given a struct definition, macro_rules! cannot enumerate its fields or types. This is why #[derive(Debug)] exists as a procedural macro — it needs semantic access that syntactic pattern matching cannot provide.
Each limitation preserves the same guarantee: you can read the macro’s patterns and know exactly what code it will generate, without knowing anything about types, values, or the broader compilation context.
Debugging: The Expansion Is Always Inspectable
Because macro expansion is predictable, it is also inspectable. cargo expand shows you the actual code after all macros are expanded:
1
2
// Install: cargo install cargo-expand
// Usage: cargo expand
For targeted debugging, stringify! converts tokens to string literals, and compile_error! produces custom diagnostics:
1
2
3
4
5
6
7
8
9
10
11
macro_rules! debug_capture {
($val:expr) => {
println!("captured: {} = {:?}", stringify!($val), $val);
};
}
fn main() {
let x = 42;
debug_capture!(x); // captured: x = 42
debug_capture!(x + 1); // captured: x + 1 = 43
}
The ability to inspect expansion is itself a consequence of the invariant: because expansion depends only on syntactic structure, the result is deterministic and observable.
The Structural Recursion Invariant, Summarized
| Mechanism | Invariant Enforced |
|---|---|
$(...)* / $(...)+ | Repetition count determined by input structure, not runtime |
| Recursive invocation | Progress toward base case — bounded by recursion limit |
| Token-only matching | No type inspection, no expression evaluation, no semantic decisions |
| Fragment specifiers | Input constrained to valid syntactic categories |
@ internal rules | Implementation separated from public interface |
cargo expand | Expansion is deterministic and always inspectable |
The limits of macro_rules! are not shortcomings — they are the invariant. By confining macros to syntactic pattern matching, Rust guarantees that you can always read the patterns and know what code will be generated. No type-dependent branching. No hidden evaluation. No runtime information leaking into expansion. The predictability is the point. When you need to cross that boundary — when you need to inspect struct fields, generate new identifiers, or run arbitrary logic at compile time — you need procedural macros. But you will have left the land of predictable expansion, and the compiler will hold that code to an even stricter standard.
Stay in the loop
Subscribe via RSS to get new posts on systems, Rust, and cryptography.
Subscribe to RSS