From 134e35cc9e3b2c6879796aef226c66ca5c87a393 Mon Sep 17 00:00:00 2001 From: Overlord Date: Wed, 4 Mar 2026 13:17:29 +0100 Subject: [PATCH] Refactor `PmpEmitter` implementation: streamline attribute parsing, improve error handling, and enhance support for `colorizer` and `exit` attribute inheritance --- .../crates/pmp-emitter-core/src/lib.rs | 229 ++++++++++-------- 1 file changed, 127 insertions(+), 102 deletions(-) diff --git a/crates/pmp-emitter/crates/pmp-emitter-core/src/lib.rs b/crates/pmp-emitter/crates/pmp-emitter-core/src/lib.rs index 061c1d5..a969019 100644 --- a/crates/pmp-emitter/crates/pmp-emitter-core/src/lib.rs +++ b/crates/pmp-emitter/crates/pmp-emitter-core/src/lib.rs @@ -1,6 +1,7 @@ -use quote::quote; use proc_macro::TokenStream; -use syn::{ Lit, Path, Type, Ident, DeriveInput, parse_macro_input }; +use quote::{ quote, quote_spanned }; +use syn::{ Lit, Path, Type, Ident, DeriveInput }; +use proc_macro2::{ TokenStream as TokenStream2 }; use darling::{ ast, Error, Result, FromMeta, FromVariant, FromDeriveInput }; // ---------------------------------------------------------------------------------------------- // @@ -22,34 +23,10 @@ struct VariantReceiver { // ---------------------------------------------------------------------------------------------- // +/// A boolean that must be written as `= true` or `= false` #[derive(Debug, Clone, Copy)] struct StrictBool(bool); -/// - `descriptor = "..."` - prefix shown before all variant messages (default: enum name) -/// - `exit = true|false` - whether to call process::exit (default: false) -#[derive(Debug, FromMeta)] -struct EnumAttrs { - #[darling(default)] - descriptor: Option, - #[darling(default)] - exit: Option, -} - -/// - `message = "..."` - error message (default: variant name as title case) -/// - `colorizer = fn` - optional fn(String) -> String for formatting -/// - `exit = true|false` - overrides enum-level exit -#[derive(Debug, FromMeta)] -struct VariantAttrs { - #[darling(default)] - exit: Option, - #[darling(default)] - message: Option, - #[darling(default)] - colorizer: Option, -} - -// ---------------------------------------------------------------------------------------------- // - impl FromMeta for StrictBool { fn from_value(value: &Lit) -> Result @@ -60,40 +37,43 @@ impl FromMeta for StrictBool } } - // intentionally NOT implementing from_word() -} - -fn parse_pmp_attr(attrs: &[syn::Attribute]) -> T -where - T: Default, -{ - attrs - .iter() - .find(|a| a.path().is_ident("pmp_emitter")) - .map(|a| T::from_meta(&a.meta).expect("invalid pmp_emitter attribute")) - .unwrap_or_default() -} - -impl Default for EnumAttrs { - fn default() -> Self { - Self { descriptor: None, exit: None } - } -} - -impl Default for VariantAttrs { - fn default() -> Self { - Self { message: None, exit: None, colorizer: None } - } + // intentionally NOT implementing from_word() - bare `exit` is rejected } // ---------------------------------------------------------------------------------------------- // -fn pascal_to_title(s: &str) -> String +/// - `descriptor = "..."` - prefix shown before all variant messages (default: enum name) +/// - `exit = true|false` - whether to call process::exit (default: false) +#[derive(Debug, Default, FromMeta)] +struct EnumAttrs { + #[darling(default)] + exit: Option, + #[darling(default)] + colorizer: Option, + #[darling(default)] + descriptor: Option, +} + +/// - `message = "..."` - error message (default: variant name as title case) +/// - `colorizer = fn` - optional fn(String) -> String for formatting +/// - `exit = true|false` - overrides enum-level exit +#[derive(Debug, Default, FromMeta)] +struct VariantAttrs { + #[darling(default)] + exit: Option, + #[darling(default)] + message: Option, + #[darling(default)] + colorizer: Option, +} + +// ---------------------------------------------------------------------------------------------- // + +fn pascal_to_title(s: &str) -> String { let mut out = String::new(); - for (i, ch) in s.chars().enumerate() - { + for (i, ch) in s.chars().enumerate() { if ch.is_uppercase() && i > 0 { out.push(' '); } out.push(ch); } @@ -101,78 +81,105 @@ fn pascal_to_title(s: &str) -> String out } +/// Parse a `#[pmp_emitter(...)]` attribute from the given list into `T`. +/// +/// Returns `Ok(T::default())` if no such attribute is present or +/// a span-accurate `Err` if the attribute is present but malformed. +fn parse_pmp_attr(attrs: &[syn::Attribute]) -> syn::Result +where + T: FromMeta + Default, +{ + match attrs.iter().find(|a| a.path().is_ident("pmp_emitter")) { + None => Ok(T::default()), + Some(attr) => T::from_meta(&attr.meta) + .map_err(|e| syn::Error::new_spanned(attr, e.to_string())), + } +} + // ---------------------------------------------------------------------------------------------- // -#[proc_macro_derive(PmpEmitter, attributes(pmp_emitter))] -pub fn pmp_emitter(item: TokenStream) -> TokenStream +/// All real expansion logic lives here, separate from the proc-macro entry point, +/// so it can be called and tested without the proc-macro machinery. +fn expand(shape: EmitterInput, raw_input: &DeriveInput) -> syn::Result { - let input = parse_macro_input!(item as DeriveInput); - - // parse enum shape - let shape = match EmitterInput::from_derive_input(&input) { - Ok(a) => a, - Err(e) => return e.write_errors().into(), - }; - - // parse enum-level #[pmp_emitter(...)] directly from the raw attrs - let enum_attrs: EnumAttrs = parse_pmp_attr(&input.attrs); + let enum_attrs: EnumAttrs = parse_pmp_attr(&raw_input.attrs)?; let enum_ident = &shape.ident; let descriptor = enum_attrs.descriptor .unwrap_or_else(|| pascal_to_title(&enum_ident.to_string())); let global_exit = enum_attrs.exit.map(|b| b.0).unwrap_or(false); + let global_colorizer = enum_attrs.colorizer; - let variants = shape - .data - .take_enum() - .expect("PmpEmitter can only be derived on enums"); + // Reject non-enums with a proper span pointing at the type name. + let variants = shape.data.take_enum().ok_or_else(|| { + syn::Error::new_spanned(enum_ident, "PmpEmitter can only be derived on enums") + })?; - let match_arms = variants.iter().map(|v| + // Accumulate all per-variant attribute errors before failing, so the user + // sees every problem in one compilation instead of one-at-a-time. + let mut errors: Option = None; + let mut match_arms: Vec = Vec::with_capacity(variants.len()); + + for v in &variants + { + let var_ident = &v.ident; + let title = pascal_to_title(&var_ident.to_string()); + + let v_attrs: VariantAttrs = match parse_pmp_attr(&v.attrs) { - let var_ident = &v.ident; - let title = pascal_to_title(&var_ident.to_string()); + Ok(a) => a, + Err(e) => { + match &mut errors { + None => errors = Some(e), + Some(existing) => existing.combine(e), + } + continue; // keep parsing remaining variants + } + }; - // parse variant-level #[pmp_emitter(...)] from the variant's raw attrs - let vattrs: VariantAttrs = parse_pmp_attr(&v.attrs); + let message = v_attrs.message.unwrap_or_else(|| title.clone()); + let should_exit = v_attrs.exit.map(|b| b.0).unwrap_or(global_exit); - let message = vattrs.message.unwrap_or_else(|| title.clone()); - let should_exit = vattrs.exit.map(|b| b.0).unwrap_or(global_exit); + // ┌─ Output format ───────────────────────────────┐ + // │ {descriptor}: {Variant Title} ("{message}") │ + // │ │ + // │ {stacktrace} │ + // └───────────────────────────────────────────────┘ + let header = format!("{}: {} (\"{}\")", descriptor, title, message); - // ┌─ Output format ───────────────────────────────┐ - // │ {descriptor}: {Variant Title} ("{message}") │ - // │ │ - // │ {stacktrace} │ - // └───────────────────────────────────────────────┘ - let header = format!("{}: {} (\"{}\")", descriptor, title, message); + let formatted_header = match v_attrs.colorizer.as_ref().or(global_colorizer.as_ref()) { + Some(path) => quote! { #path(#header) }, + None => quote! { #header.to_string() }, + }; - let formatted_header = match &vattrs.colorizer - { - Some(path) => quote! { #path(#header) }, - None => quote! { #header.to_string() }, - }; + let exit_tokens = if should_exit { quote! { std::process::exit(1); } } else { quote! {} }; - let exit_tokens = if should_exit { quote! { std::process::exit(1); } } else { quote! {} }; - - match v.fields.style - { - ast::Style::Tuple => quote! { - #enum_ident::#var_ident(trace) => - { + // quote_spanned! ties any downstream type errors back to the variant's + // source location instead of pointing at generated code. + let arm = match v.fields.style + { + ast::Style::Tuple => quote_spanned! { var_ident.span() => + #enum_ident::#var_ident(trace) => { println!("{}\n\n{}", #formatted_header, trace); #exit_tokens } }, - _ => quote! { - #enum_ident::#var_ident => - { + _ => quote_spanned! { var_ident.span() => + #enum_ident::#var_ident => { println!("{}", #formatted_header); #exit_tokens } }, - } - }); + }; - quote! { + match_arms.push(arm); + } + + // Surface all accumulated variant errors at once. + if let Some(e) = errors { return Err(e); } + + Ok(quote! + { impl #enum_ident { pub fn emit(&self) { @@ -186,5 +193,23 @@ pub fn pmp_emitter(item: TokenStream) -> TokenStream self.emit() } } - }.into() + }) +} + +// ---------------------------------------------------------------------------------------------- // + +/// Thin entry point: parse -> delegate -> convert any error to `compile_error!`. +#[proc_macro_derive(PmpEmitter, attributes(pmp_emitter))] +pub fn pmp_emitter(item: TokenStream) -> TokenStream +{ + let input = syn::parse_macro_input!(item as DeriveInput); + + let shape = match EmitterInput::from_derive_input(&input) { + Ok(s) => s, + Err(e) => return e.write_errors().into(), + }; + + expand(shape, &input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() }