Skip to content

When macro_rules! runs out

Level: 301 · deep dive

One line: macro_rules! can count its arguments, check a string literal through a const, and word its own errors; what it cannot do on stable Rust is write an identifier that is not in its input — incr_requests from requests — and that is where a function-like procedural macro starts to pay for its extra crate.

The task, done by a procedural macro

counters!(requests, errors) should define a struct with a u64 per name, and a method incr_<name> for each:

demo/counters_app/src/main.rs
counters_macro::counters!(requests, errors);

fn main() {
    let mut counts = Counters::new();
    counts.incr_requests();
    counts.incr_requests();
    counts.incr_errors();
    println!("requests = {}, errors = {}", counts.requests, counts.errors); // requests = 2, errors = 1
}

Verified output of cargo run -q -p counters_app — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

requests = 2, errors = 1

The macro parses a comma-separated list of identifiers and builds one new identifier per name with format_ident!:

demo/counters_macro/src/lib.rs
//! `counters!(requests, errors)`: a struct with one `u64` per name, and an
//! `incr_<name>` method for each.

use proc_macro::TokenStream;
use quote::{format_ident, quote};
use syn::punctuated::Punctuated;
use syn::{Ident, Token, parse_macro_input};

#[proc_macro]
pub fn counters(input: TokenStream) -> TokenStream {
    let names = parse_macro_input!(input with Punctuated::<Ident, Token![,]>::parse_terminated);
    let fields: Vec<&Ident> = names.iter().collect();
    // The identifier `macro_rules!` cannot write. `format_ident!` gives it the
    // span of `name`, the name as the caller typed it.
    let methods: Vec<Ident> = fields.iter().map(|name| format_ident!("incr_{}", name)).collect();
    quote! {
        pub struct Counters {
            #(pub #fields: u64,)*
        }

        impl Counters {
            pub fn new() -> Self {
                Self { #(#fields: 0,)* }
            }

            #(pub fn #methods(&mut self) {
                self.#fields += 1;
            })*
        }
    }
    .into()
}

cargo expand shows what the call became (recorded with the command underneath it, cargo rustc -- -Zunpretty=expanded):

Verified output of cargo rustc -q -p counters_app --profile=check -- -Zunpretty=expanded — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

#![feature(prelude_import)]
extern crate std;
#[prelude_import]
use std::prelude::rust_2024::*;
pub struct Counters {
    pub requests: u64,
    pub errors: u64,
}
impl Counters {
    pub fn new() -> Self { Self { requests: 0, errors: 0 } }
    pub fn incr_requests(&mut self) { self.requests += 1; }
    pub fn incr_errors(&mut self) { self.errors += 1; }
}

fn main() {
    let mut counts = Counters::new();
    counts.incr_requests();
    counts.incr_requests();
    counts.incr_errors(); // requests = 2, errors = 1
    {
        ::std::io::_print(format_args!("requests = {0}, errors = {1}\n",
                counts.requests, counts.errors));
    };
}

Two iterators sit in the same #(…)* repetition, #methods and #fields, and quote! walks them in step. Generating with quote covers repetition and format_ident! in full; Parsing arbitrary tokens covers parse_terminated.

The same task in macro_rules!

Everything except the method names translates line for line:

demo/rules_pasted/src/main.rs
//! `counters!` as a `macro_rules!` macro. The struct and its fields expand
//! fine; the method names do not.

macro_rules! counters {
    ($($name:ident),* $(,)?) => {
        pub struct Counters {
            $(pub $name: u64,)*
        }

        impl Counters {
            pub fn new() -> Self {
                Self { $($name: 0,)* }
            }

            $(pub fn incr_$name(&mut self) {
                self.$name += 1;
            })*
        }
    };
}

counters!(requests, errors);

fn main() {
    let mut counts = Counters::new();
    counts.incr_requests();
    println!("requests = {}", counts.requests);
}

Verified output of cargo build -q -p rules_pasted, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

error: missing parameters for function definition
  --> rules_pasted/src/main.rs:15:27
   |
