Skip to content

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

Hard Primitives

Nested repetition and zipping metavariables

A repetition inside a repetition — the step from toy macros to the ones that build routing tables, test matrices and configuration blocks.

macro_rules! table { /* ... */ }

pub fn build_table() -> Vec<(String, Vec<i64>)>

The call site is fixed:

table! {
    alpha => 1, 2, 3;
    beta  => 4, 5;
    gamma => ;
    delta => 7;
}

and must produce

[("alpha", [1, 2, 3]), ("beta", [4, 5]), ("gamma", []), ("delta", [7])]

Note gamma. A row with no entries is legal, and it is what forces you to think about * versus +.

There is a second macro, zip_pairs!, that pairs two lists positionally: zip_pairs!(x, y, z ; p, q, r) gives ["x-p", "y-q", "z-r"].

Depth must mirror exactly

The matcher for the table has two levels:

( $( $name:ident => $( $val:expr ),* ; )* )
//^outer                ^inner

The outer repetition runs once per row; the inner one runs once per value. So $name` is captured at depth 1 and `$val at depth 2.

The transcriber must reproduce that nesting exactly. $name` may only appear inside one repetition; `$val may only appear inside two:

Vec::from([
    $( (stringify!($name).to_string(), Vec::from([ $( $val ),* ])) ),*
//  ^outer again                                  ^inner again
])

Get the depth wrong and you get one of the worst error messages in Rust:

error: variable `val` is still repeating at this depth

which is accurate and completely opaque on first contact. It means: you used a depth-2 metavariable somewhere the compiler was only iterating one level, so it does not know which of the several $val`s you meant. Note also that `;` here is not a separator — it is a literal token inside the outer repetition's body. That is what allows the trailing `;` after `delta => 7` without any `$(,)?-style trick.

* versus +, and why gamma is in the test

+ means one or more. Write the inner repetition as $( $val:expr ),+ and gamma => ; fails to compile — the matcher demands at least one value and there is none. The starter does exactly this, so it does not compile until you fix it.

The same choice applies to the transcriber: if the matcher used *, the transcriber’s matching repetition must also allow zero.

Zipping two metavariables

Two metavariables can be used in one transcriber repetition if they came from repetitions at the same depth that bound the same number of fragments:

( $($a:ident),* ; $($b:ident),* ) => {
    Vec::from([ $( concat!(stringify!($a), "-", stringify!($b)).to_string() ),* ])
};

zip_pairs!(x, y, z ; p, q, r) pairs them positionally: x with p, y with q, z with r.

Two constraints, both hard errors rather than silent truncation:

  • The counts must be equal. zip_pairs!(x, y ; p, q, r) is rejected — the macro system will not quietly drop r.
  • The depths must match. You cannot zip something captured at depth 1 with something captured at depth 2.

::: question Why can’t the compiler just truncate to the shorter list, the way Iterator::zip does?

Because the two are not the same kind of operation, and treating them alike would lose information the compiler has and cannot recover later.

Iterator::zip runs at run time over two sequences whose lengths are usually unknown until they are consumed; stopping at the shorter one is the only reasonable behaviour. A macro repetition runs at compile time over two token lists whose lengths are right there in the source. If they disagree, the person who wrote the invocation made a mistake — probably a missing element — and silently dropping the excess would produce code that compiles and does the wrong thing. Exactly the failure mode item 18.4 was about.

So the compiler reports it. This is the same instinct as #[must_use] on Result and as exhaustive match: where the information exists at compile time, Rust prefers to make you resolve the ambiguity rather than pick for you. :::

Your job

Fix table! so the empty row is legal and the nesting is right, and fix zip_pairs! so it pairs both lists instead of ignoring the second.