Procedural Macros in Rust

While declarative macros (macro_rules!) provide a powerful way to write code that writes code, they are limited to matching and replacing patterns. For more advanced metaprogramming, Rust offers procedural macros.

Procedural macros are more like functions. They accept a stream of Rust code as input, operate on it, and produce a new stream of code as output. This allows for much more complex logic, such as generating entire function bodies, implementing traits based on a struct’s fields, or creating powerful domain-specific languages (DSLs).

The Three Types of Procedural Macros

There are three kinds of procedural macros, each serving a different purpose:

  1. Custom #[derive] macros: The most common type. They allow you to generate implementations for traits on structs and enums. This is how #[derive(Debug)], #[derive(Serialize)], etc., work.
  2. Attribute-like macros: These are more general attributes that can be attached to any item (like a function or struct). They can modify the item they are attached to. A common use case is in web frameworks, like #[get("/")].
  3. Function-like macros: These look like function calls (e.g., sql!(...)) but have more power than declarative macros. They can parse complex syntax that macro_rules! cannot.

How to Write a Procedural Macro

Writing a procedural macro is more involved than writing a declarative one. Procedural macros must live in their own special crate with the proc-macro crate type enabled in Cargo.toml.

# In Cargo.toml of the proc-macro crate
[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"

You will almost always use two essential helper crates:

  • syn: For parsing Rust code from a token stream into an Abstract Syntax Tree (AST) that you can easily work with.
  • quote: For turning your generated AST back into a stream of Rust code tokens.

Example: A Custom #[derive] Macro

Let’s create a classic “Hello, World” of procedural macros: a HelloMacro trait and a #[derive(HelloMacro)] that implements it.

Step 1: Create the Crates

We need two crates: one for the trait (hello_macro) and one for the procedural macro itself (hello_macro_derive).

cargo new hello_macro --lib
cargo new hello_macro_derive --lib

Step 2: Define the Trait

In hello_macro/src/lib.rs, define the trait we want to implement.

// filepath: hello_macro/src/lib.rs
pub trait HelloMacro {
    fn hello_macro();
}

Step 3: Write the Procedural Macro

Now for the main event. In hello_macro_derive/Cargo.toml, set up the crate:

# filepath: hello_macro_derive/Cargo.toml
[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"

In hello_macro_derive/src/lib.rs, write the macro function.

// filepath: hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn;

// This function is called by the compiler when it sees #[derive(HelloMacro)].
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // 1. Parse the input tokens into a syntax tree.
    let ast = syn::parse(input).unwrap();

    // 2. Build the trait implementation.
    impl_hello_macro(&ast)
}

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    // Get the name of the struct we're deriving for (e.g., "Pancakes").
    let name = &ast.ident;
    
    // 3. Use the `quote!` macro to generate the implementation.
    let gen = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                // `stringify!` converts an identifier to a string literal at compile time.
                println!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };

    // 4. Convert the generated code back into a TokenStream.
    gen.into()
}

Let’s break this down:

  1. The #[proc_macro_derive(HelloMacro)] attribute registers our function as a derive macro for the HelloMacro trait.
  2. We use syn::parse to turn the raw TokenStream into a syn::DeriveInput struct, which is a structured representation of the struct or enum.
  3. We use the quote! macro to build the output code. The #name syntax interpolates the name variable into the generated code.
  4. The quote! macro returns a TokenStream, which our function then returns.

Step 4: Use the Macro

Finally, let’s use it in a new binary crate.

// In a new crate's main.rs
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro; // The derive macro itself

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro(); // Prints: "Hello, Macro! My name is Pancakes!"
}

Attribute-like and Function-like Macros

The other two types of procedural macros work similarly but have different function signatures.

  • Attribute-like: #[proc_macro_attribute]

    • Signature: pub fn my_attribute(attr: TokenStream, item: TokenStream) -> TokenStream
    • attr contains the tokens inside the attribute (e.g., GET, "/").
    • item contains the tokens of the item the attribute is attached to (e.g., the function).
  • Function-like: #[proc_macro]

    • Signature: pub fn my_macro(input: TokenStream) -> TokenStream
    • input contains the tokens inside the macro’s parentheses.

Conclusion

Procedural macros are a significantly more advanced feature than declarative macros, but they provide unparalleled power to reduce boilerplate and build clean, expressive APIs. By leveraging the syn and quote crates, you can parse, analyze, and generate Rust code at compile time, effectively extending the language to suit your specific needs. They are the magic behind many of Rust’s most popular and powerful libraries, like Serde, Tokio, and Diesel.