15 |             $(pub fn incr_$name(&mut self) {
   |                           ^
...
22 | counters!(requests, errors);
   | --------------------------- in this macro invocation
   |
   = note: this error originates in the macro `counters` (in Nightly builds, run with -Z macro-backtrace for more info)
help: add a parameter list
   |
15 |             $(pub fn incr_()$name(&mut self) {
   |                           ++

error: expected one of `->`, `<`, `where`, or `{`, found identifier `requests`
  --> rules_pasted/src/main.rs:15:27
   |
10 |         impl Counters {
   |                       - while parsing this item list starting here
...
15 |             $(pub fn incr_$name(&mut self) {
   |                           ^^^^^ expected one of `->`, `<`, `where`, or `{`
...
18 |         }
   |         - the item list ends here
...
22 | counters!(requests, errors);
   | --------------------------- in this macro invocation
   |
   = note: this error originates in the macro `counters` (in Nightly builds, run with -Z macro-backtrace for more info)

error[E0599]: no method named `incr_requests` found for struct `Counters` in the current scope
  --> rules_pasted/src/main.rs:26:12
   |
 6 |         pub struct Counters {
   |         ------------------- method `incr_requests` not found for this struct
...
26 |     counts.incr_requests();
   |            ^^^^^^^^^^^^^ method not found in `Counters`

For more information about this error, try `rustc --explain E0599`.
error: could not compile `rules_pasted` (bin "rules_pasted") due to 3 previous errors

incr_$name is not one identifier with a hole in it. The lexer read incr_ as a complete identifier when the macro was defined, and $name substitutes a second, whole token after it, so the parser sees fn incr_ requests(…): a function called incr_, with something that is not a parameter list after the name. The help is the parser's guess at that reading, not a fix. macro_rules! substitutes tokens; it never joins two into one. The third error follows from the first two: no incr_requests was defined, so main cannot call it.

The feature that does join them exists, and it is not stable:

Verified output of cargo build -q -p rules_concat, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

error[E0658]: the `concat` meta-variable expression is unstable
  --> rules_concat/src/main.rs:14:23
   |
14 |             $(pub fn ${concat(incr_, $name)}(&mut self) {
   |                       ^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: see issue #124225 <https://github.com/rust-lang/rust/issues/124225> for more information

For more information about this error, try `rustc --explain E0658`.
error: could not compile `rules_concat` (bin "rules_concat") due to 1 previous error

Only the method line changed, to pub fn ${concat(incr_, $name)}(&mut self). ${concat(…)} is a metavariable expression, and in 1.98.0 it is behind a feature gate. It replaced the older concat_idents!, which std lists as removed since 1.90.0 (std/src/lib.rs at 1.98.0 ↗).

This is the exact point where macro_rules! stops. On stable, a name that is not already a token in the input can only come from a procedural macro. The crate people reach for is paste ↗, a #[proc_macro] whose documentation shows it used from inside a macro_rules! macro, pasting [<get_ $field>] into one identifier.

What macro_rules! can still do

The pull toward a procedural macro often starts earlier than it has to. None of these needs one:

demo/still_rules/src/lib.rs
//! Three jobs that sound like they need a procedural macro, done with
//! `macro_rules!` and a `const fn`.

/// Counts its arguments without recursion: one `()` per name, in a slice
/// whose length is a constant.
#[macro_export]
macro_rules! count {
    ($($name:ident),* $(,)?) => {
        <[()]>::len(&[$($crate::count!(@unit $name)),*])
    };
    (@unit $name:ident) => {
        ()
    };
}

/// Checks a string literal's contents while compiling. The macro cannot look
/// inside the literal, but a `const` it expands to can.
#[macro_export]
macro_rules! hex {
    ($text:literal) => {{
        const _: () = assert!($crate::all_hex($text), "not a hex string");
        $text
    }};
}

/// An error message of the macro's own, naming the token it refused.
#[macro_export]
macro_rules! method {
    (GET) => {
        "GET"
    };
    (POST) => {
        "POST"
    };
    ($other:ident) => {
        compile_error!(concat!("unknown method `", stringify!($other), "`"))
    };
}

pub const fn all_hex(text: &str) -> bool {
    let bytes = text.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        if !bytes[i].is_ascii_hexdigit() {
            return false;
        }
        i += 1;
    }
    true
}
demo/still_rules/src/main.rs
use still_rules::{count, hex, method};

const NAMES: usize = count!(requests, errors, retries);

fn main() {
    let slots = [0u64; count!(requests, errors)];
    println!("{NAMES} names, {} slots", slots.len()); // 3 names, 2 slots
    println!("{}", hex!("c0ffee")); // c0ffee
    println!("{}", method!(POST)); // POST
}

Verified output of cargo run -q -p still_rules --bin still_rules — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

3 names, 2 slots
c0ffee
POST
  • Counting. count! expands to a slice with one () per name and takes its length. No recursion over the list, and the result is a constant: count!(requests, errors) sizes an array. So the claim that macro_rules! can only count by recursing is wrong.
  • Checking a literal's contents. The macro cannot see inside "c0ffee": to a matcher, a literal is one token. It can hand the literal to a const fn, and a failing assert! in a const stops compilation.
  • An error of its own. compile_error! takes a message built with concat! and stringify!, so the message can name the token it refused.

Where they fall short is the underline:

demo/still_rules/src/bin/bad_hex.rs
use still_rules::hex;

fn main() {
    println!("{}", hex!("coffee"));
}

Verified output of cargo build -q -p still_rules --bin bad_hex, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

error[E0080]: evaluation panicked: not a hex string
 --> still_rules/src/bin/bad_hex.rs:4:20
  |
4 |     println!("{}", hex!("coffee"));
  |                    ^^^^^^^^^^^^^^ evaluation of `main::_` failed here
  |
  = note: this error originates in the macro `$crate::panic::panic_2021` which comes from the expansion of the macro `hex` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0080`.
error: could not compile `still_rules` (bin "bad_hex") due to 1 previous error
demo/still_rules/src/bin/bad_method.rs
use still_rules::method;

fn main() {
    println!("{}", method!(PATCH));
    println!("{}", method!("GET"));
}

Verified output of cargo build -q -p still_rules --bin bad_method, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

error: unknown method `PATCH`
 --> still_rules/src/bin/bad_method.rs:4:20
  |
4 |     println!("{}", method!(PATCH));
  |                    ^^^^^^^^^^^^^^
  |
  = note: this error originates in the macro `method` (in Nightly builds, run with -Z macro-backtrace for more info)

error: no rules expected `"GET"`
  --> still_rules/src/bin/bad_method.rs:5:28
   |
 5 |     println!("{}", method!("GET"));
   |                            ^^^^^ no rules expected this token in macro call
   |
note: while trying to match `GET`
  --> still_rules/src/lib.rs:29:6
   |
29 |     (GET) => {
   |      ^^^

error: could not compile `still_rules` (bin "bad_method") due to 2 previous errors

Both messages the macro wrote underline the whole call, hex!("coffee") and method!(PATCH), never the literal or the identifier inside it. The one precise underline is the compiler's own: when no rule matches, no rules expected points at the token, here "GET" written as a string. A procedural macro puts a syn::Error on any token it likes, which is what A routes! macro does with a {param} its handler does not take.

What the procedural version costs

Verified output of cargo tree -p counters_app -p still_rules -e normal — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

counters_app v0.1.0
└── counters_macro v0.1.0 (proc-macro)
    ├── proc-macro2 v1.0.107
    │   └── unicode-ident v1.0.25
    ├── quote v1.0.47
    │   └── proc-macro2 v1.0.107 (*)
    └── syn v3.0.6
        ├── proc-macro2 v1.0.107 (*)
        ├── quote v1.0.47 (*)
        └── unicode-ident v1.0.25

still_rules v0.1.0

still_rules has no dependencies. counters_macro brings syn, quote, proc-macro2 and unicode-ident, all compiled before the first line of counters_app. And it is a crate of its own, of a kind that exports nothing but macros; rules_pasted defines its macro in the same file that calls it.

demo/counters_macro/Cargo.toml
[package]
name = "counters_macro"
version = "0.1.0"
edition = "2024"
publish = false

[lib]
proc-macro = true

[dependencies]
syn = { version = "3", features = ["full", "extra-traits"] }
quote = "1"
proc-macro2 = "1"

A proc-macro crate is why that [lib] table has to be there, and A function, macro_rules!, or a procedural macro weighs what the dependencies cost.

The decision, for a function-like macro

You need macro_rules! A #[proc_macro]
Syntax a function cannot take: any number of arguments, names, types Yes Yes
A count of the arguments Yes, as a constant: <[()]>::len(&[…]) Yes
A new identifier such as incr_requests No on stable: tokens are never joined, ${concat(…)} is unstable Yes: format_ident!
To reject a string literal by its contents Yes, through a const fn; the error underlines the whole call Yes; the error underlines the literal
To turn a literal's contents into code: a pattern, a parameter No: a matcher cannot see inside a literal Yes: A routes! macro
A message of its own Yes, compile_error!; underlines the whole call Yes, syn::Error; underlines any token
To live in the crate that uses it Yes No: a crate of its own, proc-macro = true
Dependencies to compile first None syn, quote, proc-macro2, unicode-ident

Start with macro_rules!. Move when a row in bold is the one you need, or when the error your users see has to point somewhere more exact than the whole call.

If you are coming from another language

  • C and C++. The preprocessor can do the one thing on this page macro_rules! cannot: ## pastes two tokens into one, so #define COUNTER(name) static unsigned long name; static void incr_##name(void) { name += 1; } defines incr_requests (checked with Apple clang 21: clang -E prints static void incr_requests(void)). It cannot look inside a string literal, and it pastes text without knowing what the text is. Rust's split is the other way round: macro_rules! matches structured fragments and will not paste, and pasting is a job for a procedural macro.
  • Python. Methods named at run time are ordinary: setattr(Counters, f"incr_{name}", incr) in a loop builds incr_requests while the module runs. The price is that a misspelt call, c.incr_retries(), is an AttributeError when that line runs (Python 3.14 even suggests incr_errors). The Rust version builds the method while compiling, so the same typo is a compile error.
  • Java. No macros. An annotation processor or Lombok builds getName from name as a string and emits source or bytecode; that is the procedural macro's position, a program generating names, and it runs inside javac the way a procedural macro runs inside rustc.
  • ABAP. (Not machine-checked — CI cannot run ABAP.) DEFINE macros substitute placeholders, and a name built at run time is a dynamic call, CALL METHOD (lv_name), checked only when it runs. A method generated with a new name at compile time has no ABAP counterpart outside code generators.

See also

Po polsku

macro_rules! potrafi więcej, niż się zwykle mówi. Policzy argumenty bez rekurencji (<[()]>::len(&[…]) daje stałą, którą można wpisać jako długość tablicy), sprawdzi zawartość literału przez const fn i assert! w bloku const, a przez compile_error! zgłosi własny komunikat błędu. Podkreślenie obejmuje wtedy jednak całe wywołanie makra, a nie token, który był zły.

Czego nie zrobi na stabilnym Ruście: nie zbuduje nowego identyfikatora. incr_$name to dwa tokeny, incr_ i podstawione requests, bo makro deklaratywne podstawia całe tokeny i nigdy ich nie skleja. Wyrażenie ${concat(incr_, $name)} robi dokładnie to, ale w 1.98.0 jest niestabilne (E0658), a starsze concat_idents! usunięto w 1.90.0. W tym miejscu opłaca się makro proceduralne funkcyjne: format_ident!("incr_{}", name) z quote. Kosztuje osobny crate i zależności syn, quote, proc-macro2, więc zaczynaj od macro_rules!, a przechodź dopiero wtedy, gdy potrzebujesz nowej nazwy, kodu zbudowanego z treści literału albo błędu wskazującego dokładny token.

Szukaj po polsku: macro_rules! a makra proceduralne · sklejanie identyfikatorów w makrach Rust · rust macro_rules concat identifier · rust macro_metavar_expr_concat · rust paste crate · rust count macro arguments