Skip to content

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:

demo/by_hand_app/src/main.rs
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:

demo/by_hand_derive/src/lib.rs
//! `#[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:

demo/by_hand_derive/src/attrs.rs
//! 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 in field.attrs. Each list holds every outer attribute on that item: the doc comment on password is in there as #[doc = "..."] (see comments that compile), and so would be #[allow(...)] or a #[serde(...)] from another derive. debug_attrs keeps only those whose path is debug.
  • What parse_nested_meta does. It requires the #[debug(...)] form, splits the contents at top-level commas, and calls the closure once per item with meta.path set 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") calls meta.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 call meta.parse_nested_meta again on the parenthesised part.
  • Refusing a key. meta.error(msg) builds a syn::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 that else branch a misspelled flag such as skipp would be accepted in silence.
  • Reporting it. derive_custom_debug turns the error into tokens with to_compile_error() and returns those instead of an impl. Errors: from panic! to syn::Error compares 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 the impl sits 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; the impl prints format_args!("{0}", "<hidden>") in its place. That is why main reads the password's length itself: otherwise rustc warns that the field is never read.
  • failed_logins appears nowhere in the impl, and finish_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:

demo/undeclared_helper/src/main.rs
#[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:

demo/helper_before_derive/src/main.rs
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:

demo/by_hand_mistakes/src/main.rs
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 rename was accepted. The container attribute is read before any field, so it was reached, and the second rename overwrote the first. Nothing in the code counts keys.
  • rename = 5 was never read. The ? after parse_nested_meta returned at skipp, before the next field. Collecting every error means building them into one with syn::Error::combine ↗ instead of returning early.
  • The typo is underlined exactly, because meta.error spans 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 raises TypeError when 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 — that else branch is doing the job Python's function signature does.
  • Java. @JsonProperty("email") and @JsonIgnore in 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 ""; } tells javac which 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's else branch exists to prevent, moved from the compiler to the derive.
  • ABAP. (Not machine-checked — CI cannot run ABAP.) CDS annotations such as @UI.hidden: true or @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

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