Skip to content
Home
Advanced Rust Macros: Procedural Macro Guide

Advanced Rust Macros: Procedural Macro Guide

Rust Rust 9 min read 1709 words Intermediate ExcellentWiki Editorial Team

Procedural Macros in Depth

Procedural macros are Rust functions that operate on token streams at compile time, transforming source code into different source code before type checking and monomorphization. Unlike macro_rules! declarative macros, which pattern-match on token trees using a domain-specific pattern language, procedural macros are regular Rust functions that receive a proc_macro::TokenStream and return a modified TokenStream. They are defined in their own crate with proc-macro = true in Cargo.toml because they must be compiled before the consuming crate — the proc macro crate compiles first, then the compiler loads the compiled proc macro shared library and invokes it while compiling the consumer. This separation means proc macro crates can only export procedural macros and cannot export regular functions, types, or traits for consumption by the same crate that uses the macros. The three types of procedural macros — derive macros, attribute macros, and function-like macros — each serve distinct metaprogramming purposes and have different invocation syntax. David Tolnay’s syn and quote crates are the de facto standard building blocks for the entire Rust ecosystem: syn parses Rust token streams into a strongly typed abstract syntax tree (AST), and quote generates token streams from Rust code using template-like interpolation syntax. Almost every commonly used proc macro — including Serde’s #[derive(Serialize, Deserialize)], thiserror’s #[derive(Error)], and clap’s #[derive(Parser)] — is built on syn and quote (Tolnay, “syn — Parser for Rust source code,” docs.rs/syn; “quote — Rust quasi-quoting,” docs.rs/quote). The ecosystem also includes darling for ergonomic attribute parsing and proc-macro-error for better error reporting.

Derive Macros for Trait Implementation

Derive macros are the most common procedural macro type because they integrate seamlessly with Rust’s #[derive(TraitName)] syntax. The proc macro receives the entire struct, enum, or union definition as its input token stream and must produce the trait implementation code as output. The syn crate parses the input into a DeriveInput struct that provides access to the type name, generics, data structure (struct with named fields, tuple struct, or enum variants), and attributes. The quote crate generates the output using #var interpolation and #(...)* repetition syntax:

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Data, Fields};

