
Macros Code Review
- 46 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
macros-code-review is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- macros-code-review
- AI & Agent Building
- AI-coding skill
Macros Code Review by the numbers
- 46 all-time installs (skills.sh)
- Ranked #7,568 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill macros-code-reviewAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 46 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
Macros Code Review
Review Workflow
1. Check `Cargo.toml` -- Note Rust edition (2024 reserves gen keyword, affecting macro output), proc-macro crate dependencies (syn, quote, proc-macro2), and feature flags (e.g., syn with minimal features) 2. Check macro type -- Determine if reviewing declarative (macro_rules!), function-like proc macro, attribute macro, or derive macro 3. Check if a macro is needed -- If the transformation is type-based, generics are better. Macros are for structural/repetitive code generation that generics cannot express 4. Scan macro definitions -- Read full macro bodies including all match arms, not just the invocation site 5. Check each category -- Work through the checklist below, loading references as needed 6. Gates -- Complete Gates below before reporting; do not substitute informal “I verified.”
Gates (before reporting findings)
Complete in order. Do not emit findings until Gate 4 passes for each issue.
Gate 1 — Crate context (on disk) PASS when: You opened the reviewed crate’s Cargo.toml (workspace member path if applicable) and recorded Rust edition, whether the crate is proc-macro = true, and relevant proc-macro dependencies or syn / quote feature flags. Blocks rationalization: Edition 2024 findings (gen, unsafe extern, generated unsafe bodies) and syn “full” vs minimal flags require this — do not flag edition-specific macro output without matching edition from the file.
Gate 2 — Macro definitions read PASS when: For every macro you critique, you read the full definition (all macro_rules! arms, or the proc-macro entry plus helpers you rely on), not only call sites or partial expansions. Artifact: At least one path per macro to the defining .rs file(s) you used.
Gate 3 — Per-finding evidence PASS when: Each planned issue has [FILE:LINE] from the current tree for the macro definition, attribute/derive site, or generated code location you are discussing (not from memory, docs-only, or another branch).
Gate 4 — Pre-report protocol PASS when: You loaded and applied the review-verification-protocol skill, including Macro-Specific Verification for hygiene, fragment type, and proc-macro performance claims. Then add findings.
Output Format
Report findings as:
[FILE:LINE] ISSUE_TITLE
Severity: Critical | Major | Minor | Informational
Description of the issue and why it matters.Quick Reference
| Issue Type | Reference |
|---|---|
Fragment types, repetition, hygiene boundaries (vars vs types), $crate paths, TT-muncher pattern + recursion limits, fragment-matcher follow restrictions, decl-vs-proc decision tree | references/declarative-macros.md |
Proc macro types, syn/quote, span hygiene (call_site vs def_site vs mixed_site), syn::Error::new_spanned + combine, parse_quote! vs quote!, syn feature audit, trybuild UI tests | references/procedural-macros.md |
Review Checklist
Declarative Macros (macro_rules!)
- [ ] Correct fragment types used (
:exprvs:ttvs:ident-- wrong choice causes unexpected parsing) - [ ] Repetition separators match intended syntax (
,vs;vs none,*vs+) - [ ] Trailing comma/semicolon handled (add
$(,)?or$(;)?at end of repetition) - [ ] Matchers ordered from most specific to least specific (first match wins)
- [ ] No ambiguous expansions -- each metavariable appears in the correct repetition depth in the transcriber
- [ ] Variables defined in the macro use macro-internal names (hygiene protects variables, not types/modules/functions)
- [ ] Exported macros (
#[macro_export]) use$crate::for crate-internal paths, nevercrate::orself:: - [ ] Standard library paths use
::core::and::alloc::(not::std::) forno_stdcompatibility - [ ]
compile_error!used for meaningful error messages on invalid input patterns - [ ] Macro placement respects textual scoping (defined before use) unless
#[macro_export]
Procedural Macros
- [ ]
synfeatures minimized (don't enablefullwhenderivesuffices -- reduces compile time) - [ ] Spans propagated from input tokens to output tokens (errors point to user code, not macro internals)
- [ ]
Span::mixed_site()is the default for introduced helper variables —call_siteonly when intentionally pointing at user code;def_siteis nightly-only onproc_macro::Span - [ ] Error reporting uses
syn::Error::new_spanned(node, msg)with the offending AST node, neverpanic! - [ ] Multiple errors collected and reported together via
syn::Error::combine(good UX vs first-failure) - [ ]
parse_quote!(notquote!) used when the result needs to be asyn::Tfor further AST manipulation;syn::parse2(quote!{...})for fallible cases - [ ] Public types re-exported from
proc_macro2, notproc_macro(compatibility with downstream consumers) - [ ]
proc-macro2used for testing (testable outside of compiler context) - [ ] Generated code volume is proportionate -- proc macros that emit large amounts of code bloat compile times
Derive Macros
- [ ] Derivation is obvious -- a developer could guess what it does from the trait name alone
- [ ] Helper attributes (
#[serde(skip)]style) are documented - [ ] Trait implementation is correct for all variant shapes (unit, tuple, struct variants)
- [ ] Generated
implblocks use fully qualified paths (::core::,$crate::)
Attribute Macros
- [ ] Input item is preserved or intentionally transformed (not accidentally dropped)
- [ ] Attribute arguments are validated with clear error messages
- [ ] Test generation patterns (
#[test_case]style) produce unique test names - [ ] Framework annotations document what code they generate
Edition 2024 Awareness
- [ ] Macro output does not use
genas an identifier (reserved keyword -- user#genor rename) - [ ] Generated
unsafe fnbodies use explicitunsafe {}blocks around unsafe ops - [ ] Generated
externblocks useunsafe extern
Generics vs Macros
Flag a macro when the same result is achievable with generics or trait bounds. Macros are appropriate when:
- The generated code varies structurally (not just by type)
- Repetitive trait impls for many concrete types
- Test batteries with configuration variants
- Compile-time computation that
const fncannot express
Severity Calibration
Critical (Block Merge)
- Macro generates unsound
unsafecode - Hygiene violation in macro that outputs
unsafeblocks (caller's variables leak into unsafe context) - Proc macro panics instead of returning
compile_error!(crashes the compiler) - Derive macro generates incorrect trait implementation (violates trait contract)
Major (Should Fix)
- Exported macro uses
crate::orself::instead of$crate::(breaks for downstream users) - Exported macro uses
::std::instead of::core::/::alloc::(breaksno_stdusers) - Wrong fragment type causing unexpected parsing (
:exprwhere:ttneeded, or vice versa) - Proc macro enables
synfull features unnecessarily (compile time cost) - Missing span propagation (errors point to macro definition, not invocation)
- No error handling in proc macro (panics on bad input instead of
compile_error!)
Minor (Consider Fixing)
- Missing trailing comma/semicolon tolerance in repetition patterns
- Matcher arms not ordered most-specific-first
- Macro used where generics would be clearer and equally expressive
- Missing
compile_error!fallback arm for invalid patterns - Helper attributes undocumented
Informational (Note Only)
- Suggestions to split complex
macro_rules!into a proc macro - Suggestions to reduce generated code volume
- TT munching or push-down accumulation patterns that could be simplified
Valid Patterns (Do NOT Flag)
- `macro_rules!` for test batteries -- Generating repetitive test modules from a list of types/configs
- `macro_rules!` for trait impls -- Implementing a trait for many concrete types with identical bodies
- TT munching -- Valid advanced pattern for recursive token processing
- Push-down accumulation -- Valid pattern for building output incrementally across recursive calls
- `#[macro_export]` with `$crate` -- Correct way to make macros usable outside the defining crate
- `Span::call_site()` for generated functions -- Intentionally making generated items visible to callers
- `syn::Error::to_compile_error()` -- Correct error reporting pattern in proc macros
- `trybuild` tests for proc macros -- Standard compile-fail testing approach
- Attribute macros on test functions -- Common pattern for test setup/teardown
- `compile_error!` in impossible match arms -- Good practice for catching invalid macro input
Declarative Macros
Fragment Types
Each fragment type constrains what tokens the matcher will accept. Using the wrong type causes confusing parse errors or overly greedy matching.
| Fragment | Matches | Use When |
|---|---|---|
:ident | Identifier (foo, my_var) | Naming generated items, variables, or modules |
:expr | Any expression (x + 1, foo()) | Values to compute or pass to functions |
:ty | Type (u32, Vec<String>) | Type parameters for generics or trait impls |
:tt | Single token tree (foo, (a + b)) | Catch-all when other fragments are too restrictive |
:path | Path (std::io::Error, crate::Foo) | Importing or referencing items |
:pat | Pattern (Some(x), 1..=5) | Match arms, let bindings |
:stmt | Statement (let x = 1;) | Injecting statements into generated blocks |
:item | Item (fn, struct, impl) | Generating top-level definitions |
:block | Block ({ ... }) | Function bodies, closures |
:meta | Attribute content (derive(Debug)) | Forwarding attributes |
:literal | Literal value (42, "hello") | Compile-time constants |
:vis | Visibility (pub, pub(crate), empty) | Controlling generated item visibility |
:lifetime | Lifetime ('a, 'static) | Lifetime-generic generated code |
Common Fragment Mistakes
`:expr` can be broader than intended -- It matches full expressions, which can make some macro arms too permissive. Prefer narrower fragments like :tt when you need stricter syntax boundaries.
`:ty` cannot be followed by `>` -- After matching a type, the parser cannot distinguish > as closing a generic vs part of an expression. Structure matchers to avoid this ambiguity.
`:pat` changed in edition 2021+ -- Now matches | patterns (e.g., A | B). Use :pat_param if you need the pre-2021 behavior that stops at |.
Repetition Syntax
// Zero or more, comma-separated
$($item:expr),*
// One or more, semicolon-separated
$($stmt:stmt);+
// Zero or more with trailing separator tolerance
$($item:expr),* $(,)?
// Nested repetition (for key-value pairs)
$($key:expr => $value:expr),*The separator goes between repetitions. To include a terminator after each repetition, place it inside the $():
// Semicolon AFTER each, not between:
$($key:expr => $value:expr;)*
// Expands: key1 => val1; key2 => val2;Common Patterns
Test Battery
Generate multiple test modules from a compact specification:
macro_rules! test_battery {
($($t:ty as $name:ident),* $(,)?) => {
$(
mod $name {
use super::*;
#[test]
fn basic() { run_test::<$t>(Default::default()) }
#[test]
fn edge_case() { run_test::<$t>(edge_value()) }
}
)*
}
}
test_battery!(u8 as u8_tests, u32 as u32_tests, i64 as i64_tests);Trait Impl for Many Types
macro_rules! impl_display_for_newtype {
($($t:ty),* $(,)?) => {
$(
impl ::core::fmt::Display for $t {
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
::core::fmt::Display::fmt(&self.0, f)
}
}
)*
}
}Note ::core::fmt::Display -- not std::fmt::Display. Exported macros must use fully qualified paths.
Counting (TT Munching)
Count items at compile time by recursively consuming tokens:
macro_rules! count {
() => { 0usize };
($head:tt $($tail:tt)*) => { 1usize + count!($($tail)*) };
}Push-Down Accumulation
Build output incrementally across recursive calls:
macro_rules! reverse {
([] $($reversed:tt)*) => { ($($reversed)*) };
([$head:tt $($tail:tt)*] $($reversed:tt)*) => {
reverse!([$($tail)*] $head $($reversed)*)
};
}
// reverse!([a b c]) => (c b a)Hygiene Rules
What IS Hygienic (Isolated)
Variables declared inside a macro exist in the macro's namespace. They cannot shadow or be shadowed by caller variables with the same name.
macro_rules! let_x {
($val:expr) => { let x = $val; };
}
let x = 1;
let_x!(2); // This `x` is in the macro's namespace
assert_eq!(x, 1); // Caller's `x` is unchangedWhat is NOT Hygienic (Shared)
Types, modules, and functions defined in a macro are visible at the call site. This is by design -- macros commonly generate impl blocks, modules, and functions.
macro_rules! make_greeter {
() => {
fn greet() -> &'static str { "hello" }
};
}
make_greeter!();
assert_eq!(greet(), "hello"); // Function is visible hereSharing Identifiers with the Caller
Pass identifiers in from the call site to affect caller scope:
macro_rules! set_var {
($var:ident, $val:expr) => { $var = $val; };
}
let mut x = 1;
set_var!(x, 42); // `x` originated at call site, so it refers to caller's `x`
assert_eq!(x, 42);Any identifier from an :expr or :ident passed by the caller resolves in the caller's scope.
Exported Macro Paths
For macros marked #[macro_export], all paths must be absolute and crate-independent:
// BAD -- breaks for downstream users
macro_rules! bad_macro {
($val:expr) => { crate::MyType::new($val) };
}
// BAD -- breaks in no_std
macro_rules! also_bad {
($val:expr) => { ::std::vec![$val] };
}
// GOOD
#[macro_export]
macro_rules! good_macro {
($val:expr) => { $crate::MyType::new($val) };
}
// GOOD -- no_std compatible
#[macro_export]
macro_rules! good_vec {
($val:expr) => { ::alloc::vec![$val] };
}Review Checklist
1. Are fragment types appropriate for what they match? (:expr not used where :tt is safer?) 2. Do repetitions handle trailing separators? ($(,)? at end) 3. Are matchers ordered most-specific-first? 4. Do exported macros use $crate:: and ::core::/::alloc::? 5. Is there a compile_error! fallback for invalid patterns? 6. Does the macro output avoid gen as an identifier (edition 2024)? 7. Could this macro be replaced with generics?
Hygiene Boundaries -- What Is Shared and What Is Local
Decl macros are partially hygienic. The split:
- Variables (`let`-bindings) are hygienic. A
let x = 1;introduced inside a macro lives in the macro's own namespace and cannot collide with the caller'sx. - Types, modules, functions, and macros are shared with the caller. They resolve in the caller's scope, which is why a macro emitting
Vec::new()fails when invoked in a scope withoutVecimported.
macro_rules! demo {
() => {
let x = 1; // hygienic -- own namespace
let _v = Vec::new();// shared -- needs Vec in caller scope
};
}The fix for the shared half: use absolute paths (::std::vec::Vec::new(), ::core::result::Result) or $crate::... for items defined in your own crate. Never rely on the caller having use for what your macro emits.
$crate and Absolute Paths
$crate resolves to the defining crate's root, regardless of what name the caller imported your crate under. A macro that emits $crate::MyType works when the caller wrote use my_lib as renamed; or extern crate my_lib as renamed;. A macro that emits my_lib::MyType breaks immediately under renaming, and crate::MyType resolves to the caller's crate root (almost never what you want).
For standard-library references, prefer ::core::... and ::alloc::... over ::std::.... The core and alloc crates are available in no_std contexts; std is not. A macro that hardcodes ::std::fmt::Display is unusable in no_std downstreams even when core::fmt::Display would have sufficed.
TT-Muncher Pattern
A TT-muncher recursively consumes one tt (or one chunk) per expansion until the input is empty. It is the escape hatch for syntax that no single fragment matcher captures cleanly.
// Recursion limit (default 128) hazard:
// every comma-separated arg costs one expansion.
macro_rules! sum {
() => { 0 };
($head:expr $(, $tail:expr)*) => {
$head + sum!($($tail),*)
};
}Default #![recursion_limit = "128"] caps input length. Raising the limit hides the real signal: at this scale, switch to a procedural macro (see procedural-macros.md). Decl macros are a syntactic tool, not a token-stream parser.
Fragment Matcher Types -- What Each Captures
- `tt` -- single token tree (one token, or a fully-balanced
()/[]/{}group). Most permissive; the only matcher usable in TT-munchers. - `expr` -- any expression. Follow restriction: can only be followed by
=>,,, or;. Usingexprwhere you later need<or:breaks the macro. - `pat` vs `pat_param` -- 2021+
patmatches top-level|(A | B);pat_paramkeeps the pre-2021 behavior. - `ty` -- a type expression. Cannot be followed by
<(parser cannot distinguish generic-open from less-than). - `ident` -- single identifier. Combine with
paste::paste!to synthesize new identifiers. - `path`, `lifetime`, `literal`, `item`, `stmt`, `block`, `meta`, `vis` -- as named.
- `expr_2021` -- explicit edition opt-in for new
exprbehavior (let-chains, etc.) when the surrounding crate is on an older edition.
The most common bug: reaching for expr because it "matches more" and then discovering follow-restriction blocks the next token.
When NOT to Write a Decl Macro -- Jon's Decision Tree
1. Need to compute something at compile time over fixed types? -> `const fn`. No macro infrastructure, full type checking. 2. Need different implementations per type with the same code body? -> generics with traits (see ../../rust-best-practices/references/generics-dispatch.md). 3. Need to abstract a syntactic pattern (vec![], println!, mini-DSL)? -> decl macro. 4. Need to inspect, parse, or transform syntax (derive, attribute, code generation from struct shape)? -> proc macro (see procedural-macros.md).
If you find yourself writing a TT-muncher to parse structured input, you have outgrown decl macros.
Additional Review Checks ([FILE:LINE] format)
- [FILE:LINE] MACRO_NON_DOLLAR_CRATE_PATH --
#[macro_export]macro references an item from the defining crate viamy_lib::Fooorcrate::Foo. Breaks under crate renaming. Replace with$crate::Foo. - [FILE:LINE] MACRO_HARDCODED_VEC_NEW -- Emits
Vec::new()orvec![]without a path. Downstream callers withoutVecin scope (orno_std) cannot use the macro. Use::std::vec::Vec::new()or::alloc::vec::Vec::new(). - [FILE:LINE] MACRO_STD_WHERE_CORE_EXISTS -- Emits
::std::fmt::Display/::std::mem::replacewhen::core::fmt::Display/::core::mem::replaceare equivalent. Locks the macro out ofno_stddownstream crates. - [FILE:LINE] TT_MUNCHER_NO_LIMIT_COMMENT -- Recursive
$head:tt $($tail:tt)*pattern with no comment naming therecursion_limithazard. At ~64 inputs the default limit becomes a problem; readers need to know. - [FILE:LINE] TT_MUNCHER_NEAR_LIMIT -- TT-muncher invoked with input lengths approaching
recursion_limit. Either raise the limit deliberately with a justifying comment or rewrite as a proc macro. - [FILE:LINE] EXPR_MATCHER_WRONG_FRAGMENT -- Matcher uses
$x:exprbut the macro body needs to follow$xwith a token outside=>/,/;(e.g.,$x:expr : $t:ty). Follow restriction makes the macro fail to parse. Use$x:ttand validate downstream, or restructure the pattern. - [FILE:LINE] DECL_MACRO_REIMPLEMENTS_CONST_FN -- Macro performs integer/string computation that a
const fnwould handle with full type checking and no expansion bloat. Replace withconst fn. - [FILE:LINE] DECL_MACRO_REIMPLEMENTS_GENERICS -- Macro varies only over types with identical code bodies. Replace with a generic function or trait impl; see ../../rust-best-practices/references/generics-dispatch.md.
- [FILE:LINE] DECL_MACRO_SHADOWS_CALLER_IDENTS -- Macro emits items (
fn,struct,mod) whose names match identifiers commonly used in caller code. Because items are not hygienic, this silently shadows. Either prefix names with a macro-specific tag or document the contract.
Procedural Macros
Three Types
| Type | Annotation | Behavior |
|---|---|---|
| Function-like | #[proc_macro] | fn(TokenStream) -> TokenStream -- replaces invocation |
| Attribute | #[proc_macro_attribute] | fn(attr, item) -> TokenStream -- replaces annotated item |
| Derive | #[proc_macro_derive(Trait)] | fn(TokenStream) -> TokenStream -- appends after item |
Attribute macro gotcha: The return replaces the item entirely. Forgetting to include the original item in the output deletes it silently.
Derive macro constraint: Cannot modify the annotated item. Output is appended. Helper attributes (#[my_helper(skip)]) are markers consumed by the derive, not independent macros.
Parsing with syn
Minimize features to reduce compile time:
# BAD -- enables everything, slow to compile
syn = { version = "2", features = ["full"] }
# GOOD -- only what derive macros need
syn = { version = "2", features = ["derive"] }Standard parsing pattern:
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(MyTrait)]
pub fn derive_my_trait(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
quote! {
impl MyTrait for #name {
fn method(&self) -> &'static str { ::core::stringify!(#name) }
}
}.into()
}Generating Code with quote
Interpolation with #var and repetition with #(#items)*:
let field_names: Vec<_> = fields.iter().map(|f| &f.ident).collect();
let tokens = quote! {
impl ::core::fmt::Debug for #name {
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
f.debug_struct(::core::stringify!(#name))
#(.field(::core::stringify!(#field_names), &self.#field_names))*
.finish()
}
}
};Span Handling
Spans tie generated tokens to source locations. Correct spans produce good error messages.
| Span | Resolution | Use For |
|---|---|---|
Span::call_site() | At macro invocation | Generated items visible to callers |
Span::mixed_site() | Variables at def site, types at call site | Private variables (matches macro_rules! hygiene) |
Span::def_site() | At macro definition | Unstable/nightly only |
Always propagate input spans to related output tokens:
// BAD -- error points to macro definition
let method = Ident::new("process", Span::call_site());
// GOOD -- error points to the user's field
let method = Ident::new(&format!("process_{}", field_name), field_name.span());Error Reporting
A proc macro that panics crashes the compiler. Use syn::Error with spans instead:
// BAD -- unhelpful ICE
panic!("MyTrait requires at least one field");
// GOOD -- compiler error pointing to the struct
return syn::Error::new_spanned(&input.ident, "requires at least one field")
.to_compile_error().into();Collect multiple errors with syn::Error::combine instead of returning on first failure:
let mut errors = Vec::new();
for field in &fields {
if !is_valid(field) {
errors.push(syn::Error::new_spanned(field, "invalid field type"));
}
}
if let Some(first) = errors.into_iter().reduce(|mut a, b| { a.combine(b); a }) {
return first.to_compile_error().into();
}Compile-Time Cost
1. Dependency weight -- syn with full features takes tens of seconds to compile. Minimize features. Compile proc-macro crates in debug mode (execution speed rarely matters).
2. Generated code volume -- The macro saves typing, not compiler work. Large quote! blocks repeated across many invocations bloat compile times.
Mitigation: minimize syn features, use proc-macro2 for testing, profile with cargo build --timings, prefer declarative macros for simpler cases.
Testing
`trybuild` -- Compile-fail tests with expected .stderr output:
#[test]
fn compile_tests() {
let t = trybuild::TestCases::new();
t.pass("tests/pass/*.rs");
t.compile_fail("tests/fail/*.rs");
}`proc-macro2` -- Unit tests outside the compiler context. Use proc_macro2::TokenStream and compare to_string() output.
`macrotest` -- Snapshot-based expansion tests. Expands macros and compares against committed snapshots with macrotest::expand("tests/expand/*.rs").
Common Patterns
Derive with Helper Attributes
#[proc_macro_derive(Builder, attributes(builder))]
pub fn derive_builder(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
// Look for #[builder(default)] on fields via field.attrs
}Attribute Macro for Test Generation
#[proc_macro_attribute]
pub fn test_with_db(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input_fn = parse_macro_input!(item as syn::ItemFn);
let fn_name = &input_fn.sig.ident;
let fn_body = &input_fn.block;
quote! {
#[test]
fn #fn_name() {
let db = setup_test_db();
let result = ::std::panic::catch_unwind(|| #fn_body);
teardown_test_db(db);
if let Err(e) = result { ::std::panic::resume_unwind(e); }
}
}.into()
}Review Checklist
1. Is syn configured with minimal features? 2. Do generated tokens carry spans from input tokens? 3. Does the macro use syn::Error (not panic!) for invalid input? 4. Are multiple errors collected and reported together? 5. Is the volume of generated code reasonable? 6. Are there trybuild or macrotest tests? 7. Does generated code use ::core::/::alloc:: paths for no_std compatibility? 8. Does the attribute macro preserve the input item? 9. Is Span::call_site() used only for intentionally public identifiers? 10. Does generated code avoid gen as an identifier (edition 2024)?
See also declarative-macros.md.
Span Hygiene in Proc-Macros
Three proc_macro2::Span constructors with distinct semantics. Choose deliberately:
- `Span::call_site()` — span of the macro invocation site (caller's location). Identifiers minted with this span resolve in the caller's scope. Use only when you want generated tokens to "look like the user wrote them" (errors pointing at user input, identifiers intended to respect the caller's namespace, items the user must be able to reference by name).
- `Span::def_site()` — span of the macro definition site. Identifiers resolve in the macro crate's scope. Use for helper items the macro introduces that must NOT clash with caller identifiers. Unstable on stable Rust (only fully usable on nightly
proc_macro::Span);proc_macro2::Span::def_site()falls back tocall_siteon stable. - `Span::mixed_site()` — a compromise matching
macro_rules!hygiene: variables are hygienic (resolve in macro scope), but types, modules, and macros resolve in caller scope. Default for most proc-macro work. Usemixed_siteby default; reach forcall_siteonly when explicitly pointing at user code.
use proc_macro2::Span;
use syn::Ident;
// BAD: helper variable in caller scope -- collides with caller's `tmp`
let helper = Ident::new("tmp", Span::call_site());
// GOOD: variable hygienic, types still resolve at caller
let helper = Ident::new("tmp", Span::mixed_site());
let ty: syn::Type = syn::parse_quote!(::core::option::Option<#ident>);Error Reporting via syn::Error
Point at user code with precise spans instead of panicking:
syn::Error::new(span, "...")— error at the given span.syn::Error::new_spanned(node, "...")— error spanning the entire AST node; prefer this when you have asyn::Field,syn::Variant,syn::Type.- Convert with
.to_compile_error()(returnsproc_macro2::TokenStream) or.into_compile_error()(consumes). syn::Erroris iterable —combine()multiple errors and emit them together for one-pass diagnostics.
let mut acc: Option<syn::Error> = None;
for field in fields.iter() {
if field_is_invalid(field) {
let e = syn::Error::new_spanned(field, "unsupported field type");
match acc { Some(ref mut a) => a.combine(e), None => acc = Some(e) }
}
}
if let Some(e) = acc { return e.to_compile_error().into(); }parse_quote! vs quote!
quote! produces a proc_macro2::TokenStream — the standard final return value. syn::parse_quote! produces a syn::T for any T: syn::parse::Parse (annotate the binding to drive inference). Use parse_quote! when subsequent code manipulates the result as a syn::Type, syn::Expr, syn::WhereClause, etc.
Pitfall: parse_quote! panics on parse failure. For fallible parses use syn::parse2(quote! { ... }) explicitly and handle the Result.
let where_clause: syn::WhereClause = syn::parse_quote!(where #ty: ::core::fmt::Debug);
let ts: proc_macro2::TokenStream = quote! { impl Trait for #name {} };Compile-Time Cost Drivers
Proc macros are the biggest contributor to slow Rust builds. Audit:
- `syn` feature flags —
features = ["full"]costs 30-50% more compile time than["derive"]or["parsing"]alone. Many crates pull["full"]reflexively; check what your macro actually parses. - Derive fan-out —
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]on 100 types is 600 macro expansions. Each expansion runs the proc-macro code AND produces tokens the compiler must parse and check. - Attribute macros on async fn —
tracing::instrument,tokio::main,async_traiteach expand the function body, growing AST size and multiplying downstream type-check work. - Switch to function-like when use is rare — if a derive is used once or twice in a crate, a function-like macro called explicitly avoids the derive-registration overhead.
trybuild for UI Tests
See ../../rust-testing-code-review/references/advanced-testing.md for full trybuild patterns. Key proc-macro caveat: .stderr outputs are rustc-version-sensitive. Pin a stable rustc in CI for trybuild jobs and use TRYBUILD=overwrite only on intentional rustc upgrades — never as a blanket fix for failing UI tests.
Additional Review Checks ([FILE:LINE] format)
- [FILE:LINE] CALL_SITE_FOR_HELPER_VAR — Proc macro mints a helper variable via
Ident::new("tmp", Span::call_site()). Caller with a same-named variable triggers ambiguity. UseSpan::mixed_site(). - [FILE:LINE] DEF_SITE_ON_STABLE — Proc macro uses
Span::def_site()fromproc_macro::Spanon stable rustc. Won't compile. Useproc_macro2::Span::mixed_site()untildef_sitestabilizes. - [FILE:LINE] PANIC_FOR_USER_ERROR — Proc macro uses
panic!/unwrap()/expect()on invalid user input. Crashes the compiler with an ICE and loses spans. Returnsyn::Error::new_spanned(node, "...").to_compile_error()instead. - [FILE:LINE] EARLY_RETURN_ON_FIRST_ERROR — Macro returns on the first
syn::Errorinstead of accumulating withcombine(). Users iterate compile-fix-compile-fix per error; combine and emit once. - [FILE:LINE] QUOTE_ASSIGNED_TO_SYN_TYPE —
let ty: syn::Type = quote! { ... };won't compile (quote!producesTokenStream). Usesyn::parse_quote!for the syn AST type, orsyn::parse2(quote!{...})for fallible parses. - [FILE:LINE] SYN_FEATURES_FULL_UNNEEDED —
syn = { version = "2", features = ["full"] }enabled when the macro only parsesDeriveInput. Switch tofeatures = ["derive"]to cut compile time 30-50%. - [FILE:LINE] DERIVE_WITHOUT_TRYBUILD — Derive macro ships without
trybuildcompile-fail tests. Error-message regressions and accepted-but-broken inputs go unnoticed. Addtests/ui/with paired.stderrfixtures. - [FILE:LINE] PROC_MACRO_TESTS_NIGHTLY_ONLY — Trybuild/UI tests pinned to
nightlyrustc only. Stable users hit different diagnostic wording; regressions surface as user bug reports. Run trybuild on the stable toolchain the crate supports. - [FILE:LINE] ATTR_MACRO_LOSES_BODY_SPAN — Attribute macro re-emits the user's
fnbody but rebuilds tokens without preserving the original span. Errors inside the body point at the macro's source, not the user's code. Useparse_quote_spanned!or propagateblock.span(). - [FILE:LINE] PROC_MACRO_REEXPORT_PROC_MACRO_TYPES — Macro re-exports
proc_macro::TokenStream/proc_macro::Spaninstead ofproc_macro2equivalents. Downstream consumers (e.g., other macros wrapping yours, unit tests) can't link againstproc_macrooutside a proc-macro crate. Useproc_macro2for shared APIs; convert at the entry point only.