DEV Community

Gregory Chris
Gregory Chris

Posted on

Your First Procedural Macro (Without the Fear)

Your First Procedural Macro (Without the Fear)

Procedural macros in Rust can feel like a rite of passage: an intimidating yet exciting step into the world of metaprogramming. Many fear they’re too complex or cryptic, but I’m here to tell you otherwise. In this blog post, we’ll demystify procedural macros and build your very first one: a simple #[my_debug] attribute macro that prints a message whenever a function is entered.

By the end of this post, you’ll have a working procedural macro, a better understanding of how they operate, and the confidence to experiment further. So, grab your favorite editor and a warm beverage—let’s dive in!


What Are Procedural Macros?

Before we write any code, let’s take a moment to understand what procedural macros are and why they’re useful.

In Rust, macros are metaprogramming tools that allow you to generate code at compile time. You’ve likely used declarative macros like println!() or vec![], which are great for simple code generation. But what if you need finer control? What if you want to modify or analyze Rust code itself? That’s where procedural macros come in.

Procedural macros are small programs that take Rust code as input, manipulate or analyze it, and produce new Rust code as output. They’re immensely powerful for tasks like generating repetitive boilerplate, implementing custom attributes, or even creating entire domain-specific languages.


What Will We Build?

To keep things simple, we’ll implement a #[my_debug] macro. When applied to a function, this macro will automatically print a message whenever the function is entered. For example:

use my_debug::my_debug;

#[my_debug]
fn greet(name: &str) {
    println!("Hello, {}!", name);
}

fn main() {
    greet("Rustacean");
}
Enter fullscreen mode Exit fullscreen mode

Running this program will produce the following output:

Entering greet
Hello, Rustacean!
Enter fullscreen mode Exit fullscreen mode

This small macro will give us a practical introduction to procedural macros without being overwhelming.


Setting Up Your Project

Let’s build our procedural macro crate. Procedural macros must be defined in their own crate with the proc-macro crate type.

  1. Start by creating a new library crate:
   cargo new my_debug --lib
   cd my_debug
Enter fullscreen mode Exit fullscreen mode
  1. Open Cargo.toml and add the proc-macro key:
   [lib]
   proc-macro = true
Enter fullscreen mode Exit fullscreen mode
  1. Add the quote and syn crates as dependencies. These are invaluable tools for working with procedural macros:
   [dependencies]
   syn = "2.0"
   quote = "1.0"
Enter fullscreen mode Exit fullscreen mode
  • syn: Parses Rust source code into a manipulable syntax tree.
  • quote: Turns Rust syntax trees back into code.

Writing the #[my_debug] Macro

Now for the fun part: implementing the procedural macro.

Step 1: Import the Necessary Libraries

Open src/lib.rs and start with the following imports:

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};
Enter fullscreen mode Exit fullscreen mode
  • TokenStream is the input and output type for procedural macros.
  • quote helps us generate Rust code.
  • syn will parse the input into a structured representation.

Step 2: Define the Macro Function

Procedural macros are simply functions marked with the #[proc_macro_attribute] attribute. Let’s define our my_debug macro:

#[proc_macro_attribute]
pub fn my_debug(_attr: TokenStream, item: TokenStream) -> TokenStream {
    // Parse the input tokens into a syntax tree
    let input = parse_macro_input!(item as ItemFn);

    // Generate the new function with added debug functionality
    let output = expand_my_debug(input);

    // Convert the output back into a TokenStream
    TokenStream::from(output)
}
Enter fullscreen mode Exit fullscreen mode

Here’s a breakdown:

  • The _attr argument is for macro attributes (e.g., #[my_debug(some_attr)]). We won’t use it here, so we ignore it.
  • The item argument is the function to which the macro is applied.
  • We parse item into an ItemFn (a structure representing a function) and pass it to expand_my_debug for processing.

Step 3: Add Debug Logic

Now, let’s write the expand_my_debug function. This is where the magic happens:

fn expand_my_debug(input: ItemFn) -> proc_macro2::TokenStream {
    // Extract the function's name
    let fn_name = &input.sig.ident;

    // Get the original function body and signature
    let fn_sig = &input.sig;
    let fn_block = &input.block;

    // Generate a new function that adds a debug print before execution
    quote! {
        #fn_sig {
            println!("Entering {}", stringify!(#fn_name));
            #fn_block
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Here’s what’s going on:

  1. We extract the function’s name (fn_name) and reuse its signature (fn_sig) and body (fn_block).
  2. Using quote!, we generate a new function that wraps the original body with a println! statement.
  3. The stringify! macro converts the function name into a string literal.

Testing Your Macro

With our macro written, it’s time to test it. Procedural macros are compiled as separate crates, so we need to create a new binary crate to test it.

  1. Navigate to your workspace root and create a new binary crate:
   cargo new my_debug_test
   cd my_debug_test
Enter fullscreen mode Exit fullscreen mode
  1. Add the my_debug crate as a dependency in my_debug_test/Cargo.toml:
   [dependencies]
   my_debug = { path = "../my_debug" }
Enter fullscreen mode Exit fullscreen mode
  1. Write a test program in src/main.rs:
   use my_debug::my_debug;

   #[my_debug]
   fn greet(name: &str) {
       println!("Hello, {}!", name);
   }

   fn main() {
       greet("Rustacean");
   }
Enter fullscreen mode Exit fullscreen mode
  1. Run the program:
   cargo run
Enter fullscreen mode Exit fullscreen mode

You should see the following output:

Entering greet
Hello, Rustacean!
Enter fullscreen mode Exit fullscreen mode

Congratulations—you’ve written your first procedural macro!


Common Pitfalls and How to Avoid Them

Procedural macros are powerful but can be tricky to debug. Here are some common pitfalls:

  1. Forgetting to parse input: Always parse the input TokenStream into a structured form (e.g., ItemFn). Raw token manipulation is error-prone.
  2. Mismatched syntax: Ensure your generated code is valid Rust. Tools like cargo expand can help you inspect the output.
  3. Missing imports: Make sure to import quote, syn, and other necessary crates.
  4. Infinite recursion: Applying a procedural macro to itself can cause infinite recursion. Be cautious!

Key Takeaways and Next Steps

You’ve just built a fully functional procedural macro! Here’s what we covered:

  • What procedural macros are and why they’re useful.
  • How to set up a procedural macro crate.
  • Writing a simple #[my_debug] macro to enhance function debugging.
  • Common pitfalls and debugging strategies.

Next Steps

  1. Experiment with more complex macros, such as those that modify struct fields or generate boilerplate code.
  2. Dive deeper into the syn and quote documentation to explore their full capabilities.
  3. Try building a custom derive macro or a function-like macro for more advanced scenarios.

Procedural macros unlock a whole new level of power in Rust programming. With this foundation, you’re well-equipped to tackle more ambitious projects. Happy coding! 🚀


Did you enjoy this tutorial? Have questions or feedback? Share your thoughts in the comments below!

Top comments (2)