#[proc_macro_derive(IntoMap)]
pub fn into_map_derive(input: TokenStream) -> TokenStream {
    let DeriveInput { ident, data, .. } = parse_macro_input!(input);
    let field_names: Vec<_> = match data {
        Data::Struct(ds) => ds.fields.iter()
            .filter_map(|f| f.ident.as_ref()).collect(),
        _ => panic!("IntoMap only supported on structs with named fields"),
    };
    let field_strs: Vec<_> = field_names.iter().map(|f| f.to_string()).collect();
    let expanded = quote! {
        impl IntoMap for #ident {
            fn into_map(&self) -> std::collections::HashMap<String, String> {
                let mut map = std::collections::HashMap::new();
                #( map.insert(#field_strs.to_string(),
                    format!("{}", self.#field_names)); )*
                map
            }
        }
    };
    TokenStream::from(expanded)
---

Generic parameters and where clauses must be preserved in the output to maintain compatibility with generic types. The syn::Generics type provides methods for stripping or modifying generic parameters as needed. For enums, the derive macro must handle each variant separately, typically generating a match expression.

Attribute Macros for Code Transformation

Attribute macros annotate items and can modify them arbitrarily. They receive two token streams: the attribute arguments and the annotated item itself, making them more powerful than derive macros:

#[proc_macro_attribute]
pub fn instrument(attr: TokenStream, item: TokenStream) -> TokenStream {
    let level = if attr.is_empty() {
        Ident::new("info", Span::call_site())
    } else {
        parse_macro_input!(attr as Ident)
    };
    let input_fn = parse_macro_input!(item as syn::ItemFn);
    let fn_name = &input_fn.sig.ident;
    let vis = &input_fn.vis;
    let sig = &input_fn.sig;
    let block = &input_fn.block;
    let expanded = quote! {
        #vis #sig {
            log::log!(log::Level::#level, "entering {}", stringify!(#fn_name));
            let start = std::time::Instant::now();
            let result = #block;
            log::log!(log::Level::#level, "exiting {} in {:?}", stringify!(#fn_name), start.elapsed());
            result
        }
    };
    TokenStream::from(expanded)
---

Function-like Macros

Function-like macros invoke with my_macro!(...) syntax and can appear anywhere a function call would appear. They are the most flexible proc macro type, enabling compile-time validation like SQL query checking or embedded DSLs. Unlike derive macros, they can be used in expression, statement, or item position.

The syn and quote Ecosystem

The syn crate provides parsers for the complete Rust grammar including all syntax extensions. Key types include DeriveInput, ItemFn, ItemStruct, Lit, Type, and Expr. The parse_macro_input! macro simplifies error handling with automatic Display and Span reporting. The parse_quote! macro allows constructing AST nodes inline with the same interpolation syntax as quote!. For testing, proc-macro2 wraps compiler types for use in regular test code outside proc macro crates.

Debugging Procedural Macros

cargo expand is the most essential debugging tool — it shows exactly what code the macros generate. Install with cargo install cargo-expand. For in-situ debugging, use eprintln! inside procedural macros to print token streams during compilation. The trybuild crate enables compile-fail tests that verify macros produce appropriate error messages for invalid inputs. Span management is critical for good error messages — use Span::call_site() for generated code and preserve spans from input tokens where possible.

Performance Considerations

Procedural macros add compile-time overhead. Complex macros with heavy parsing can add seconds to compile times. Strategies for minimizing overhead include caching parsed results, using incremental compilation, and keeping macro logic simple. The proc_macro2::TokenStream::from_str can be slower than using quote! due to re-parsing overhead.

Handling Generic Parameters in Derive Macros

When implementing derive macros for generic types, the generated trait implementation must preserve all generic parameters, where clauses, and lifetime annotations. The syn::Generics API provides methods for split_for_impl() which separates generic parameters into the declaration and the reference portions. For types with complex bounds like struct Container<T: Display + Debug, U: Clone>, the derive macro output must include impl<T: Display + Debug, U: Clone> MyTrait for Container<T, U>. Failing to preserve bounds causes compilation errors in the consuming crate. The parse_quote! macro simplifies this by allowing direct interpolation of parsed generics into generated code. For associated types and const generics, additional care is needed: const generic expressions must be valid in the output context. The darling crate’s FromDeriveInput trait provides ergonomic parsing of generic parameters along with custom attributes, reducing boilerplate in complex derive macros.

Testing Procedural Macros

Testing proc macros requires specialized approaches since they operate at compile time. Unit tests verify the logic of helper functions that construct token streams, using proc_macro2::TokenStream for testable parsing without compiler integration. Integration tests use trybuild to verify that macros produce correct code for valid inputs and appropriate error messages for invalid inputs. The trybuild crate compiles test cases in a separate compilation context and compares compile errors to expected output files. Snapshot testing with insta captures expanded macro output for visual inspection. For derive macros, the type_system test pattern verifies that generated implementations satisfy trait bounds. The compiletest_rs crate provides a harness for running the Rust compiler’s built-in test suite against proc macro crates. Comprehensive proc macro test suites typically include: correct expansion for each supported type variant, error messages for invalid attribute combinations, panic safety when inputs violate expectations, and regression tests for previously fixed bugs. The cargo expand output should be reviewed after every macro change to verify the generated code meets expectations.

Debugging and Diagnostics

Procedural macro debugging is difficult because errors appear at the call site, not in the macro implementation. The proc_macro_diagnostic nightly feature emits compiler errors, warnings, and notes directly from macro code using Diagnostic::spans_error(). For stable Rust, panic in the macro code to show a backtrace: set RUST_BACKTRACE=1 and run cargo build. The macrotest crate provides snapshot testing by comparing macro expansion output against golden files, catching unexpected output changes. Attribute macros benefit from testing with trybuild which compiles test cases and verifies compile-time errors match expected messages.

To trace token stream transformations, add eprintln!("TOKENS: {:?}", tokens) debug output. The quote! macro’s .to_string() reveals the generated code. For derive macros on complex types, test each supported item type (struct, enum with variants, union) separately. The syn crate’s parse_quote! macro constructs AST nodes inline for unit tests. Common gotchas include: forgetting to handle crate visibility on generated items, mismatched hygiene causing identifier collisions, and incorrect token ordering after complex .replace() operations on token streams. A robust test suite covers positive cases, edge cases (empty enums, zero-field structs), and error cases (unsupported type combinations).

Frequently Asked Questions

What is the difference between declarative and procedural macros? Declarative macros pattern-match on token trees. Procedural macros are Rust functions that process token streams programmatically.

Why do procedural macros need their own crate? They must be compiled as a dynamic library before the compiler can invoke them, requiring proc-macro = true in Cargo.toml.

What are the syn and quote crates for? syn parses Rust token streams into strongly typed AST structures. quote generates Rust token streams from template syntax.

How do I test procedural macros? Use cargo expand to inspect generated code, trybuild for compile-fail tests, and proc-macro2 for unit testing.

Can procedural macros slow down compilation? Yes — proc macros add overhead during expansion. Complex macros with heavy parsing can add seconds to compile times.

Related: Rust Macros Guide | Rust Serialization with Serde | Rust Unsafe Code

Rust in Production: Case Studies

Rust has been adopted in production by major technology companies for performance-critical and safety-critical systems. Mozilla uses Rust in the Firefox browser engine (Servo/Quantum) for improved memory safety and performance in CSS layout and rendering. Dropbox rewrote their sync engine core in Rust, achieving significant performance improvements and eliminating entire classes of bugs. Cloudflare uses Rust for edge computing infrastructure, including their Pingora HTTP proxy that handles 10% of the world’s internet traffic. Figma rewrote their multiplayer editing engine in Rust, achieving predictable performance and eliminating crashes. Discord used Rust for their read states service, handling millions of concurrent connections with minimal resources. These case studies demonstrate Rust’s value in systems where performance, reliability, and memory safety are critical. The common pattern: rewrite performance-critical components in Rust while maintaining the rest of the system in the original language, proving that incremental adoption works in practice.

Embedded Systems Programming

Rust is gaining traction in embedded systems for its memory safety guarantees without a garbage collector or runtime. The cortex-m-quickstart template provides a starting point for ARM Cortex-M microcontrollers. The RTIC framework provides real-time interrupt-driven concurrency. Embassy offers an async embedded runtime with USB, networking, and BLE support. Rust’s type system prevents common embedded errors like misconfiguring peripherals, incorrect register access, and data races between interrupt handlers and main code. The embedded ecosystem continues to mature with HAL implementations for major microcontroller families from STM, Nordic, and Espressif.

Section: Rust 1709 words 9 min read Intermediate 756 articles in section Report inaccuracy Back to top