Skip to Content
Course content

66: Procedural Macros Overview

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Procedural macros are often the most intimidating part of Rust because they feel less like writing code and more like writing a program that writes other programs. If declarative macros (macro_rules!) are like find-and-replace on steroids, procedural macros are full-blown compiler plugins. They take a stream of tokens as input, manipulate them, and spit out a new stream of tokens.

To keep this concrete, we're going to build a simple attribute macro called #[log_call]. The goal is to be able to slap this on any function, and have Rust automatically inject a print statement at the start and end of that function. It's a classic "cross-cutting concern" that saves us from typing the same boilerplate in fifty different functions.

Setting up the specialized crate

Here is the first thing you need to know: procedural macros must reside in their own separate crate. You can't define them in the same crate where you use them. This is because the compiler needs to compile the macro first, then run it to generate the code for your main application.

I'll start by creating a new library crate called my_macros. In the Cargo.toml, I have to explicitly tell Rust this is a proc-macro library:

[lib]
proc-macro = true

[dependencies]
syn = { version = "2.0", features = ["full"] }
quote = "1.0"
proc-macro2 = "1.0"

I'm pulling in syn to parse the Rust code into a data structure I can actually work with, and quote to turn those data structures back into Rust code. Without these, you'd be manually manipulating token streams, which is a nightmare I wouldn't wish on anyone.

Parsing the function input

Now, let's write the macro. I'll define the function and use syn::parse_macro_input! to turn the input tokens into an ItemFn, which represents a function definition in Rust.

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

#[proc_macro_attribute]
pub fn log_call(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let input = parse_macro_input!(item as ItemFn);
    let fn_name = &input.sig.ident;
    
    // We'll handle the code generation here
}

At this point, input is a rich object. I can see the function's name, its arguments, and its body. I've grabbed the fn_name because I want to use it in my log message.

The "Invisible Function" mistake

This is where I usually trip up when I first start writing these. I'll try to wrap the function body, but I'll make a classic mistake: I'll generate the logging code but forget to include the original function signature and body in the output.

// MISTAKE: I'm only returning the print statements!
let expanded = quote! {
    println!("Entering function: {}", stringify!(#fn_name));
    println!("Exiting function: {}", stringify!(#fn_name));
};
TokenStream::from(expanded)

When I try to use this in my main project, I'll get a bizarre compiler error saying the function I just called doesn't exist. Why? Because an attribute macro replaces the item it's attached to. By only returning the println! calls, I literally deleted my function from the source code and replaced it with two floating print statements that aren't even inside a function. Oops.

Correctly wrapping the logic

To fix this, I need to reconstruct the entire function. I'll use the quote! macro to recreate the function signature and then inject my logging calls around the original block of code.

#[proc_macro_attribute]
pub fn log_call(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let input = parse_macro_input!(item as ItemFn);
    let fn_name = &input.sig.ident;
    let fn_vis = &input.vis;
    let fn_sig = &input.sig;
    let fn_block = &input.block;

    let expanded = quote! {
        #fn_vis #fn_sig {
            println!("Entering function: {}", stringify!(#fn_name));
            let result = (|| #fn_block)();
            println!("Exiting function: {}", stringify!(#fn_name));
            result
        }
    };
    
    TokenStream::from(expanded)
}

I used a little trick here: wrapping the original #fn_block in an immediately-invoked closure (|| #fn_block)(). This ensures that if the original function had a return statement or returned a value, it still works perfectly and the "Exiting" log always runs. Now, when I apply #[log_call] to a function, it actually behaves like a wrapper.




📋 Practical Task

Build a #[track_usage] Attribute Macro

Create a procedural macro crate that implements an attribute macro called #[track_usage]. This macro should wrap a function so that every time the function is called, it prints a message to the console in the following format: "Executing [function_name]...", followed by the actual execution of the function.

Your implementation must:

  • Use syn to parse the input as an ItemFn.
  • Use quote to rebuild the function so that it is not deleted from the final binary.
  • Correctly handle the function's visibility and signature so it remains usable by the rest of the program.
  • Successfully print the name of the function dynamically using stringify! or by accessing the identifier.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.