use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{Data, DeriveInput, Fields, parse_macro_input};
#[derive(Clone, Copy)]
enum FieldRedaction {
Url,
Key,
Raw,
}
#[proc_macro_derive(RedactingDebug, attributes(redact, raw))]
pub fn derive_redacting_debug(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
expand(&input)
.unwrap_or_else(syn::Error::into_compile_error)
.into()
}
fn expand(input: &DeriveInput) -> syn::Result<TokenStream2> {
let Data::Struct(data) = &input.data else {
return Err(syn::Error::new_spanned(
input,
"RedactingDebug only supports structs with named fields (enums are not supported \
— see issue #1238's Out of Scope; they need per-variant redaction logic)",
));
};
let Fields::Named(fields) = &data.fields else {
return Err(syn::Error::new_spanned(
&data.fields,
"RedactingDebug requires named fields (tuple structs and unit structs are not \
supported in this version — see issue #1238's Out of Scope)",
));
};
if fields.named.is_empty() {
return Err(syn::Error::new_spanned(
fields,
"RedactingDebug requires at least one named field — a zero-field struct has \
nothing to redact; use a hand-written `impl Debug` instead",
));
}
let mut errors: Vec<syn::Error> = Vec::new();
let mut field_tokens = Vec::new();
for field in &fields.named {
let Some(field_ident) = &field.ident else {
continue;
};
match field_redaction_kind(field) {
Ok(kind) => {
let name = field_ident.to_string();
field_tokens.push(match kind {
FieldRedaction::Url => quote! {
.field(#name, &::deps_core::redact_debug::__redact_url_field(&self.#field_ident))
},
FieldRedaction::Key => quote! {
.field(#name, &::deps_core::redact_debug::__redact_key_field(&self.#field_ident))
},
FieldRedaction::Raw => quote! {
.field(#name, &self.#field_ident)
},
});
}
Err(e) => errors.push(e),
}
}
if let Some(combined) = errors.into_iter().reduce(|mut acc, e| {
acc.combine(e);
acc
}) {
return Err(combined);
}
let ident = &input.ident;
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
let name_str = ident.to_string();
Ok(quote! {
#[automatically_derived]
impl #impl_generics ::std::fmt::Debug for #ident #ty_generics #where_clause {
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
f.debug_struct(#name_str)
#(#field_tokens)*
.finish()
}
}
})
}
fn field_redaction_kind(field: &syn::Field) -> syn::Result<FieldRedaction> {
let mut found: Option<FieldRedaction> = None;
for attr in &field.attrs {
if attr.path().is_ident("raw") {
record_redaction(&mut found, FieldRedaction::Raw, attr)?;
} else if attr.path().is_ident("redact") {
let mut kind = None;
attr.parse_nested_meta(|meta| {
let this_kind = if meta.path.is_ident("url") {
FieldRedaction::Url
} else if meta.path.is_ident("key") {
FieldRedaction::Key
} else {
return Err(meta.error("expected `url` or `key` inside `#[redact(...)]`"));
};
if kind.is_some() {
return Err(
meta.error("`#[redact(...)]` accepts exactly one of `url`/`key`, not both")
);
}
kind = Some(this_kind);
Ok(())
})?;
let Some(kind) = kind else {
return Err(syn::Error::new_spanned(
attr,
"`#[redact(...)]` requires `url` or `key`, e.g. `#[redact(url)]`",
));
};
record_redaction(&mut found, kind, attr)?;
}
}
found.ok_or_else(|| {
let field_name = field
.ident
.as_ref()
.map_or_else(|| "<field>".to_string(), ToString::to_string);
syn::Error::new_spanned(
field,
format!(
"field `{field_name}` must carry exactly one of `#[redact(url)]`, \
`#[redact(key)]`, or `#[raw]` (RedactingDebug, #1238)"
),
)
})
}
fn record_redaction(
found: &mut Option<FieldRedaction>,
kind: FieldRedaction,
attr: &syn::Attribute,
) -> syn::Result<()> {
if found.is_some() {
return Err(syn::Error::new_spanned(
attr,
"field carries more than one of `#[redact(url)]`, `#[redact(key)]`, `#[raw]` — \
exactly one is required",
));
}
*found = Some(kind);
Ok(())
}