Procedural macros use Rust programs to process tokens and output tokens, making them suitable for structured parsing and code generation that declarative macros struggle to express. They run in the host compilation environment, and their output is then compiled by the target crate; they are not general-purpose compiler plugins that can query the real types of arbitrary variables or trait implementations.
How the Three Types of Macros Change the Call Site
Category
Input
Output Behavior
#[derive(MyTrait)]
The derived struct, enum, or union
Appends generated items after the original definition, preserving the original type definition
#[my_attr(...)]
Attribute parameters and the annotated item (two token streams)
Replaces the annotated item; can output multiple items
my_macro!(...)
Token stream within delimiters
Replaces the macro call; must conform to the syntax position
If an attribute macro forgets to output the original function, it will genuinely remove it; a derive macro cannot modify original fields by outputting a new struct with the same name. Macros may also error, panic, or enter infinite execution; error handling for invalid inputs should be considered part of the interface design. Rust Procedural Macros Rules
A Derive Implementation That Preserves Generics
Create the workspace root Cargo.toml:
[workspace]
members = ["hello-derive", "demo"]
resolver = "3"
hello-derive/Cargo.toml:
[package]
name = "hello-derive"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
syn = "2"
quote = "1"
hello-derive/src/lib.rs:
useproc_macro::TokenStream;usequote::quote;usesyn::{parse_macro_input, DeriveInput};#[proc_macro_derive(Hello)]pubfnderive_hello(input: TokenStream)-> TokenStream{let input =parse_macro_input!(input as DeriveInput);ifmatches!(input.data,syn::Data::Union(_)){returnsyn::Error::new_spanned(&input.ident,"Hello does not support unions")
.to_compile_error().into();}let name =&input.ident;let(impl_generics, type_generics, where_clause)= input.generics.split_for_impl();let expanded =quote!{impl#impl_genericscrate::Hellofor #name #type_generics #where_clause{fnhello_name()->&'staticstr{::core::stringify!(#name)}}};
expanded.into()}
demo/Cargo.toml:
[package]
name = "demo"
version = "0.1.0"
edition = "2024"
[dependencies]
hello-derive = { path = "../hello-derive" }
demo/src/main.rs:
traitHello{fnhello_name()->&'staticstr;}#[derive(hello_derive::Hello)]structExample<T> where T: Copy {value: T,
}fnmain(){let value = Example { value:42_u32};assert_eq!(value.value,42);assert_eq!(Example::<u32>::hello_name(),"Example");println!("{}",Example::<u32>::hello_name());}
Running cargo run -p demo should print Example. This teaching macro explicitly requires the Hello trait to exist in the calling crate's root; a formal public library should design stable trait paths or configurable paths, and consider cases where dependencies are renamed. Do not implicitly impose this convention on all users.
Why Split Generics into Three Parts
impl<T>, Example<T>, and where T: Copy appear in different positions with different syntactic responsibilities. You cannot simply interpolate the entire Generics object in all positions. split_for_impl provides the corresponding forms for them and handles details such as default parameters that should not be repeated in the impl. syn Generics
If the generated methods read fields and require traits like Display, you need to add appropriate constraints. Do not blindly add the same bound to all generic parameters for convenience; fields might use PhantomData, associated types, or not involve that parameter at all. Design constraints based on the actual requirements of the generated code.
The example uses expanded as the variable name; gen is a reserved keyword in Rust 2024, so let gen from older tutorials cannot be used verbatim in that edition unless written as the raw identifier r#gen. gen keyword
Same Derive, Verifying Three Types of Generics and Default Parameters
Keep hello-derive unchanged and replace demo/src/main.rs with the following complete program. This example simultaneously verifies lifetimes, type parameters, const parameters, and where clauses, and uses the macro within a submodule.
traitHello{fnhello_name()->&'staticstr;}modmodel{#[derive(hello_derive::Hello)]pubstructRecord<'a, T = u8, const N:usize = 2>
where T: Copy,
{pubname:&'astr,
pubvalues: [T; N],
}}fnmain(){let name =String::from("sample");let record:model::Record<'_>=model::Record {
name:&name, values:[1,2],};assert_eq!(record.name,"sample");assert_eq!(record.values,[1,2]);assert_eq!(<model::Record<'_, u8, 2>as Hello>::hello_name(),"Record");}
The generated implementation header is structurally equivalent to impl<'a, T, const N: usize> crate::Hello for Record<'a, T, N> where T: Copy. Default parameters are not repeated at the impl declaration site, but T and N must be passed when applying the type; the where clause follows the type. The tokens at these three positions are not the same syntax; directly interpolating the entire declaration repeatedly may generate invalid code.
Here, the root trait is still found as crate::Hello within the submodule. If the trait is moved elsewhere, you must synchronously change the generated path or design a configurable entry point; "submodule call succeeds" does not mean "any dependency layout is automatically adapted."
If Generated Code Doesn't Use Fields, Don't Impose Field Constraints
Here is another replaceable demo/src/main.rs. Hello only returns the type name and does not read or clone fields, so the implementation should hold even if T does not implement Clone, Copy, or Display.
If you later add functionality to print fields, you need to constrain the types of fields that are actually formatted, rather than mechanically adding Display to all T. For example, if a field is <T as Trait>::Item, the required capability might fall on the associated type; if a field is PhantomData<T>, T might not need to have the same capability. Macro authors should deduce constraints from the actual operations performed by the generated methods.
Why split one generic declaration into three parts?
Defaults expose careless copying: a struct may declare T=u8, but an impl must not repeat the default, and Record’s application uses only T. Toggle the syntax elements to see that split_for_impl organizes position-specific syntax rather than type-checking fields.
Preparing the visual
Why split one generic declaration into three parts?
impl declares parameters, type application supplies arguments, and where retains bounds; defaults are not repeated in impl.
syn parses syntax trees, and quote constructs tokens via interpolation. Seeing a field type alias Alias does not mean the macro can query what type it ultimately resolves to; guessing types via token strings is also susceptible to changes in paths, generics, and formatting.
Procedural macros are generally unhygienic; generated names and paths are influenced by the calling environment. Prioritize using appropriate qualified paths to avoid conflicts with user items, and use input spans to point errors to the relevant declarations. Converting TokenStream to a string and then performing string replacement usually loses structure and diagnostic quality.
Helper Attributes Also Require Complete Syntax Contracts
If a derive wants to accept #[hello(...)], you must register the corresponding helper attribute in #[proc_macro_derive(Hello, attributes(hello))] and handle it in the parsing code. Registration only allows the compiler to accept the attribute; it does not automatically execute configuration, nor does it validate its position or content for the macro.
For example, when preparing to support #[hello(trait_path = "crate::api::Hello")], you should first define the following behaviors; the minimal Hello implementation above does not yet provide this option.
Input
Result the Macro Must Explicitly Decide
Path not configured
Use the default path specified in the documentation
Configured with a valid path string
Parse into syn::Path and use it in the generated code
Unknown key name
Point to the key and report an unsupported option
Path specified multiple times
Reject duplicates or define clear override rules to avoid accidentally taking the last one
Invalid path syntax
Report a parsing error at the input string location
Attribute placed in an unsupported location
Explicitly reject it to avoid silent ignoring
syn's parse_nested_meta is suitable for common key-value/nested attribute syntax, and LitStr can be further parsed into a Path; custom DSLs can implement Parse. Do not convert the entire attribute to a string first and then rely on splitting by commas and equals signs, as parentheses grouping, escaping, and nesting will break this parsing method. syn 2 Attribute Parsing
Crate Dependency Direction
Macro definitions must reside in the root of the proc-macro crate and cannot be used directly within the same crate where they are defined; they can be called by independent users, integration tests, etc. This crate can have private helper functions and modules, but it cannot export arbitrary public runtime APIs like a normal library.
Workspace members can certainly depend on another member providing a procedural macro; the restriction is that the dependency graph must not contain cycles. A common structure is separating runtime interface libraries and derive libraries, with the interface library re-exporting the macro if necessary; the fact that generated code references a certain path does not mean the macro crate itself must depend on that runtime crate.
Macro execution can access host resources such as the file system, so builds should control external inputs, avoid relying on the accidental current directory, and avoid untraceable environment changes. Across compilations, cfg inside macros sees the host configuration; target conditions usually need to generate cfg in the target code or use explicitly passed build information.
Generated Target Paths vs. Macro's Own Build Dependencies
If a macro outputs ::runtime_api::Trait, this requires the user to be able to resolve that path when compiling the generated code; it does not mean the macro itself is currently calling runtime_api code. The macro crate can depend only on syn/quote to generate tokens; the runtime API crate can then re-export the derive, without necessarily creating a reverse dependency cycle.
Dependency renaming changes the crate name visible to the user. Public macros can provide path configuration or use a verified dependency name resolution scheme; hardcoding an apparently absolute path only solves some name conflicts but does not solve the renaming problem. You cannot use $crate (specific to declarative macros) as a general path patch in procedural macros.
Usage of Diagnostics and Expanded Results
Expected erroneous inputs should use syn::Error etc. to generate compile_error!, preserving location and clear messages, rather than using panic to report all errors. The union example above gives a reason for rejection at the type name.
cargo expand is an additionally installed tool that helps view the generated structure. It displays a text-based expansion result and cannot fully preserve hygiene and other compiler internal information; therefore, the output is not guaranteed to behave identically if copied, nor is it the final assembly. cargo-expand
When verifying procedural macros, at least check simple types, generics and where clauses, module locations, and unsupported inputs. Just seeing that the example code expands "looks like an impl" is not enough to prove that the generated library is suitable for real callers.
Error Samples Must Fail to Compile, Success Samples Must Be Actually Called
Use Case
Behavior to Verify
Normal struct
Generated implementation is callable, original fields still exist
Lifetime + Type + Const Generics
Implementation header and type application parameters are valid
Default Generic Parameters
Default values are not copied to the impl generic declaration
Types in Submodules
Generated paths resolve according to convention
Generic Parameters Without Extra Traits
No irrelevant constraints are added due to macro generation
Union
Explicitly reports "Hello does not support unions"
Trait Does Not Exist or Is Renamed
Cannot pretend the generated code has passed name checking
You can use a dedicated compile test framework to manage pass/fail cases, or compile independent samples separately in a small workspace. Diagnostic assertions should focus on the core messages and key locations provided by the macro; full rustc output may change with versions, so check the reasons for changes when updating snapshots.
cargo expand helps check "what was generated," running samples checks "if the interface works as expected," and failure samples check "which inputs are explicitly unsupported." These three types of evidence complement each other; printing out beautiful tokens is not enough to guarantee that generics, paths, and diagnostics hold up for real users.