Rust’s procedural macros are a powerful tool for code generation, but setting them up within a Cargo workspace can initially feel like navigating a maze of Cargo.toml files and build scripts.

Let’s see one in action. Imagine we want a macro #[derive(Hello)] that adds a hello() method to any struct.

// In src/main.rs (or a crate within the workspace)
#[derive(Hello)]
struct MyStruct {
    name: String,
}

fn main() {
    let instance = MyStruct { name: "World".to_string() };
    println!("{}", instance.hello());
}

When compiled, #[derive(Hello)] should transform MyStruct into something like this internally:

struct MyStruct {
    name: String,
}

impl MyStruct {
    fn hello(&self) -> String {
        format!("Hello, {}!", self.name)
    }
}

To achieve this, we need a separate crate for our procedural macro.

Here’s the structure of our workspace:

my_project/
├── Cargo.toml
├── macro_lib/
│   ├── Cargo.toml
│   └── src/
│       └── lib.rs
└── app_crate/
    ├── Cargo.toml
    └── src/
        └── main.rs

my_project/Cargo.toml (Workspace Root):

[workspace]
members = [
    "macro_lib",
    "app_crate",
]

This tells Cargo that macro_lib and app_crate are part of the same workspace.

macro_lib/Cargo.toml:

[package]
name = "macro_lib"
version = "0.1.0"
edition = "2021"

[lib]
proc-macro = true # This is the key for proc-macro crates

[dependencies]
syn = "2.0" # For parsing Rust code
quote = "1.0" # For generating Rust code
proc-macro2 = "1.0" # For token streams

The proc-macro = true line is crucial. It signals to the Rust compiler that this crate contains procedural macros. You’ll also need syn for parsing the input code, quote for constructing the output code, and proc-macro2 which syn and quote build upon.

macro_lib/src/lib.rs:

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

#[proc_macro_derive(Hello)]
pub fn hello_derive(input: TokenStream) -> TokenStream {
    // Parse the input tokens into a DeriveInput struct
    let ast = parse_macro_input!(input as DeriveInput);

    // Get the name of the struct
    let name = &ast.ident;

    // Generate the new code
    let gen = quote! {
        impl #name {
            fn hello(&self) -> String {
                // This is a simplified example; a real macro might inspect fields
                format!("Hello from {}!", stringify!(#name))
            }
        }
    };

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

This lib.rs defines our #[proc_macro_derive(Hello)] function. It takes TokenStream as input, parses it into a DeriveInput using syn, extracts the struct’s identifier, and then uses quote! to generate the impl block. Finally, it converts the generated quote output back into a TokenStream.

app_crate/Cargo.toml:

[package]
name = "app_crate"
version = "0.1.0"
edition = "2021"

[dependencies]
macro_lib = { path = "../macro_lib" } # Reference our local macro crate

Here, we add macro_lib as a dependency, pointing to its path within the workspace. This allows app_crate to use the macros defined in macro_lib.

app_crate/src/main.rs:

use macro_lib::Hello; // Import the derive macro

#[derive(Hello)]
struct MyStruct {
    name: String,
}

#[derive(Hello)]
struct AnotherStruct;

fn main() {
    let instance = MyStruct { name: "World".to_string() };
    println!("{}", instance.hello());

    let another = AnotherStruct;
    println!("{}", another.hello());
}

Now, when you run cargo build or cargo run from the my_project/ directory (or app_crate/ directory), Cargo will build both crates. The macro_lib will be compiled as a proc-macro library, and app_crate will use it to generate the hello() methods.

The most surprising aspect of procedural macros is how they operate on Rust code as data structures (Abstract Syntax Trees, or ASTs) rather than just text. The syn crate parses the input TokenStream into these structured representations, allowing you to programmatically analyze and manipulate the code before it’s compiled into machine code. This means you’re not just writing text transformations; you’re writing code that writes code, with full understanding of Rust’s grammar and semantics.

When #[derive(Hello)] is encountered on MyStruct, the compiler invokes the hello_derive function in macro_lib. syn parses MyStruct { name: String } into an ast::DeriveInput. The ast.ident is MyStruct. quote! then generates the impl MyStruct { fn hello(&self) -> String { ... } } code. This generated code is then fed back into the compilation process as if it were part of app_crate/src/main.rs.

The stringify!(#name) part in the macro_lib is a macro that converts its argument into a string literal. So, for MyStruct, it will generate format!("Hello from {}!", "MyStruct"). If you wanted to use the fields of the struct, you’d need to parse ast.data to get field names and types, which involves more advanced syn usage.

The power of this approach lies in its flexibility. You can create macros that generate entire traits, implement complex logic based on struct fields, or even create DSLs (Domain-Specific Languages) embedded within Rust.

The next step after mastering derive macros is exploring attribute-like and function-like procedural macros, which offer even more granular control over code generation by allowing you to attach custom attributes to items or invoke macros like functions.

Want structured learning?

Take the full Cargo course →