Skip to main content

telemetry_safe_derive/
lib.rs

1use proc_macro::TokenStream;
2use quote::quote;
3use syn::parse_macro_input;
4use syn::spanned::Spanned;
5use syn::{
6    Attribute, Data, DataEnum, DataStruct, DeriveInput, Error, Expr, Fields, LitStr, Result, Token,
7};
8
9#[proc_macro_derive(ToTelemetry, attributes(telemetry))]
10pub fn derive_to_telemetry(input: TokenStream) -> TokenStream {
11    let input = parse_macro_input!(input as DeriveInput);
12    match expand_derive(&input) {
13        Ok(tokens) => tokens.into(),
14        Err(err) => err.to_compile_error().into(),
15    }
16}
17
18fn expand_derive(input: &DeriveInput) -> Result<proc_macro2::TokenStream> {
19    let ident = &input.ident;
20    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
21
22    let body = match &input.data {
23        Data::Struct(data) => expand_struct(ident, data)?,
24        Data::Enum(data) => expand_enum(data)?,
25        Data::Union(data) => {
26            return Err(Error::new(
27                data.union_token.span(),
28                "ToTelemetry cannot be derived for unions",
29            ));
30        }
31    };
32
33    Ok(quote! {
34        impl #impl_generics ::telemetry_safe::ToTelemetry for #ident #ty_generics #where_clause {
35            fn fmt_telemetry(
36                &self,
37                f: &mut ::std::fmt::Formatter<'_>,
38            ) -> ::std::fmt::Result {
39                #body
40            }
41        }
42    })
43}
44
45fn expand_struct(ident: &syn::Ident, data: &DataStruct) -> Result<proc_macro2::TokenStream> {
46    match &data.fields {
47        Fields::Named(fields) => {
48            let mut field_exprs = Vec::new();
49            for field in &fields.named {
50                let attr = parse_field_attr(&field.attrs).transpose()?;
51                if matches!(attr, Some(FieldAttr::Skip)) {
52                    continue;
53                }
54
55                let name = field.ident.as_ref().expect("named field");
56                let key = LitStr::new(&name.to_string(), name.span());
57                let value = field_expr(field, quote! { self.#name }, attr)?;
58                field_exprs.push(quote! {
59                    ds.field(#key, &#value);
60                });
61            }
62
63            Ok(quote! {
64                let mut ds = f.debug_struct(stringify!(#ident));
65                #(#field_exprs)*
66                ds.finish()
67            })
68        }
69        Fields::Unnamed(fields) => {
70            let mut field_exprs = Vec::new();
71            for (index, field) in fields.unnamed.iter().enumerate() {
72                let attr = parse_field_attr(&field.attrs).transpose()?;
73                if matches!(attr, Some(FieldAttr::Skip)) {
74                    continue;
75                }
76
77                let accessor = syn::Index::from(index);
78                let value = field_expr(field, quote! { self.#accessor }, attr)?;
79                field_exprs.push(quote! {
80                    ds.field(&#value);
81                });
82            }
83
84            Ok(quote! {
85                let mut ds = f.debug_tuple(stringify!(#ident));
86                #(#field_exprs)*
87                ds.finish()
88            })
89        }
90        Fields::Unit => Ok(quote! {
91            f.write_str(stringify!(#ident))
92        }),
93    }
94}
95
96fn expand_enum(data: &DataEnum) -> Result<proc_macro2::TokenStream> {
97    let arms = data
98        .variants
99        .iter()
100        .map(|variant| {
101            let ident = &variant.ident;
102            match &variant.fields {
103                Fields::Named(fields) => {
104                    let mut bindings = Vec::new();
105                    let mut formatter = Vec::new();
106                    for field in &fields.named {
107                        let attr = parse_field_attr(&field.attrs).transpose()?;
108                        let name = field.ident.as_ref().expect("named field");
109
110                        if matches!(attr, Some(FieldAttr::Skip)) {
111                            bindings.push(quote! { #name: _ });
112                            continue;
113                        }
114
115                        if !field_attr_requires_binding(attr.as_ref()) {
116                            // Fields that never read the matched value must bind `_`,
117                            // otherwise enum patterns leak `unused variable` warnings
118                            // into downstream crates despite being intentionally ignored.
119                            bindings.push(quote! { #name: _ });
120                        } else {
121                            bindings.push(quote! { #name });
122                        }
123
124                        let key = LitStr::new(&name.to_string(), name.span());
125                        let value = field_expr(field, quote! { #name }, attr)?;
126                        formatter.push(quote! {
127                            ds.field(#key, &#value);
128                        });
129                    }
130
131                    Ok(quote! {
132                        Self::#ident { #(#bindings),* } => {
133                            let mut ds = f.debug_struct(stringify!(#ident));
134                            #(#formatter)*
135                            ds.finish()
136                        }
137                    })
138                }
139                Fields::Unnamed(fields) => {
140                    let mut bindings = Vec::new();
141                    let mut formatter = Vec::new();
142                    for (index, field) in fields.unnamed.iter().enumerate() {
143                        let attr = parse_field_attr(&field.attrs).transpose()?;
144                        let binding = syn::Ident::new(&format!("field_{index}"), ident.span());
145
146                        if matches!(attr, Some(FieldAttr::Skip)) {
147                            bindings.push(quote! { _ });
148                            continue;
149                        }
150
151                        if !field_attr_requires_binding(attr.as_ref()) {
152                            bindings.push(quote! { _ });
153                        } else {
154                            bindings.push(quote! { #binding });
155                        }
156
157                        let value = field_expr(field, quote! { #binding }, attr)?;
158                        formatter.push(quote! {
159                            ds.field(&#value);
160                        });
161                    }
162
163                    Ok(quote! {
164                        Self::#ident(#(#bindings),*) => {
165                            let mut ds = f.debug_tuple(stringify!(#ident));
166                            #(#formatter)*
167                            ds.finish()
168                        }
169                    })
170                }
171                Fields::Unit => Ok(quote! {
172                    Self::#ident => f.write_str(stringify!(#ident))
173                }),
174            }
175        })
176        .collect::<Result<Vec<_>>>()?;
177
178    Ok(quote! {
179        match self {
180            #(#arms),*
181        }
182    })
183}
184
185fn field_expr(
186    field: &syn::Field,
187    accessor: proc_macro2::TokenStream,
188    attr: Option<FieldAttr>,
189) -> Result<proc_macro2::TokenStream> {
190    match attr {
191        Some(FieldAttr::Literal(literal)) => Ok(quote! {
192            ::std::format_args!("{}", #literal)
193        }),
194        Some(FieldAttr::Display(format)) => {
195            // `display` is spelled out in the syntax because it trusts the
196            // field's Display impl as an explicit escape hatch.
197            match format {
198                DisplayFormat::Implicit => Ok(quote! {
199                    ::std::format_args!("{}", #accessor)
200                }),
201                DisplayFormat::Interpolated(format) => Ok(quote! {
202                    ::std::format_args!(#format, #accessor)
203                }),
204            }
205        }
206        Some(FieldAttr::Skip) | None => {
207            let ty = &field.ty;
208            Ok(quote! {{
209                let value: &#ty = &#accessor;
210                ::telemetry_safe::telemetry_debug(value)
211            }})
212        }
213    }
214}
215
216enum FieldAttr {
217    Literal(LitStr),
218    Display(DisplayFormat),
219    Skip,
220}
221
222enum DisplayFormat {
223    Implicit,
224    Interpolated(LitStr),
225}
226
227fn field_attr_requires_binding(attr: Option<&FieldAttr>) -> bool {
228    !matches!(attr, Some(FieldAttr::Skip | FieldAttr::Literal(_)))
229}
230
231fn parse_field_attr(attrs: &[Attribute]) -> Option<Result<FieldAttr>> {
232    attrs
233        .iter()
234        .find(|attr| attr.path().is_ident("telemetry"))
235        .map(parse_single_field_attr)
236}
237
238fn parse_single_field_attr(attr: &Attribute) -> Result<FieldAttr> {
239    attr.parse_args_with(|input: syn::parse::ParseStream<'_>| {
240        if input.peek(syn::Ident) {
241            let ident: syn::Ident = input.parse()?;
242            if ident == "skip" {
243                if !input.is_empty() {
244                    return Err(input.error("unexpected tokens after skip"));
245                }
246                return Ok(FieldAttr::Skip);
247            }
248
249            if ident == "display" {
250                if input.is_empty() {
251                    return Ok(FieldAttr::Display(DisplayFormat::Implicit));
252                }
253
254                let _eq: Token![=] = input.parse()?;
255                let format: LitStr = input.parse()?;
256                if !input.is_empty() {
257                    return Err(input.error("unexpected tokens after display format"));
258                }
259
260                return Ok(FieldAttr::Display(parse_display_format(format)?));
261            }
262
263            return Err(Error::new(
264                ident.span(),
265                "unsupported telemetry attribute; expected `skip`, `display`, or a string literal",
266            ));
267        }
268
269        let format: Expr = input.parse()?;
270        if !input.is_empty() {
271            let _comma: Token![,] = input.parse()?;
272            if !input.is_empty() {
273                return Err(input.error("expected a single format string or `skip`"));
274            }
275        }
276
277        match format {
278            Expr::Lit(expr_lit) => match expr_lit.lit {
279                syn::Lit::Str(lit) => Ok(FieldAttr::Literal(parse_literal_format(lit)?)),
280                other => Err(Error::new(other.span(), "expected string literal")),
281            },
282            other => Err(Error::new(other.span(), "expected string literal")),
283        }
284    })
285}
286
287fn parse_literal_format(format: LitStr) -> Result<LitStr> {
288    let value = format.value();
289    if value.contains(['{', '}']) {
290        return Err(Error::new(
291            format.span(),
292            "string literal telemetry formats cannot contain `{` or `}`; use `display` to opt into Display formatting",
293        ));
294    }
295
296    Ok(format)
297}
298
299fn parse_display_format(format: LitStr) -> Result<DisplayFormat> {
300    let value = format.value();
301    let placeholder_count = value.matches("{}").count();
302
303    // Keep `display = ...` narrow: one Display placeholder plus fixed text.
304    if value.replace("{}", "").contains(['{', '}']) {
305        return Err(Error::new(
306            format.span(),
307            "display format must contain exactly one `{}` placeholder",
308        ));
309    }
310
311    match placeholder_count {
312        1 => Ok(DisplayFormat::Interpolated(format)),
313        _ => Err(Error::new(
314            format.span(),
315            "display format must contain exactly one `{}` placeholder",
316        )),
317    }
318}