Lesson 80 of 94
Procedural Macros
A procedural macro is a Rust function that runs inside the compiler. It receives the tokens of your code and returns new tokens. #[derive(Serialize)], #[tokio::main], #[derive(Parser)] from clap, #[instrument] from tracing, and sqlx::query! are all procedural macros, so reading almost any real Rust codebase means reading code that one of them generated.
Two things are worth knowing up front:
- An attribute macro can replace the code it's attached to, not just add new items.
- There is no runtime reflection involved. Everything happens during
cargo build, and the generated code is type-checked like hand-written code.
There are three kinds:
| Kind | Declared with | Used as | Examples |
|---|---|---|---|
| derive | #[proc_macro_derive(Name)] |
#[derive(Name)] on a struct, enum, or union |
Serialize, Error, Parser |
| attribute | #[proc_macro_attribute] |
#[name] or #[name(args)] on any item |
#[tokio::main], #[instrument], #[async_trait] |
| function-like | #[proc_macro] |
name!(...) |
sqlx::query!, html! in web frameworks |
All three live in a separate crate with proc-macro = true and are written with three helper crates:
proc-macro2: a copy of the compiler'sTokenStreamtype that also works outside macros, in tests and normal programs.syn: parses tokens into a syntax tree (DeriveInput,ItemFn,Expr, ...).quote: turns a Rust-like template back into tokens.
This lesson uses them in three stages: first reading what derives produce, then using syn and quote in ordinary programs so you can see every step printed, and finally writing real macro crates.
Part 1: Reading procedural macros
1. Derive macros you already use
#[derive(...)] runs a procedural macro that reads your type definition and generates trait implementations. The standard derives (Debug, Clone, PartialEq, ...) are built into the compiler; crates add more.
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Order {
id: u32,
items: Vec<String>,
#[serde(rename = "totalCents")]
total_cents: u64,
}
#[derive(Debug, Error)]
enum OrderError {
#[error("order {0} not found")]
NotFound(u32),
#[error("invalid JSON: {0}")]
Json(#[from] serde_json::Error),
}
fn parse(json: &str) -> Result<Order, OrderError> {
let order: Order = serde_json::from_str(json)?;
if order.id == 0 {
return Err(OrderError::NotFound(0));
}
Ok(order)
}
fn main() {
let o = Order { id: 7, items: vec![String::from("book")], total_cents: 1500 };
let json = serde_json::to_string(&o).unwrap();
println!("{json}");
println!("{:?}", parse(&json).map(|p| p == o));
println!("{}", parse(r#"{"id":0,"items":[],"totalCents":0}"#).unwrap_err());
println!("{}", parse("{oops").unwrap_err());
}{"id":7,"items":["book"],"totalCents":1500}
Ok(true)
order 0 not found
invalid JSON: key must be a string at line 1 column 2#[serde(rename = ...)], #[error(...)], and #[from] are helper attributes: annotations that only the derive macro reads. The compiler ignores them otherwise.
2. What a derive generates
A derive writes the same code you would write by hand. For Debug, the expansion is essentially this:
use std::fmt;
// What `#[derive(Debug)]` generates for `struct Point { x: i32, y: i32 }`, simplified.
struct Point {
x: i32,
y: i32,
}
impl fmt::Debug for Point {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Point").field("x", &self.x).field("y", &self.y).finish()
}
}
#[derive(Debug)]
struct DerivedPoint {
x: i32,
y: i32,
}
fn main() {
println!("{:?}", Point { x: 1, y: 2 });
println!("{:?}", DerivedPoint { x: 1, y: 2 });
println!("{:#?}", Point { x: 3, y: 4 });
}Point { x: 1, y: 2 }
DerivedPoint { x: 1, y: 2 }
Point {
x: 3,
y: 4,
}To see the real expansion of any macro in your own project, install cargo expand:
cargo install cargo-expand
cargo expand # the whole crate, every macro expanded
cargo expand orders::api # one module3. What an attribute macro generates: #[tokio::main]
#[tokio::main] takes your async fn main and replaces it with a normal main that builds a runtime and blocks on your code. Both programs below do the same thing:
async fn load_greeting() -> String {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
String::from("hello from async")
}
// Roughly what `#[tokio::main] async fn main() { ... }` expands to.
fn main() {
let body = async {
println!("{}", load_greeting().await);
};
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("failed to build the Tokio runtime")
.block_on(body);
}hello from asyncasync fn load_greeting() -> String {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
String::from("hello from async")
}
#[tokio::main]
async fn main() {
println!("{}", load_greeting().await);
}hello from asyncPart 2: Tokens, syn, and quote
proc-macro2, syn, and quote are ordinary crates. The examples in this part use them in normal programs, so you can print every intermediate step. Add them with:
cargo add proc-macro2 quote prettyplease
cargo add syn --features full,extra-traits4. A token stream is a tree of four kinds of token
The compiler hands a macro a TokenStream: a sequence of token trees. There are only four kinds: identifiers (including keywords), punctuation, literals, and groups delimited by (), [], or {}. A group contains its own token stream, so the stream is a tree.
use proc_macro2::{TokenStream, TokenTree};
fn show(tokens: TokenStream, depth: usize) {
for tree in tokens {
let pad = " ".repeat(depth);
match tree {
TokenTree::Ident(ident) => println!("{pad}Ident {ident}"),
TokenTree::Punct(punct) => println!("{pad}Punct {}", punct.as_char()),
TokenTree::Literal(literal) => println!("{pad}Literal {literal}"),
TokenTree::Group(group) => {
println!("{pad}Group {:?}", group.delimiter());
show(group.stream(), depth + 1);
}
}
}
}
fn main() {
let tokens: TokenStream = "let total = price(qty, [1, 2]) * 2;".parse().unwrap();
show(tokens, 0);
}Ident let
Ident total
Punct =
Ident price
Group Parenthesis
Ident qty
Punct ,
Group Bracket
Literal 1
Punct ,
Literal 2
Punct *
Literal 2
Punct ;Tokens carry no meaning yet: price(qty, ...) is just an identifier followed by a group. Giving them structure is syn's job.
5. Parsing tokens into a syntax tree with syn
syn has a type for every piece of Rust syntax. Parsing an expression gives you an Expr enum with operator precedence already applied:
use quote::ToTokens;
use syn::{BinOp, Expr};
fn describe(expr: &Expr) -> String {
match expr {
Expr::Binary(binary) => {
let op = match binary.op {
BinOp::Add(_) => "+",
BinOp::Sub(_) => "-",
BinOp::Mul(_) => "*",
_ => "?",
};
format!("({} {op} {})", describe(&binary.left), describe(&binary.right))
}
Expr::Paren(paren) => describe(&paren.expr),
Expr::Path(path) => path.path.segments.last().unwrap().ident.to_string(),
other => other.to_token_stream().to_string(),
}
}
fn main() {
for source in ["a + b * 2", "(a + b) * 2", "price - discount - 1"] {
let expr: Expr = syn::parse_str(source).unwrap();
println!("{source:<22} => {}", describe(&expr));
}
let error = syn::parse_str::<Expr>("a + + b").unwrap_err();
println!("error: {error}");
}a + b * 2 => (a + (b * 2))
(a + b) * 2 => ((a + b) * 2)
price - discount - 1 => ((price - discount) - 1)
error: expected an expressionto_token_stream() (from the ToTokens trait) goes the other way: any syntax tree node can be turned back into tokens.
6. DeriveInput: what a derive macro receives
A derive macro receives the struct or enum it's attached to. syn parses that into a DeriveInput: the name, generics, attributes, and a data field that is either a struct, an enum, or a union.
use quote::ToTokens;
use syn::{Data, DeriveInput, Fields, Visibility};
fn main() {
let input: DeriveInput = syn::parse_str(
r#"
/// A customer record.
#[derive(Debug)]
pub struct Customer<T: Clone> {
pub id: u64,
email: String,
#[serde(default)]
tags: Vec<T>,
}
"#,
)
.unwrap();
println!("name: {}", input.ident);
println!("generics: {}", input.generics.to_token_stream());
println!("attrs: {}", input.attrs.len());
if let Data::Struct(data) = &input.data {
if let Fields::Named(fields) = &data.fields {
for field in &fields.named {
let name = field.ident.as_ref().unwrap();
let ty = field.ty.to_token_stream();
let public = matches!(field.vis, Visibility::Public(_));
println!("field: {name}: {ty} (pub: {public}, attrs: {})", field.attrs.len());
}
}
}
}name: Customer
generics: < T : Clone >
attrs: 2
field: id: u64 (pub: true, attrs: 0)
field: email: String (pub: false, attrs: 0)
field: tags: Vec < T > (pub: false, attrs: 1)The struct has two attributes because a /// doc comment is really an attribute, #[doc = " A customer record."]. Token streams print with spaces between tokens (Vec < T >); that's normal.
7. Generating code with quote!
quote! looks like Rust code with holes in it. #name inserts a variable that implements ToTokens, and #(...)* repeats for every item of an iterator, like $(...)* in macro_rules!. format_ident! builds new identifiers. prettyplease formats the result so you can read it.
use quote::{format_ident, quote};
fn main() {
let name = format_ident!("Customer");
let fields = [("id", "u64"), ("email", "String"), ("vip", "bool")];
let setters = fields.iter().map(|(field, ty)| {
let field = format_ident!("{}", field);
let setter = format_ident!("with_{}", field);
let ty: syn::Type = syn::parse_str(ty).unwrap();
quote! {
pub fn #setter(mut self, value: #ty) -> Self {
self.#field = value;
self
}
}
});
let names = fields.iter().map(|(field, _)| field);
let tokens = quote! {
impl #name {
pub const FIELDS: &'static [&'static str] = &[#(#names),*];
#(#setters)*
}
};
let file: syn::File = syn::parse2(tokens).unwrap();
println!("{}", prettyplease::unparse(&file));
}impl Customer {
pub const FIELDS: &'static [&'static str] = &["id", "email", "vip"];
pub fn with_id(mut self, value: u64) -> Self {
self.id = value;
self
}
pub fn with_email(mut self, value: String) -> Self {
self.email = value;
self
}
pub fn with_vip(mut self, value: bool) -> Self {
self.vip = value;
self
}
}A &str interpolated with # becomes a string literal ("id"). To paste text in as an identifier, make an Ident with format_ident!.
8. Macro logic as a normal function, with unit tests
Real macro crates keep the logic in a function from proc_macro2::TokenStream to syn::Result<proc_macro2::TokenStream>. The macro entry point is then one line, and the logic can be unit tested like any other code:
use proc_macro2::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput};
fn expand_describe(input: TokenStream) -> syn::Result<TokenStream> {
let ast: DeriveInput = syn::parse2(input)?;
let name = &ast.ident;
let Data::Struct(data) = &ast.data else {
return Err(syn::Error::new_spanned(name, "Describe only supports structs"));
};
let fields: Vec<String> = data.fields.iter().filter_map(|f| f.ident.as_ref()).map(|i| i.to_string()).collect();
let text = format!("{name} {{ {} }}", fields.join(", "));
Ok(quote! {
impl #name {
pub fn describe() -> &'static str {
#text
}
}
})
}
#[test]
fn lists_named_fields() {
let output = expand_describe(quote! { struct Point { x: i32, y: i32 } }).unwrap();
assert!(output.to_string().contains(r#""Point { x, y }""#));
}
#[test]
fn rejects_enums() {
let error = expand_describe(quote! { enum Shape { Circle } }).unwrap_err();
assert_eq!(error.to_string(), "Describe only supports structs");
}running 2 tests
test lists_named_fields ... ok
test rejects_enums ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00squote! is handy for writing test input: it produces a token stream from real Rust syntax.
Part 3: Writing procedural macros
9. Your first derive macro
A procedural macro crate is a library with proc-macro = true. It can only export macros, so a project that also has runtime code usually has two crates: mylib and mylib-derive (like serde and serde_derive).
[package]
name = "describe_derive"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1"
proc-macro2 = "1"The entry point takes and returns the compiler's own proc_macro::TokenStream. parse_macro_input! parses it with syn, and turns a parse error into a compile error for you.
use proc_macro::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, parse_macro_input};
#[proc_macro_derive(Describe)]
pub fn derive_describe(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
let Data::Struct(data) = &ast.data else {
panic!("Describe can only be derived for structs");
};
let fields: Vec<String> = data.fields.iter().filter_map(|f| f.ident.as_ref()).map(|i| i.to_string()).collect();
let text = format!("{name} {{ {} }}", fields.join(", "));
quote! {
impl #name {
pub fn describe() -> &'static str {
#text
}
}
}
.into()
}An application adds describe_derive = { path = "../describe_derive" } to its dependencies and uses the derive:
use describe_derive::Describe;
#[derive(Describe)]
#[allow(dead_code)]
struct Customer {
id: u64,
email: String,
vip: bool,
}
#[derive(Describe)]
#[allow(dead_code)]
struct Point {
x: i32,
y: i32,
}
fn main() {
println!("{}", Customer::describe());
println!("{}", Point::describe());
}Customer { id, email, vip }
Point { x, y }The strings were built at compile time; at run time describe() just returns a constant.
10. A derive for enums
Rust enums have no built-in way to list their variants or get a variant's name. A derive can generate both. For each variant it produces a match arm, with a pattern that fits the variant's shape:
use proc_macro::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, Fields, parse_macro_input};
#[proc_macro_derive(VariantNames)]
pub fn derive_variant_names(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
let Data::Enum(data) = &ast.data else {
panic!("VariantNames can only be derived for enums");
};
let names = data.variants.iter().map(|v| v.ident.to_string());
let arms = data.variants.iter().map(|v| {
let variant = &v.ident;
let text = variant.to_string();
match &v.fields {
Fields::Unit => quote! { #name::#variant => #text },
Fields::Unnamed(_) => quote! { #name::#variant(..) => #text },
Fields::Named(_) => quote! { #name::#variant { .. } => #text },
}
});
quote! {
impl #name {
pub const VARIANTS: &'static [&'static str] = &[#(#names),*];
pub fn variant_name(&self) -> &'static str {
match self {
#(#arms),*
}
}
}
}
.into()
}use variant_names::VariantNames;
#[derive(VariantNames)]
#[allow(dead_code)]
enum Event {
Login { user: String },
Purchase(u64),
Logout,
}
fn main() {
println!("{:?}", Event::VARIANTS);
let events = [Event::Login { user: String::from("ana") }, Event::Purchase(1999), Event::Logout];
for event in &events {
println!("{}", event.variant_name());
}
}["Login", "Purchase", "Logout"]
Login
Purchase
LogoutThe strum crate provides this and more (EnumIter, EnumString, Display) as ready-made derives.
11. Generic types and compile errors
Two things separate a toy derive from a real one:
- Generics.
impl Pagedoesn't compile forstruct Page<T>.split_for_impl()returns the three pieces an impl needs:impl<T: Clone>,Page<T>, and thewhereclause. - Errors. Panicking gives a poor message. Returning a
syn::Errorwith a span makes the compiler point at the exact tokens that are wrong.
This derive generates getters:
use proc_macro::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, Fields, parse_macro_input};
#[proc_macro_derive(Getters)]
pub fn derive_getters(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
expand(&ast).unwrap_or_else(syn::Error::into_compile_error).into()
}
fn expand(ast: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
let name = &ast.ident;
let Data::Struct(data) = &ast.data else {
return Err(syn::Error::new_spanned(name, "Getters can only be derived for structs"));
};
let Fields::Named(fields) = &data.fields else {
return Err(syn::Error::new_spanned(&data.fields, "Getters needs named fields"));
};
let getters = fields.named.iter().map(|field| {
let ident = field.ident.as_ref().unwrap();
let ty = &field.ty;
quote! {
pub fn #ident(&self) -> &#ty {
&self.#ident
}
}
});
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
Ok(quote! {
impl #impl_generics #name #type_generics #where_clause {
#(#getters)*
}
})
}use getters::Getters;
#[derive(Getters)]
struct Page<T> {
items: Vec<T>,
number: u32,
total_pages: u32,
}
#[derive(Getters)]
struct Account {
owner: String,
balance_cents: i64,
}
fn main() {
let page = Page { items: vec!["ana", "ben"], number: 1, total_pages: 3 };
println!("{:?} page {}/{}", page.items(), page.number(), page.total_pages());
let account = Account { owner: String::from("cy"), balance_cents: 1250 };
println!("{} has {} cents", account.owner(), account.balance_cents());
}["ana", "ben"] page 1/3
cy has 1250 centsDeriving it on an enum now gives an error that points at the enum's name, just like a built-in error:
use getters::Getters;
#[derive(Getters)]
enum Status {
Active,
Closed,
}
fn main() {}error: Getters can only be derived for structs
--> src/main.rs:4:6
|
4 | enum Status {
| ^^^^^^12. Helper attributes and absolute paths: a builder
A derive declares its helper attributes with attributes(...), then reads them from each field. parse_nested_meta parses the contents of #[builder(...)] and reports unknown options with a proper span.
This derive also shows a rule every real macro follows: generated code must use absolute paths such as ::core::option::Option. The macro can't know what the user has in scope, and many crates define their own type Result<T> = ....
use proc_macro::TokenStream;
use quote::{format_ident, quote};
use syn::{Data, DeriveInput, Field, Fields, parse_macro_input};
#[proc_macro_derive(Builder, attributes(builder))]
pub fn derive_builder(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
expand(&ast).unwrap_or_else(syn::Error::into_compile_error).into()
}
// Is the field marked `#[builder(default)]`?
fn has_default(field: &Field) -> syn::Result<bool> {
let mut default = false;
for attr in field.attrs.iter().filter(|attr| attr.path().is_ident("builder")) {
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("default") {
default = true;
Ok(())
} else {
Err(meta.error("unknown builder option, expected `default`"))
}
})?;
}
Ok(default)
}
fn expand(ast: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
let name = &ast.ident;
let vis = &ast.vis;
let builder = format_ident!("{}Builder", name);
let Data::Struct(data) = &ast.data else {
return Err(syn::Error::new_spanned(name, "Builder can only be derived for structs"));
};
let Fields::Named(fields) = &data.fields else {
return Err(syn::Error::new_spanned(&data.fields, "Builder needs named fields"));
};
let mut slots = Vec::new();
let mut setters = Vec::new();
let mut finishers = Vec::new();
for field in &fields.named {
let ident = field.ident.as_ref().unwrap();
let ty = &field.ty;
slots.push(quote! { #ident: ::core::option::Option<#ty> });
setters.push(quote! {
pub fn #ident(mut self, value: impl ::core::convert::Into<#ty>) -> Self {
self.#ident = ::core::option::Option::Some(value.into());
self
}
});
let missing = format!("missing field `{ident}`");
finishers.push(if has_default(field)? {
quote! { #ident: self.#ident.unwrap_or_default() }
} else {
quote! { #ident: self.#ident.ok_or(#missing)? }
});
}
Ok(quote! {
#[derive(Default)]
#vis struct #builder {
#(#slots,)*
}
impl #builder {
#(#setters)*
pub fn build(self) -> ::core::result::Result<#name, &'static str> {
::core::result::Result::Ok(#name { #(#finishers,)* })
}
}
impl #name {
pub fn builder() -> #builder {
<#builder as ::core::default::Default>::default()
}
}
})
}The application below defines its own Result alias, as many crates do. The generated code still compiles because it never says plain Result:
use builder_derive::Builder;
use std::time::Duration;
type Result<T> = std::result::Result<T, String>;
#[derive(Debug, Builder)]
struct HttpRequest {
method: String,
url: String,
#[builder(default)]
headers: Vec<(String, String)>,
#[builder(default)]
timeout: Duration,
}
fn send(request: &HttpRequest) -> Result<u16> {
if request.url.starts_with("https://") { Ok(200) } else { Err(format!("refusing {}", request.url)) }
}
fn main() {
let request = HttpRequest::builder()
.method("GET")
.url("https://api.example.com/orders")
.timeout(Duration::from_secs(30))
.build()
.unwrap();
println!("{request:?}");
println!("{:?}", send(&request));
let incomplete = HttpRequest::builder().method("POST").build();
println!("{incomplete:?}");
}HttpRequest { method: "GET", url: "https://api.example.com/orders", headers: [], timeout: 30s }
Ok(200)
Err("missing field `url`")A typo in a helper attribute is caught at compile time, pointing at the typo:
use builder_derive::Builder;
#[derive(Builder)]
struct Config {
#[builder(defualt)]
port: u16,
}
fn main() {}error: unknown builder option, expected `default`
--> src/main.rs:5:15
|
5 | #[builder(defualt)]
| ^^^^^^^13. An attribute macro that wraps a function
An attribute macro receives two token streams: the attribute's arguments and the item it's attached to. Whatever it returns replaces the item. This one parses a function as an ItemFn and wraps its body with logging, the way #[tracing::instrument] does:
use proc_macro::TokenStream;
use quote::quote;
use syn::{FnArg, ItemFn, Pat, parse_macro_input};
#[proc_macro_attribute]
pub fn logged(_args: TokenStream, item: TokenStream) -> TokenStream {
let ItemFn { attrs, vis, sig, block } = parse_macro_input!(item as ItemFn);
let name = sig.ident.to_string();
let output = &sig.output;
// The names of the `name: Type` parameters, to print on entry.
let params: Vec<_> = sig
.inputs
.iter()
.filter_map(|input| match input {
FnArg::Typed(typed) => match &*typed.pat {
Pat::Ident(pat) => Some(pat.ident.clone()),
_ => None,
},
FnArg::Receiver(_) => None,
})
.collect();
quote! {
#(#attrs)*
#vis #sig {
let __args: ::std::vec::Vec<::std::string::String> = ::std::vec![#(::std::format!("{:?}", #params)),*];
::std::println!("-> {}({})", #name, __args.join(", "));
// The original body runs in a closure, so an early `return` or `?` still ends up here.
let __result = (move || #output #block)();
::std::println!("<- {} = {:?}", #name, __result);
__result
}
}
.into()
}use logged::logged;
#[logged]
fn add(a: i32, b: i32) -> i32 {
a + b
}
#[logged]
fn parse_port(text: &str) -> Result<u16, String> {
let port: u16 = text.parse().map_err(|e| format!("{e}"))?;
if port < 1024 {
return Err(format!("port {port} is reserved"));
}
Ok(port)
}
fn main() {
add(2, 3);
let _ = parse_port("8080");
let _ = parse_port("80");
let _ = parse_port("http");
}-> add(2, 3)
<- add = 5
-> parse_port("8080")
<- parse_port = Ok(8080)
-> parse_port("80")
<- parse_port = Err("port 80 is reserved")
-> parse_port("http")
<- parse_port = Err("invalid digit found in string")Two details worth copying from real macros:
- The local variables are called
__argsand__result. Unlikemacro_rules!, identifiers made byquote!are not hygienic by default: a plainresultcould clash with a parameter of the same name. (Span::mixed_site()givesmacro_rules!-style hygiene for local variables when you need it.) #(#attrs)*and#visput the function's doc comments, other attributes, and visibility back. Forgetting them silently dropspubor#[test].
14. Attribute arguments: #[retry(times = 3)]
The first token stream holds the arguments. syn::meta::parser parses key = value lists with the same meta API as helper attributes:
use proc_macro::TokenStream;
use quote::quote;
use syn::{ItemFn, LitInt, parse_macro_input};
#[proc_macro_attribute]
pub fn retry(args: TokenStream, item: TokenStream) -> TokenStream {
let mut times: u32 = 3;
let parser = syn::meta::parser(|meta| {
if meta.path.is_ident("times") {
times = meta.value()?.parse::<LitInt>()?.base10_parse()?;
Ok(())
} else {
Err(meta.error("expected `times = N`"))
}
});
parse_macro_input!(args with parser);
let ItemFn { attrs, vis, sig, block } = parse_macro_input!(item as ItemFn);
let name = sig.ident.to_string();
let output = &sig.output;
quote! {
#(#attrs)*
#vis #sig {
let mut __attempt: u32 = 1;
loop {
match (|| #output #block)() {
::core::result::Result::Ok(value) => return ::core::result::Result::Ok(value),
::core::result::Result::Err(error) if __attempt < #times => {
::std::println!("{} attempt {} failed: {}; retrying", #name, __attempt, error);
__attempt += 1;
}
::core::result::Result::Err(error) => return ::core::result::Result::Err(error),
}
}
}
}
.into()
}use retry_attr::retry;
use std::sync::atomic::{AtomicU32, Ordering};
static CALLS: AtomicU32 = AtomicU32::new(0);
#[retry(times = 4)]
fn fetch_price(sku: &str) -> Result<u32, String> {
let call = CALLS.fetch_add(1, Ordering::SeqCst) + 1;
if call < 3 { Err(format!("timeout #{call} for {sku}")) } else { Ok(1999) }
}
#[retry(times = 2)]
fn always_down() -> Result<(), String> {
Err(String::from("503 Service Unavailable"))
}
fn main() {
println!("{:?}", fetch_price("SKU-1"));
println!("{:?}", always_down());
}fetch_price attempt 1 failed: timeout #1 for SKU-1; retrying
fetch_price attempt 2 failed: timeout #2 for SKU-1; retrying
Ok(1999)
always_down attempt 1 failed: 503 Service Unavailable; retrying
Err("503 Service Unavailable")15. A function-like macro that validates at compile time
A function-like macro receives the tokens inside its parentheses. It can do any computation and turn problems into compile errors. hex! turns a hex string into a byte array while compiling, so a typo never reaches run time:
use proc_macro::TokenStream;
use quote::quote;
use syn::{LitStr, parse_macro_input};
#[proc_macro]
pub fn hex(input: TokenStream) -> TokenStream {
let literal = parse_macro_input!(input as LitStr);
let digits: String = literal.value().chars().filter(|c| !c.is_whitespace()).collect();
if let Some(bad) = digits.chars().find(|c| !c.is_ascii_hexdigit()) {
let message = format!("`{bad}` is not a hex digit");
return syn::Error::new_spanned(&literal, message).to_compile_error().into();
}
if digits.len() % 2 != 0 {
return syn::Error::new_spanned(&literal, "hex string needs an even number of digits").to_compile_error().into();
}
let bytes = digits.as_bytes().chunks(2).map(|pair| {
let pair = std::str::from_utf8(pair).unwrap();
u8::from_str_radix(pair, 16).unwrap()
});
quote! { [#(#bytes),*] }.into()
}use hex_lit::hex;
const PNG_SIGNATURE: [u8; 4] = hex!("89 50 4E 47");
fn main() {
let key = hex!("00ff 10 7f");
println!("{PNG_SIGNATURE:?}");
println!("{key:?} ({} bytes)", key.len());
let file = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A];
println!("is PNG: {}", file.starts_with(&PNG_SIGNATURE));
}[137, 80, 78, 71]
[0, 255, 16, 127] (4 bytes)
is PNG: trueThe array's length is part of its type ([u8; 4]), and it was computed by the macro. A bad digit is a compile error:
use hex_lit::hex;
fn main() {
let key = hex!("12 3g");
println!("{key:?}");
}error: `g` is not a hex digit
--> src/main.rs:4:20
|
4 | let key = hex!("12 3g");
| ^^^^^^^16. Custom syntax with the Parse trait
For your own syntax, implement syn::parse::Parse for a struct. input.parse()? reads one piece at a time (an identifier, a string literal, a => token), and Punctuated handles comma-separated lists. This routes! macro turns a routing table into a match, and rejects unknown HTTP methods at compile time:
use proc_macro::TokenStream;
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{Ident, LitStr, Token, parse_macro_input};
// One `GET "/users" => list_users` entry.
struct Route {
method: Ident,
path: LitStr,
handler: Ident,
}
impl Parse for Route {
fn parse(input: ParseStream) -> syn::Result<Self> {
let method: Ident = input.parse()?;
if !["GET", "POST", "PUT", "DELETE"].contains(&method.to_string().as_str()) {
return Err(syn::Error::new(method.span(), "expected GET, POST, PUT, or DELETE"));
}
let path: LitStr = input.parse()?;
input.parse::<Token![=>]>()?;
let handler: Ident = input.parse()?;
Ok(Route { method, path, handler })
}
}
#[proc_macro]
pub fn routes(input: TokenStream) -> TokenStream {
let routes = parse_macro_input!(input with Punctuated::<Route, Token![,]>::parse_terminated);
let arms = routes.iter().map(|Route { method, path, handler }| {
let method = method.to_string();
quote! { (#method, #path) => ::core::option::Option::Some(#handler(body)) }
});
quote! {
fn dispatch(method: &str, path: &str, body: &str) -> ::core::option::Option<::std::string::String> {
match (method, path) {
#(#arms,)*
_ => ::core::option::Option::None,
}
}
}
.into()
}use routes_macro::routes;
fn list_users(_body: &str) -> String {
String::from("[ana, ben]")
}
fn create_user(body: &str) -> String {
format!("created {body}")
}
fn delete_user(_body: &str) -> String {
String::from("forbidden")
}
routes! {
GET "/users" => list_users,
POST "/users" => create_user,
DELETE "/users" => delete_user,
}
fn main() {
for (method, path, body) in [("GET", "/users", ""), ("POST", "/users", "cy"), ("PUT", "/users", "")] {
match dispatch(method, path, body) {
Some(response) => println!("{method} {path} -> {response}"),
None => println!("{method} {path} -> 405 Method Not Allowed"),
}
}
}GET /users -> [ana, ben]
POST /users -> created cy
PUT /users -> 405 Method Not AllowedCompare this with the macro_rules! version in Declarative Macros: that one builds a Vec at run time and accepts any identifier as a method. This one produces a plain match and validates the table while compiling:
use routes_macro::routes;
fn update_user(_body: &str) -> String {
String::from("updated")
}
routes! {
PATCH "/users" => update_user,
}
fn main() {}error: expected GET, POST, PUT, or DELETE
--> src/main.rs:8:5
|
8 | PATCH "/users" => update_user,
| ^^^^^17. Testing macros that must fail: trybuild
Unit tests (example 8) cover the expansion logic. To check that users get the right compile errors, the trybuild crate compiles small test files and compares the compiler's output with a saved .stderr file:
// builder_derive/tests/ui.rs
#[test]
fn ui() {
let t = trybuild::TestCases::new();
t.pass("tests/ui/basic.rs"); // must compile
t.compile_fail("tests/ui/typo.rs"); // must fail with the output in tests/ui/typo.stderr
}cargo add --dev trybuild
TRYBUILD=overwrite cargo test # write or refresh the .stderr files, then review the diffChoosing between the kinds
| You want to... | Use |
|---|---|
| implement a trait or add methods based on a type's fields or variants | derive macro |
| change or wrap a function, module, or impl | attribute macro |
| accept custom syntax, or compute something at compile time from a literal | function-like macro, or macro_rules! if pattern matching is enough |
| cut down on repetition inside one crate | macro_rules!: no extra crate, faster to compile |
Procedural macros cost compile time: syn with the full feature is a sizable dependency, and every macro invocation runs code. Crates that care about build times ask for only the syn features they need.
Summary
- Procedural macros are functions from token streams to token streams. They live in a
proc-macro = truecrate and come in three kinds: derive, attribute, and function-like. - A token stream is a tree of identifiers, punctuation, literals, and delimited groups.
synparses it into typed syntax (DeriveInput,ItemFn,Expr), andquote!generates tokens with#varand#(...)*. - Keep the logic in functions over
proc_macro2::TokenStreamreturningsyn::Result, so it can be unit tested; the entry point converts errors withinto_compile_error(). - Handle generics with
split_for_impl(), read helper attributes withparse_nested_meta, and report problems withsyn::Error::new_spannedso the compiler points at the right tokens. - Generated code should use absolute paths (
::core::result::Result) and unusual names for locals, because proc macro identifiers are not hygienic by default. cargo expandshows what a macro produced;trybuildtests the errors users will see.
Next: Unsafe Rust & FFI: what unsafe allows and how to wrap it in safe APIs.
