Skip to main content

error_forge_derive/
lib.rs

1extern crate proc_macro;
2use proc_macro::TokenStream;
3use quote::{format_ident, quote};
4use syn::{parse_macro_input, Data, DeriveInput, Fields};
5
6/// Derive macro for ModError
7///
8/// This macro automatically implements the ForgeError trait and common
9/// error handling functionality for a struct or enum, allowing for
10/// "lazy mode" error creation with minimal boilerplate.
11///
12/// # Example
13///
14/// When the macro is used in your application where error-forge is a dependency:
15///
16/// ```ignore
17/// use error_forge::ModError;
18///
19/// #[derive(Debug, ModError)]
20/// #[error_prefix("Database")]
21/// pub enum DbError {
22///     #[error_display("Connection to {0} failed")]
23///     ConnectionFailed(String),
24///
25///     #[error_display("Query execution failed: {reason}")]
26///     QueryFailed { reason: String },
27///
28///     #[error_display("Transaction error")]
29///     #[error_http_status(400)]
30///     TransactionError,
31/// }
32/// ```
33///
34/// `#[error_display("...")]` is optional; without it a variant displays
35/// as its name. The string may reference any subset of the variant's
36/// fields: by name for struct-like variants, by position (`{0}`, `{}`)
37/// for tuple variants. Fields it does not mention are ignored.
38///
39/// Note: This is a procedural macro that is re-exported by the `error-forge` crate.
40/// When using in your application, import it from the main crate with `use error_forge::ModError;`.
41#[proc_macro_derive(
42    ModError,
43    attributes(
44        error_prefix,
45        error_display,
46        error_kind,
47        error_caption,
48        error_retryable,
49        error_http_status,
50        error_exit_code,
51        error_fatal
52    )
53)]
54pub fn derive_mod_error(input: TokenStream) -> TokenStream {
55    // Parse the input
56    let input = parse_macro_input!(input as DeriveInput);
57
58    // Return the generated implementation
59    TokenStream::from(expand(&input))
60}
61
62/// Expand `#[derive(ModError)]` for a parsed item.
63///
64/// Unsupported input (a union) produces a `compile_error!` pointing at
65/// the item instead of panicking inside the compiler.
66fn expand(input: &DeriveInput) -> proc_macro2::TokenStream {
67    // Check if this is an enum or struct
68    let is_enum = match &input.data {
69        Data::Enum(_) => true,
70        Data::Struct(_) => false,
71        Data::Union(data) => {
72            return syn::Error::new_spanned(
73                data.union_token,
74                "ModError cannot be derived for unions; use an enum or a struct",
75            )
76            .to_compile_error();
77        }
78    };
79
80    // Get the error prefix from attributes
81    let error_prefix = get_error_prefix(&input.attrs);
82
83    // Generate implementation based on whether it's an enum or struct
84    if is_enum {
85        implement_for_enum(input, &error_prefix)
86    } else {
87        implement_for_struct(input, &error_prefix)
88    }
89}
90
91/// Rewrite a display format so it only needs the fields it mentions.
92///
93/// `positional` lists the binding names in field order: the field names
94/// of a struct-like variant, or `_0`, `_1`, ... for a tuple variant.
95/// Positional placeholders (`{}`, `{0}`, `{0:?}`) are rewritten to those
96/// names, so the generated `format!` can pass exactly the referenced
97/// fields as named arguments. Passing every field used to fail with
98/// "argument never used" whenever the string skipped one, including the
99/// default display (the bare variant name) on variants with fields.
100///
101/// Returns the rewritten string and the referenced bindings in first-use
102/// order, or `None` for strings this does not handle (width or precision
103/// taken from an argument, malformed braces, out-of-range indices); the
104/// caller then falls back to passing every field.
105fn rewrite_format(fmt: &str, positional: &[String]) -> Option<(String, Vec<String>)> {
106    let mut out = String::with_capacity(fmt.len());
107    let mut used: Vec<String> = Vec::new();
108    let mut next_implicit = 0usize;
109    let mut chars = fmt.chars().peekable();
110
111    while let Some(c) = chars.next() {
112        match c {
113            '{' if chars.peek() == Some(&'{') => {
114                chars.next();
115                out.push_str("{{");
116            }
117            '{' => {
118                let mut inner = String::new();
119                loop {
120                    match chars.next() {
121                        Some('}') => break,
122                        Some('{') | None => return None,
123                        Some(ch) => inner.push(ch),
124                    }
125                }
126                let (arg, spec) = match inner.find(':') {
127                    Some(i) => (&inner[..i], Some(&inner[i + 1..])),
128                    None => (inner.as_str(), None),
129                };
130                if spec.is_some_and(|spec| spec.contains('$') || spec.contains('*')) {
131                    return None;
132                }
133                let name = if arg.is_empty() {
134                    let name = positional.get(next_implicit)?.clone();
135                    next_implicit += 1;
136                    name
137                } else if arg.bytes().all(|b| b.is_ascii_digit()) {
138                    positional.get(arg.parse::<usize>().ok()?)?.clone()
139                } else if is_plain_ident(arg) {
140                    arg.to_string()
141                } else {
142                    return None;
143                };
144                if positional.contains(&name) && !used.contains(&name) {
145                    used.push(name.clone());
146                }
147                out.push('{');
148                out.push_str(&name);
149                if let Some(spec) = spec {
150                    out.push(':');
151                    out.push_str(spec);
152                }
153                out.push('}');
154            }
155            '}' if chars.peek() == Some(&'}') => {
156                chars.next();
157                out.push_str("}}");
158            }
159            '}' => return None,
160            other => out.push(other),
161        }
162    }
163
164    Some((out, used))
165}
166
167fn is_plain_ident(s: &str) -> bool {
168    let mut chars = s.chars();
169    match chars.next() {
170        Some(first) if first == '_' || first.is_alphabetic() => {
171            chars.all(|c| c == '_' || c.is_alphanumeric())
172        }
173        _ => false,
174    }
175}
176
177// Extract error_prefix attribute value
178fn get_error_prefix(attrs: &[syn::Attribute]) -> String {
179    for attr in attrs {
180        if attr.path.is_ident("error_prefix") {
181            if let Some(value) = parse_string_attribute(attr) {
182                return value;
183            }
184        }
185    }
186    String::new()
187}
188
189fn parse_string_attribute(attr: &syn::Attribute) -> Option<String> {
190    match attr.parse_meta().ok()? {
191        syn::Meta::NameValue(meta) => match meta.lit {
192            syn::Lit::Str(lit) => Some(lit.value()),
193            _ => None,
194        },
195        syn::Meta::List(meta) => match meta.nested.iter().next() {
196            Some(syn::NestedMeta::Lit(syn::Lit::Str(lit))) => Some(lit.value()),
197            _ => None,
198        },
199        syn::Meta::Path(_) => None,
200    }
201}
202
203fn parse_int_attribute<T>(attr: &syn::Attribute) -> Option<T>
204where
205    T: std::str::FromStr,
206    T::Err: std::fmt::Display,
207{
208    match attr.parse_meta().ok()? {
209        syn::Meta::NameValue(meta) => match meta.lit {
210            syn::Lit::Int(lit) => lit.base10_parse().ok(),
211            _ => None,
212        },
213        syn::Meta::List(meta) => match meta.nested.iter().next() {
214            Some(syn::NestedMeta::Lit(syn::Lit::Int(lit))) => lit.base10_parse().ok(),
215            _ => None,
216        },
217        syn::Meta::Path(_) => None,
218    }
219}
220
221fn has_flag_attribute(attr: &syn::Attribute, name: &str) -> bool {
222    attr.path.is_ident(name)
223}
224
225// Implement ModError for an enum
226fn implement_for_enum(input: &DeriveInput, error_prefix: &str) -> proc_macro2::TokenStream {
227    let name = &input.ident;
228    let data_enum = match &input.data {
229        Data::Enum(data) => data,
230        _ => panic!("Expected enum"),
231    };
232
233    // Generate match arms for each variant
234    let mut kind_match_arms = Vec::new();
235    let mut caption_match_arms = Vec::new();
236    let mut display_match_arms = Vec::new();
237    let mut retryable_match_arms = Vec::new();
238    let mut fatal_match_arms = Vec::new();
239    let mut status_code_match_arms = Vec::new();
240    let mut exit_code_match_arms = Vec::new();
241
242    // Process each variant
243    for variant in &data_enum.variants {
244        let variant_name = &variant.ident;
245        let variant_name_str = variant_name.to_string();
246
247        // Default values
248        let mut display_format = variant_name_str.clone();
249        let mut kind_name = variant_name_str.clone();
250        let mut caption = format!("{}: Error", error_prefix);
251        let mut retryable = false;
252        let mut fatal = false;
253        let mut status_code: u16 = 500;
254        let mut exit_code: i32 = 1;
255
256        // Extract attributes
257        for attr in &variant.attrs {
258            if attr.path.is_ident("error_display") {
259                if let Some(value) = parse_string_attribute(attr) {
260                    display_format = value;
261                }
262            } else if attr.path.is_ident("error_kind") {
263                if let Some(value) = parse_string_attribute(attr) {
264                    kind_name = value;
265                }
266            } else if attr.path.is_ident("error_caption") {
267                if let Some(value) = parse_string_attribute(attr) {
268                    caption = value;
269                }
270            } else if attr.path.is_ident("error_retryable") {
271                retryable = true;
272            } else if has_flag_attribute(attr, "error_fatal") {
273                fatal = true;
274            } else if attr.path.is_ident("error_http_status") {
275                if let Some(value) = parse_int_attribute(attr) {
276                    status_code = value;
277                }
278            } else if attr.path.is_ident("error_exit_code") {
279                if let Some(value) = parse_int_attribute(attr) {
280                    exit_code = value;
281                }
282            }
283        }
284
285        // Generate pattern matching based on the variant's fields
286        match &variant.fields {
287            Fields::Named(fields) => {
288                let field_names: Vec<_> = fields
289                    .named
290                    .iter()
291                    .map(|f| f.ident.as_ref().unwrap())
292                    .collect();
293
294                // Format string handled directly in match arm
295
296                kind_match_arms.push(quote! {
297                    Self::#variant_name { .. } => #kind_name
298                });
299
300                caption_match_arms.push(quote! {
301                    Self::#variant_name { .. } => #caption
302                });
303
304                let positional: Vec<String> =
305                    field_names.iter().map(|name| name.to_string()).collect();
306                match rewrite_format(&display_format, &positional) {
307                    Some((format_str, used)) => {
308                        // Bind and pass only the fields the string uses.
309                        let used_fields: Vec<_> = field_names
310                            .iter()
311                            .filter(|name| used.contains(&name.to_string()))
312                            .collect();
313                        display_match_arms.push(quote! {
314                            Self::#variant_name { #(#used_fields,)* .. } => format!(#format_str #(, #used_fields = #used_fields)*)
315                        });
316                    }
317                    None => {
318                        display_match_arms.push(quote! {
319                            Self::#variant_name { #(#field_names),* } => format!(#display_format, #(#field_names = #field_names),*)
320                        });
321                    }
322                }
323
324                retryable_match_arms.push(quote! {
325                    Self::#variant_name { .. } => #retryable
326                });
327
328                fatal_match_arms.push(quote! {
329                    Self::#variant_name { .. } => #fatal
330                });
331
332                status_code_match_arms.push(quote! {
333                    Self::#variant_name { .. } => #status_code
334                });
335
336                exit_code_match_arms.push(quote! {
337                    Self::#variant_name { .. } => #exit_code
338                });
339            }
340            Fields::Unnamed(fields) => {
341                let field_count = fields.unnamed.len();
342                let field_names: Vec<_> =
343                    (0..field_count).map(|i| format_ident!("_{}", i)).collect();
344
345                // Generate display format with tuple fields
346                kind_match_arms.push(quote! {
347                    Self::#variant_name(..) => #kind_name
348                });
349
350                caption_match_arms.push(quote! {
351                    Self::#variant_name(..) => #caption
352                });
353
354                let field_pattern_list = field_names.iter().map(|name| quote! { #name, });
355                let positional: Vec<String> =
356                    field_names.iter().map(|name| name.to_string()).collect();
357                match rewrite_format(&display_format, &positional) {
358                    Some((format_str, used)) => {
359                        // `_N` bindings never trigger unused-variable
360                        // warnings, so every field can stay bound.
361                        let used_fields: Vec<_> = field_names
362                            .iter()
363                            .filter(|name| used.contains(&name.to_string()))
364                            .collect();
365                        display_match_arms.push(quote! {
366                            Self::#variant_name(#(#field_pattern_list)*) => format!(#format_str #(, #used_fields = #used_fields)*)
367                        });
368                    }
369                    None => {
370                        display_match_arms.push(quote! {
371                            Self::#variant_name(#(#field_pattern_list)*) => format!(#display_format #(, #field_names)*)
372                        });
373                    }
374                }
375
376                retryable_match_arms.push(quote! {
377                    Self::#variant_name(..) => #retryable
378                });
379
380                fatal_match_arms.push(quote! {
381                    Self::#variant_name(..) => #fatal
382                });
383
384                status_code_match_arms.push(quote! {
385                    Self::#variant_name(..) => #status_code
386                });
387
388                exit_code_match_arms.push(quote! {
389                    Self::#variant_name(..) => #exit_code
390                });
391            }
392            Fields::Unit => {
393                // Unit variant (no fields)
394                kind_match_arms.push(quote! {
395                    Self::#variant_name => #kind_name
396                });
397
398                caption_match_arms.push(quote! {
399                    Self::#variant_name => #caption
400                });
401
402                display_match_arms.push(quote! {
403                    Self::#variant_name => #display_format.to_string()
404                });
405
406                retryable_match_arms.push(quote! {
407                    Self::#variant_name => #retryable
408                });
409
410                fatal_match_arms.push(quote! {
411                    Self::#variant_name => #fatal
412                });
413
414                status_code_match_arms.push(quote! {
415                    Self::#variant_name => #status_code
416                });
417
418                exit_code_match_arms.push(quote! {
419                    Self::#variant_name => #exit_code
420                });
421            }
422        }
423    }
424
425    // Generate implementation
426    quote! {
427        impl ::std::fmt::Display for #name {
428            fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
429                let msg = match self {
430                    #(#display_match_arms,)*
431                };
432                write!(f, "{}", msg)
433            }
434        }
435
436        impl ::error_forge::error::ForgeError for #name {
437            fn kind(&self) -> &'static str {
438                match self {
439                    #(#kind_match_arms,)*
440                }
441            }
442
443            fn caption(&self) -> &'static str {
444                match self {
445                    #(#caption_match_arms,)*
446                }
447            }
448
449            fn is_retryable(&self) -> bool {
450                match self {
451                    #(#retryable_match_arms,)*
452                }
453            }
454
455            fn is_fatal(&self) -> bool {
456                match self {
457                    #(#fatal_match_arms,)*
458                }
459            }
460
461            fn status_code(&self) -> u16 {
462                match self {
463                    #(#status_code_match_arms,)*
464                }
465            }
466
467            fn exit_code(&self) -> i32 {
468                match self {
469                    #(#exit_code_match_arms,)*
470                }
471            }
472        }
473
474        impl ::std::error::Error for #name {
475            fn source(&self) -> Option<&(dyn ::std::error::Error + 'static)> {
476                None
477            }
478        }
479    }
480}
481
482// Implement ModError for a struct
483fn implement_for_struct(input: &DeriveInput, error_prefix: &str) -> proc_macro2::TokenStream {
484    let name = &input.ident;
485    let name_str = name.to_string();
486
487    quote! {
488        impl ::std::fmt::Display for #name {
489            fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
490                write!(f, "{}: Error", #error_prefix)
491            }
492        }
493
494        impl ::error_forge::error::ForgeError for #name {
495            fn kind(&self) -> &'static str {
496                #name_str
497            }
498
499            fn caption(&self) -> &'static str {
500                concat!(#error_prefix, ": Error")
501            }
502        }
503
504        impl ::std::error::Error for #name {
505            fn source(&self) -> Option<&(dyn ::std::error::Error + 'static)> {
506                None
507            }
508        }
509    }
510}
511
512// Note: The implementation now handles formatting directly in the match arms instead of using a helper function
513
514#[cfg(test)]
515mod tests {
516    use super::{expand, rewrite_format};
517
518    fn names(list: &[&str]) -> Vec<String> {
519        list.iter().map(|s| s.to_string()).collect()
520    }
521
522    #[test]
523    fn union_produces_compile_error_instead_of_panicking() {
524        let input: syn::DeriveInput = syn::parse_quote! {
525            union Bits { a: u32, b: f32 }
526        };
527        let output = expand(&input).to_string();
528        assert!(output.contains("compile_error"), "{output}");
529        assert!(output.contains("cannot be derived for unions"), "{output}");
530    }
531
532    #[test]
533    fn rewrite_maps_positional_placeholders_to_bindings() {
534        let tuple = names(&["_0", "_1", "_2"]);
535        assert_eq!(
536            rewrite_format("{} then {} and {0:?}", &tuple),
537            Some((
538                "{_0} then {_1} and {_0:?}".to_string(),
539                names(&["_0", "_1"])
540            ))
541        );
542        assert_eq!(
543            rewrite_format("only {2}", &tuple),
544            Some(("only {_2}".to_string(), names(&["_2"])))
545        );
546        assert_eq!(
547            rewrite_format("NoFields", &tuple),
548            Some(("NoFields".to_string(), Vec::new()))
549        );
550    }
551
552    #[test]
553    fn rewrite_keeps_named_and_escaped_placeholders() {
554        let fields = names(&["host", "port"]);
555        assert_eq!(
556            rewrite_format("{{literal}} {port:>5} {CONST}", &fields),
557            Some((
558                "{{literal}} {port:>5} {CONST}".to_string(),
559                names(&["port"])
560            ))
561        );
562        // Positional references in a struct-like variant map to the
563        // fields in declaration order, as format! did before.
564        assert_eq!(
565            rewrite_format("{}:{}", &fields),
566            Some(("{host}:{port}".to_string(), names(&["host", "port"])))
567        );
568    }
569
570    #[test]
571    fn rewrite_declines_unsupported_strings() {
572        let fields = names(&["a", "b"]);
573        assert_eq!(rewrite_format("{:width$}", &fields), None);
574        assert_eq!(rewrite_format("{:.*}", &fields), None);
575        assert_eq!(rewrite_format("{5}", &fields), None);
576        assert_eq!(rewrite_format("unclosed {", &fields), None);
577        assert_eq!(rewrite_format("stray }", &fields), None);
578    }
579}