1extern crate proc_macro;
2use proc_macro::TokenStream;
3use quote::{format_ident, quote};
4use syn::{parse_macro_input, Data, DeriveInput, Fields};
5
6#[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 let input = parse_macro_input!(input as DeriveInput);
57
58 TokenStream::from(expand(&input))
60}
61
62fn expand(input: &DeriveInput) -> proc_macro2::TokenStream {
67 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 let error_prefix = get_error_prefix(&input.attrs);
82
83 if is_enum {
85 implement_for_enum(input, &error_prefix)
86 } else {
87 implement_for_struct(input, &error_prefix)
88 }
89}
90
91fn 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
177fn 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
225fn 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 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 for variant in &data_enum.variants {
244 let variant_name = &variant.ident;
245 let variant_name_str = variant_name.to_string();
246
247 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 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 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 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 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 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 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 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 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
482fn 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#[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 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}