Helper attributes by hand¶
Level: 301 · deep dive
One line: A derive names the attributes it reads — #[proc_macro_derive(CustomDebug, attributes(debug))] — and from then on the compiler accepts #[debug(...)] on the struct and its fields, checks nothing but the name, and leaves it in place for the derive to find in DeriveInput::attrs and Field::attrs and take apart with Attribute::parse_nested_meta.
A Debug that hides a password¶
#[derive(CustomDebug)] writes a Debug impl, like the built-in derive, except that the struct can shape it:
use custom_debug::CustomDebug;
#[derive(CustomDebug)]
#[debug(rename = "Account", mask = "<hidden>")]
struct User {
id: u64,
#[debug(rename = "email")]
email_address: String,
/// A doc comment is an attribute too; the derive has to step over it.
#[debug(redact)]
password: String,
#[debug(skip)]
failed_logins: u32,
}
#[derive(CustomDebug)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let user = User {
id: 7,
email_address: "ada@example.com".to_owned(),
password: "hunter2".to_owned(),
failed_logins: 3,
};
println!("{user:?}");
println!("{user:#?}");
println!(
"hidden, not gone: password has {} bytes, failed_logins = {}",
user.password.len(),
user.failed_logins
);
println!("{:?}", Point { x: 1, y: 2 });
}
Verified output of cargo run -q -p by_hand_app — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
Account { id: 7, email: "ada@example.com", password: <hidden>, .. }
Account {
id: 7,
email: "ada@example.com",
password: <hidden>,
..
}
hidden, not gone: password has 7 bytes, failed_logins = 3
Point { x: 1, y: 2 }
| Written on | Attribute | Form | Effect |
|---|---|---|---|
| the struct | rename = "Account" |
key = value | prints Account instead of User |
| the struct | mask = "<hidden>" |
key = value | what a redacted field prints; *** if absent |
| a field | rename = "email" |
key = value | the label instead of the field's name |
| a field | redact |
flag | the mask instead of the value |
| a field | skip |
flag | no entry at all, and a closing .. |
An attribute on the struct is a container attribute; one on a field is a field attribute. Point carries neither and gets the plain format, and {:#?} works for free because the generated code goes through Formatter::debug_struct ↗, which handles both.
The macro: declare, read, generate¶
lib.rs declares the derive and its helper, and writes the impl:
//! `#[derive(CustomDebug)]`: a `Debug` impl that the struct can shape with
//! `#[debug(...)]` attributes. This file declares the macro and writes the
//! `impl`; reading the attributes is `attrs.rs`'s job.
mod attrs;
use proc_macro::TokenStream;
use quote::quote;
use syn::{DeriveInput, parse_macro_input};
/// `attributes(debug)` is the declaration: it lets `#[debug(...)]` appear on
/// the struct and on its fields, and hands it to this function untouched.
#[proc_macro_derive(CustomDebug, attributes(debug))]
pub fn derive_custom_debug(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
match attrs::Container::parse(&input) {
Ok(container) => expand(&container).into(),
Err(error) => error.to_compile_error().into(),
}
}
/// `f.debug_struct(name).field(..)...finish()`, one `.field` per shown field.
/// Generics are ignored here; `generics_lifetimes_and_where` adds them.
fn expand(container: &attrs::Container) -> proc_macro2::TokenStream {
let ident = &container.ident;
let name = container
.rename
.clone()
.unwrap_or_else(|| ident.to_string());
let mask = &container.mask;
let shown = container.fields.iter().filter(|field| !field.skip);
let calls = shown.map(|field| {
let member = &field.ident;
let label = field.rename.clone().unwrap_or_else(|| member.to_string());
if field.redact {
quote!(.field(#label, &::core::format_args!("{}", #mask)))
} else {
quote!(.field(#label, &self.#member))
}
});
// A skipped field still exists, so say so: `finish_non_exhaustive` prints `..`.
let finish = if container.fields.iter().any(|field| field.skip) {
quote!(finish_non_exhaustive)
} else {
quote!(finish)
};
quote! {
impl ::core::fmt::Debug for #ident {
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
f.debug_struct(#name) #(#calls)* .#finish()
}
}
}
}
attrs.rs does the reading:
//! Reading `#[debug(...)]` by hand: pick our attributes out of `attrs`, walk
//! each one with `parse_nested_meta`, and refuse any key we do not know.
use syn::{Attribute, Data, DeriveInput, Error, Fields, Ident, LitStr, Result};
/// What `#[debug(...)]` on the struct asked for.
pub struct Container {
pub ident: Ident,
pub rename: Option<String>,
pub mask: String,
pub fields: Vec<Field>,
}
/// What `#[debug(...)]` on one field asked for.
pub struct Field {
pub ident: Ident,
pub rename: Option<String>,
pub skip: bool,
pub redact: bool,
}
impl Container {
pub fn parse(input: &DeriveInput) -> Result<Self> {
let mut rename = None;
let mut mask = "***".to_owned();
for attr in debug_attrs(&input.attrs) {
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("rename") {
rename = Some(meta.value()?.parse::<LitStr>()?.value());
} else if meta.path.is_ident("mask") {
mask = meta.value()?.parse::<LitStr>()?.value();
} else {
return Err(meta.error("unknown key: expected `rename` or `mask`"));
}
Ok(())
})?;
}
let Data::Struct(data) = &input.data else {
return Err(Error::new_spanned(
&input.ident,
"CustomDebug needs a struct",
));
};
let Fields::Named(named) = &data.fields else {
return Err(Error::new_spanned(
&data.fields,
"CustomDebug needs named fields",
));
};
let fields = named
.named
.iter()
.map(Field::parse)
.collect::<Result<_>>()?;
let ident = input.ident.clone();
Ok(Container {
ident,
rename,
mask,
fields,
})
}
}
impl Field {
fn parse(field: &syn::Field) -> Result<Self> {
let ident = field.ident.clone().expect("named fields have names");
let (mut rename, mut skip, mut redact) = (None, false, false);
for attr in debug_attrs(&field.attrs) {
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("rename") {
rename = Some(meta.value()?.parse::<LitStr>()?.value());
} else if meta.path.is_ident("skip") {
skip = true;
} else if meta.path.is_ident("redact") {
redact = true;
} else {
return Err(meta.error("unknown key: expected `rename`, `skip` or `redact`"));
}
Ok(())
})?;
}
Ok(Field {
ident,
rename,
skip,
redact,
})
}
}
/// Only `#[debug(...)]`. The same list holds doc comments, `#[allow]`, and the
/// helper attributes of every other derive on the struct.
fn debug_attrs(attrs: &[Attribute]) -> impl Iterator<Item = &Attribute> {
attrs.iter().filter(|attr| attr.path().is_ident("debug"))
}
- Where the attributes are. Container attributes are in
input.attrs, a field's infield.attrs. Each list holds every outer attribute on that item: the doc comment onpasswordis in there as#[doc = "..."](see comments that compile), and so would be#[allow(...)]or a#[serde(...)]from another derive.debug_attrskeeps only those whose path isdebug. - What
parse_nested_metadoes. It requires the#[debug(...)]form, splits the contents at top-level commas, and calls the closure once per item withmeta.pathset to the item's leading name. The closure decides what may follow that name: - a flag (
skip) is the name alone, so the closure only records it; - a key = value pair (
rename = "email") callsmeta.value()?, which consumes the=and returns the input positioned at the value, and.parse::<LitStr>()?then demands a string literal; - a nested list (
with(a, b), not used here) would callmeta.parse_nested_metaagain on the parenthesised part. - Refusing a key.
meta.error(msg)builds asyn::Error↗ whose span covers the key, so the user's compiler error underlines their typo rather than the derive. The compiler never looks inside the parentheses, so without thatelsebranch a misspelled flag such asskippwould be accepted in silence. - Reporting it.
derive_custom_debugturns the error into tokens withto_compile_error()and returns those instead of animpl. Errors: frompanic!tosyn::Errorcompares that with panicking.
This derive ignores generics, to keep the attribute code in view: on struct User<T> it would write impl Debug for User, which does not compile. Generics, lifetimes and where records that failure and fixes it.
What the compiler kept¶
cargo expand -p by_hand_app, from the separately installed cargo-expand, shows what the derive wrote. The key below was recorded with the compiler command that tool wraps, named in the caption:
Verified output of cargo rustc -q -p by_hand_app --profile=check -- -Zunpretty=expanded — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
#![feature(prelude_import)]
extern crate std;
#[prelude_import]
use std::prelude::rust_2024::*;
use custom_debug::CustomDebug;
#[debug(rename = "Account", mask = "<hidden>")]
struct User {
id: u64,
#[debug(rename = "email")]
email_address: String,
/// A doc comment is an attribute too; the derive has to step over it.
#[debug(redact)]
password: String,
#[debug(skip)]
failed_logins: u32,
}
impl ::core::fmt::Debug for User {
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
f.debug_struct("Account").field("id",
&self.id).field("email",
&self.email_address).field("password",
&format_args!("{0}", "<hidden>")).finish_non_exhaustive()
}
}
struct Point {
x: i32,
y: i32,
}
impl ::core::fmt::Debug for Point {
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
f.debug_struct("Point").field("x",
&self.x).field("y", &self.y).finish()
}
}
fn main() {
let user =
User {
id: 7,
email_address: "ada@example.com".to_owned(),
password: "hunter2".to_owned(),
failed_logins: 3,
};
{ ::std::io::_print(format_args!("{0:?}\n", user)); };
{ ::std::io::_print(format_args!("{0:#?}\n", user)); };
{
::std::io::_print(format_args!("hidden, not gone: password has {0} bytes, failed_logins = {1}\n",
user.password.len(), user.failed_logins));
};
{ ::std::io::_print(format_args!("{0:?}\n", Point { x: 1, y: 2 })); };
}
#[derive(CustomDebug)]is gone, and theimplsits after each struct.- Every
#[debug(...)]is still on the struct and its fields. A helper attribute is inert: the derive reads it, the compiler removes nothing, and nothing else acts on it. - The redacted field never touches
self.password; theimplprintsformat_args!("{0}", "<hidden>")in its place. That is whymainreads the password's length itself: otherwise rustc warns that the field is never read. failed_loginsappears nowhere in theimpl, andfinish_non_exhaustive()prints the..that says a field was left out (DebugStruct::finish_non_exhaustive↗).
Refusals¶
An attribute nobody declared¶
#[debug(...)] is a name only because a derive declared it. std's #[derive(Debug)] declares no helper attributes:
#[derive(Debug)] // std's derive declares no helper attributes
struct User {
id: u64,
#[debug(skip)]
password: String,
}
fn main() {
let user = User {
id: 7,
password: "hunter2".to_owned(),
};
println!("{user:?}");
}
Verified output of cargo build -q -p undeclared_helper, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
error: cannot find attribute `debug` in this scope
--> undeclared_helper/src/main.rs:4:7
|
4 | #[debug(skip)]
| ^^^^^
error: could not compile `undeclared_helper` (bin "undeclared_helper") due to 1 previous error
A helper above its derive¶
The helper has to come after the #[derive(...)] that introduces it:
use custom_debug::CustomDebug;
#[debug(rename = "Account")] // above the derive that declares it
#[derive(CustomDebug)]
struct User {
id: u64,
}
fn main() {
println!("{:?}", User { id: 7 });
}
Verified output of cargo build -q -p helper_before_derive, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
error: derive helper attribute is used before it is introduced
--> helper_before_derive/src/main.rs:3:3
|
3 | #[debug(rename = "Account")] // above the derive that declares it
| ^^^^^
4 | #[derive(CustomDebug)]
| ----------- the attribute is introduced here
|
= warning: this was previously accepted by the compiler but is being phased out; it will become a hard error in a future release!
= note: for more information, see issue #79202 <https://github.com/rust-lang/rust/issues/79202>
= note: `#[deny(legacy_derive_helpers)]` (part of `#[deny(future_incompatible)]`) on by default
error: could not compile `helper_before_derive` (bin "helper_before_derive") due to 2 previous errors
The note calls it "previously accepted" and a future hard error, but on 1.98.0 the build already fails: legacy_derive_helpers is a lint set to deny. With the derive first, as in by_hand_app, the same attribute compiles.
A mistake inside the attribute¶
Three mistakes, which only the derive can see:
use custom_debug::CustomDebug;
#[derive(CustomDebug)]
#[debug(rename = "Account", rename = "User")] // the same key twice
pub struct User {
#[debug(skipp)] // a typo
pub id: u64,
#[debug(rename = 5)] // a number where a string belongs
pub password: String,
}
fn main() {}
Verified output of cargo build -q -p by_hand_mistakes, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
error: unknown key: expected `rename`, `skip` or `redact`
--> by_hand_mistakes/src/main.rs:6:13
|
6 | #[debug(skipp)] // a typo
| ^^^^^
error: could not compile `by_hand_mistakes` (bin "by_hand_mistakes") due to 1 previous error
One error for three mistakes, and each missing one is explained by attrs.rs:
- The duplicate
renamewas accepted. The container attribute is read before any field, so it was reached, and the secondrenameoverwrote the first. Nothing in the code counts keys. rename = 5was never read. The?afterparse_nested_metareturned atskipp, before the next field. Collecting every error means building them into one withsyn::Error::combine↗ instead of returning early.- The typo is underlined exactly, because
meta.errorspans the key.
Real derives write that extra code. serde_derive 1.0.229 ↗ reads #[serde(...)] with the same parse_nested_meta loop and the same else branch ("unknown serde container attribute", line 496 ↗), and adds two things this page lacks: a wrapper that reports "duplicate serde attribute" (line 45 ↗), and a context that stores each error and moves on to the next attribute.
syn 1, 2 and 3¶
A tutorial that calls attr.parse_meta() and matches on NestedMeta was written for syn 1: both are in syn 1.0.109, and 2.0.119 has neither. parse_nested_meta replaced them, and syn 3 did not change it — attr.rs and meta.rs are byte-identical in syn 2.0.119 and 3.0.6. If a tutorial's parse_meta does not compile, that is why.
What this costs¶
Five keys took this much code. The next page reads the same five with darling, and its counting program measures both crates:
Verified output of cargo run -q -p darling_line_counts — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
file version lines code
attrs.rs by hand 96 84
attrs.rs darling 28 20
lib.rs by hand 54 42
lib.rs darling 56 44
darling_app is by_hand_app, unchanged: true
darling_mistakes is by_hand_mistakes, unchanged: true
The size is not the only cost. Every key is a branch in a closure; the list of valid keys in each error message is a string that has to be kept in step with those branches by hand; and duplicates, several errors at once, and a "did you mean" are each more code.
If you are coming from another language¶
- Python. A dataclass field's
field(repr=False)leaves it out of__repr__, which is#[debug(skip)]exactly: per-field metadata that the class decorator reads. The difference is who checks it.field()is an ordinary function, so a misspelled keyword raisesTypeErrorwhen the class is defined. The contents of#[debug(...)]are unchecked tokens until the derive reads them, and a key the derive does not refuse is ignored — thatelsebranch is doing the job Python's function signature does. - Java.
@JsonProperty("email")and@JsonIgnorein Jackson, or Lombok's@ToString.Exclude, are the same idea: annotations on fields, read by a processor. But a Java annotation is a declared type.@interface Debug { String rename() default ""; }tellsjavacwhich elements exist and their types, so a misspelled element or a missing required one is a compiler error before any processor runs. A Rust helper attribute declares only its name; everything inside the parentheses is the derive's to check. - C and C++.
[[nodiscard]]and[[deprecated("reason")]]are attributes the compiler reads for itself. One it does not recognise is ignored with a warning — clang prints "unknown attribute ... ignored" and compiles anyway — which is the silent typo this page'selsebranch exists to prevent, moved from the compiler to the derive. - ABAP. (Not machine-checked — CI cannot run ABAP.) CDS annotations such as
@UI.hidden: trueor@EndUserText.label: 'Email'are the closest match: metadata on an entity or one of its elements, which a framework reads to decide what the user sees. The difference is when the reader runs. A Fiori app reads the annotations long after the view was activated; a derive reads its helper attributes while your crate compiles, so a wrong key can be a compiler error rather than a field that quietly stays visible.
See also¶
- Three kinds of procedural macro — the
attributes(...)list in the declarations ofserde,thiserrorandclap - What an attribute is — outer and inner attributes, and the families the compiler acts on itself
- Helper attributes with
darling— the same five keys, declared as a struct - Errors: from
panic!tosyn::Error— the errors an unknown key should produce - Expanding
thiserror—#[error("...")]and#[from]are helper attributes too - The Rust Reference: derive macro helper attributes ↗
Attribute::parse_nested_metain syn 3.0.6 ↗
Po polsku¶
Atrybut pomocniczy (helper attribute) to atrybut, który makro derive deklaruje w #[proc_macro_derive(CustomDebug, attributes(debug))]. Od tej chwili kompilator przyjmuje #[debug(...)] na strukturze (atrybut kontenera, container attribute) i na jej polach (atrybut pola, field attribute), sprawdza tylko nazwę i zostawia atrybut na miejscu — w rozwinięciu makra nadal tam jest. Treść w nawiasach czyta dopiero makro: z DeriveInput::attrs i Field::attrs wybiera atrybuty o ścieżce debug (ta sama lista zawiera też komentarze dokumentacyjne) i rozbiera je metodą Attribute::parse_nested_meta: flaga (skip) to sama nazwa, para klucz = wartość (rename = "email") wymaga meta.value()?, a nieznany klucz trzeba odrzucić błędem meta.error(...), który podkreśla literówkę — inaczej zostanie po cichu zignorowany.
Pułapki: atrybut bez deklaracji daje błąd cannot find attribute, atrybut nad #[derive] zamiast pod nim łamie w 1.98.0 kompilację (lint legacy_derive_helpers ma poziom deny), a kod pisany ręcznie zgłasza tylko pierwszy błąd i nie wykrywa powtórzonego klucza. W poradnikach dla syn 1 spotkasz parse_meta() i NestedMeta — w syn 2 i 3 ich nie ma.
Szukaj po polsku: atrybuty pomocnicze makr derive · parse_nested_meta · rust derive helper attributes · syn parse_nested_meta example · legacy_derive_helpers