diff --git a/crates/pmp-core/Cargo.toml b/crates/pmp-core/Cargo.toml new file mode 100644 index 0000000..efc3dec --- /dev/null +++ b/crates/pmp-core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "pmp-core" +version = "1.0.0" + +edition.workspace = true +authors.workspace = true +license-file.workspace = true + +[dependencies] +syn = { version = "^2", features = ["full"] } +darling = "^0" +proc-macro2 = "^1" diff --git a/crates/pmp-core/src/attrs.rs b/crates/pmp-core/src/attrs.rs new file mode 100644 index 0000000..d9a6dc2 --- /dev/null +++ b/crates/pmp-core/src/attrs.rs @@ -0,0 +1,76 @@ +use darling::{ FromMeta }; + +/// Implemented by any item that carries a `Vec`. +/// +/// Allows `parse_pmp_attrs` to be generic over variants, fields, etc. +pub trait HasAttrs { + fn attrs(&self) -> &[syn::Attribute]; +} + +/// Blanket impl for anything that exposes `.attrs` as a public field via a deref, +/// covered by explicit impls below for the common syn types. +impl HasAttrs for syn::Field { + fn attrs(&self) -> &[syn::Attribute] { &self.attrs } +} + +impl HasAttrs for syn::Variant { + fn attrs(&self) -> &[syn::Attribute] { &self.attrs } +} + +// ---------------------------------------------------------------------------------------------- // + +/// Finds a `#[{attr_name}(...)]` attribute in the given list and parses it into `T`. +/// +/// Returns `Ok(T::default())` if no such attribute is present or +/// a span-accurate `Err` if the attribute is present but malformed. +/// +/// Each proc-macro crate passes its own attribute name: +/// ``` +/// // in pmp-emitter-core: +/// let attrs: EnumAttrs = parse_pmp_attr(&input.attrs, "pmp_emitter")?; +/// +/// // in pmp-macro-core: +/// let attrs: MacroAttrs = parse_pmp_attr(&input.attrs, "pmp_macro")?; +/// ``` +pub fn parse_pmp_attr(attrs: &[syn::Attribute], attr_name: &str) -> syn::Result +where + T: FromMeta + Default, +{ + match attrs.iter().find(|a| a.path().is_ident(attr_name)) + { + None => Ok(T::default()), + Some(attr) => T::from_meta(&attr.meta) + .map_err(|e| syn::Error::new_spanned(attr, e.to_string())), + } +} + +/// Accumulates errors from parsing `#[{attr_name}(...)]` on a collection of items +/// (e.g. enum variants or struct fields) into a single `syn::Error`. +/// +/// Returns `Ok(Vec)` with one parsed value per item, or `Err` containing all +/// accumulated errors if any items failed. +pub fn parse_pmp_attrs(items: I, attr_name: &str) -> syn::Result> +where + T: FromMeta + Default, + I: IntoIterator, +{ + let mut results: Vec = Vec::new(); + let mut errors: Option = None; + + for item in items + { + match parse_pmp_attr(item.attrs(), attr_name) + { + Ok(v) => results.push(v), + Err(e) => match &mut errors { + None => errors = Some(e), + Some(existing) => existing.combine(e), + }, + } + } + + match errors { + None => Ok(results), + Some(e) => Err(e), + } +} diff --git a/crates/pmp-core/src/functions.rs b/crates/pmp-core/src/functions.rs new file mode 100644 index 0000000..c39773e --- /dev/null +++ b/crates/pmp-core/src/functions.rs @@ -0,0 +1,26 @@ + +pub fn pascal_to_title(s: &str) -> String +{ + let mut out = String::new(); + + for (i, ch) in s.chars().enumerate() { + if ch.is_uppercase() && i > 0 { out.push(' '); } + out.push(ch); + } + + out +} + +pub fn snake_to_title(s: &str) -> String +{ + s.split('_') + .map(|word| { + let mut c = word.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } + }) + .collect::>() + .join(" ") +} diff --git a/crates/pmp-core/src/lib.rs b/crates/pmp-core/src/lib.rs new file mode 100644 index 0000000..899352d --- /dev/null +++ b/crates/pmp-core/src/lib.rs @@ -0,0 +1,20 @@ +//! Shared proc-macro utilities for all `pmp-*` crates. +//! +//! This crate provides building blocks that are identical across every +//! `pmp-*-core` proc-derive-macro crate. Each consumer crate still defines +//! its own darling input structs (because `forward_attrs` bakes in a per-crate +//! attribute name at compile time), but everything else should be imported from here. +//! +//! | Module | Contents | +//! |-----------|------------------------------------------------------------------| +//! | `types` | [`StrictBool`], [`StrictString`], [`StrictPath`], [`StrictExpr`] | +//! | `helpers` | [`pascal_to_title`], [`snake_to_title`] | +//! | `attr` | [`parse_pmp_attr`], [`parse_pmp_attrs`], [`HasAttrs`] | + +mod attrs; +mod types; +mod functions; + +pub use attrs::*; +pub use types::*; +pub use functions::*; diff --git a/crates/pmp-core/src/types.rs b/crates/pmp-core/src/types.rs new file mode 100644 index 0000000..742444c --- /dev/null +++ b/crates/pmp-core/src/types.rs @@ -0,0 +1,67 @@ +use syn::{ Lit, Path }; +use darling::{ Error, Result, FromMeta }; + +/// A boolean that must be written as `= true` or `= false` +#[allow(dead_code)] +#[derive(Debug, Clone, Copy)] +pub struct StrictBool(pub bool); + +impl FromMeta for StrictBool +{ + fn from_word() -> Result + { + Err(Error::custom( + "expected `= true/false`, provide a boolean value" + )) + } + + fn from_value(value: &Lit) -> Result + { + match value { + Lit::Bool(b) => Ok(StrictBool(b.value)), + _ => Err(Error::unexpected_lit_type(value)), + } + } +} + +/// A string that must be written as `= "..."` +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct StrictString(pub String); + +impl FromMeta for StrictString +{ + fn from_word() -> Result + { + Err(Error::custom( + "expected `= \"...\"`, provide a string value" + )) + } + + fn from_value(value: &Lit) -> Result { + String::from_value(value).map(StrictString) + } +} + +/// A path that must be written as `= my_fn` +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct StrictPath(pub Path); + +impl FromMeta for StrictPath +{ + fn from_word() -> Result + { + Err(Error::custom( + "expected `= my_fn`, provide a function path" + )) + } + + fn from_value(value: &Lit) -> Result { + Path::from_value(value).map(StrictPath) + } + + fn from_expr(expr: &syn::Expr) -> Result { + Path::from_expr(expr).map(StrictPath) + } +} diff --git a/crates/pmp-emitter/crates/pmp-emitter-core/Cargo.toml b/crates/pmp-emitter/crates/pmp-emitter-core/Cargo.toml index 1d6a584..6727306 100644 --- a/crates/pmp-emitter/crates/pmp-emitter-core/Cargo.toml +++ b/crates/pmp-emitter/crates/pmp-emitter-core/Cargo.toml @@ -10,6 +10,8 @@ license-file.workspace = true proc-macro = true [dependencies] +pmp-core = { path = "../../../pmp-core" } + syn = { version = "^2", features = ["full"] } quote = "^1" darling = "^0" diff --git a/crates/pmp-emitter/crates/pmp-emitter-core/src/attrs.rs b/crates/pmp-emitter/crates/pmp-emitter-core/src/attrs.rs new file mode 100644 index 0000000..0607149 --- /dev/null +++ b/crates/pmp-emitter/crates/pmp-emitter-core/src/attrs.rs @@ -0,0 +1,46 @@ +use darling::{ ast, FromMeta, FromVariant, FromDeriveInput }; +use pmp_core::{ StrictBool, StrictPath, StrictString }; +use syn::{ Type, Ident }; + +#[derive(Debug, FromDeriveInput)] +#[darling(forward_attrs(pmp_emitter))] +pub(crate) struct EmitterInput { + pub ident: Ident, + pub data: ast::Data, +} + +#[derive(Debug, FromVariant)] +#[darling(forward_attrs(pmp_emitter))] +pub(crate) struct VariantReceiver { + pub ident: Ident, + pub fields: ast::Fields, + pub attrs: Vec, +} + +// ---------------------------------------------------------------------------------------------- // + +/// - `descriptor = "..."` - prefix shown before all variant messages (default: enum name) +/// - `exit = true|false` - whether to call process::exit (default: false) +/// - `colorizer = fn` - fn(String) -> String applied to all variants unless overridden +#[derive(Debug, Default, FromMeta)] +pub(crate) struct EnumAttrs { + #[darling(default)] + pub exit: Option, + #[darling(default)] + pub colorizer: Option, + #[darling(default)] + pub 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)] +pub(crate) struct VariantAttrs { + #[darling(default)] + pub exit: Option, + #[darling(default)] + pub message: Option, + #[darling(default)] + pub colorizer: Option, +} diff --git a/crates/pmp-emitter/crates/pmp-emitter-core/src/expand.rs b/crates/pmp-emitter/crates/pmp-emitter-core/src/expand.rs new file mode 100644 index 0000000..96dd7a0 --- /dev/null +++ b/crates/pmp-emitter/crates/pmp-emitter-core/src/expand.rs @@ -0,0 +1,103 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::{ quote, quote_spanned }; +use pmp_core::{ self, StrictPath }; +use syn::DeriveInput; +use darling::ast; + +use crate::attrs::{ EnumAttrs, EmitterInput, VariantAttrs }; + +// ---------------------------------------------------------------------------------------------- // + +pub fn expand(shape: EmitterInput, raw_input: &DeriveInput) -> syn::Result +{ + let enum_attrs: EnumAttrs = pmp_core::parse_pmp_attr(&raw_input.attrs, "pmp_emitter")?; + + let enum_ident = &shape.ident; + let descriptor = enum_attrs.descriptor + .map(|s| s.0) + .unwrap_or_else(|| pmp_core::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().ok_or_else(|| { + syn::Error::new_spanned(enum_ident, "PmpEmitter can only be derived on enums") + })?; + + 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 = pmp_core::pascal_to_title(&var_ident.to_string()); + + let v_attrs: VariantAttrs = match pmp_core::parse_pmp_attr(&v.attrs, "pmp_emitter") + { + Ok(a) => a, + Err(e) => + { + match &mut errors { + None => errors = Some(e), + Some(existing) => existing.combine(e), + } + continue; + } + }; + + let message = v_attrs.message.map(|s| s.0).unwrap_or_else(|| title.clone()); + let should_exit = v_attrs.exit.map(|b| b.0).unwrap_or(global_exit); + + let header = format!("{}: {} (\"{}\")", descriptor, title, message); + + let formatted_header = match v_attrs.colorizer.as_ref().or(global_colorizer.as_ref()) { + Some(StrictPath(path)) => quote! { #path(#header) }, + None => quote! { #header.to_string() }, + }; + + let exit_tokens = if should_exit { quote! { std::process::exit(1); } } else { quote! {} }; + + let arm = match v.fields.style + { + ast::Style::Tuple => + { + let pattern = if v.fields.len() == 1 { quote! { (trace) } } else { quote! { (trace, ..) } }; + + quote_spanned! { var_ident.span() => + #enum_ident::#var_ident #pattern => + { + println!("{}\n\n{}", #formatted_header, trace); + #exit_tokens + } + } + }, + _ => quote_spanned! { var_ident.span() => + #enum_ident::#var_ident => + { + println!("{}", #formatted_header); + #exit_tokens + } + }, + }; + + match_arms.push(arm); + } + + if let Some(e) = errors { return Err(e); } + + Ok(quote! + { + impl #enum_ident + { + pub fn emit(&self) { + match self { #(#match_arms),* } + } + } + + impl ::pmp_emitter::pmp_emitter_misc::Emittable for #enum_ident + { + fn emit(&self) { + self.emit() + } + } + }) +} 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 0091f5c..c43fed3 100644 --- a/crates/pmp-emitter/crates/pmp-emitter-core/src/lib.rs +++ b/crates/pmp-emitter/crates/pmp-emitter-core/src/lib.rs @@ -1,257 +1,14 @@ +use darling::FromDeriveInput; use proc_macro::TokenStream; -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 }; +use syn::DeriveInput; + +mod attrs; +mod expand; + +use attrs::EmitterInput; // ---------------------------------------------------------------------------------------------- // -#[derive(Debug, FromDeriveInput)] -#[darling(forward_attrs(pmp_emitter))] -struct EmitterInput { - ident: Ident, - data: ast::Data, -} - -#[derive(Debug, FromVariant)] -#[darling(forward_attrs(pmp_emitter))] -struct VariantReceiver { - ident: Ident, - fields: ast::Fields, - attrs: Vec, -} - -// ---------------------------------------------------------------------------------------------- // - -/// A boolean that must be written as `= true` or `= false` -#[derive(Debug, Clone, Copy)] -struct StrictBool(bool); - -impl FromMeta for StrictBool -{ - fn from_word() -> Result { - Err(Error::custom( - "expected `= true/false`, provide a boolean value" - )) - } - - fn from_value(value: &Lit) -> Result - { - match value { - Lit::Bool(b) => Ok(StrictBool(b.value)), - _ => Err(Error::unexpected_lit_type(value)), - } - } -} - -/// A string that must be written as `= "..."` -#[derive(Debug, Clone)] -struct StrictString(String); - -impl FromMeta for StrictString -{ - fn from_word() -> Result { - Err(Error::custom( - "expected `= \"...\"`, provide a string value" - )) - } - - fn from_value(value: &Lit) -> Result { - String::from_value(value).map(StrictString) - } -} - -/// A path that must be written as `= my_fn` -#[derive(Debug, Clone)] -struct StrictPath(Path); - -impl FromMeta for StrictPath -{ - fn from_word() -> Result { - Err(Error::custom( - "expected `= my_fn`, provide a function path" - )) - } - - fn from_value(value: &Lit) -> Result { - Path::from_value(value).map(StrictPath) - } - - fn from_expr(expr: &syn::Expr) -> Result { - Path::from_expr(expr).map(StrictPath) - } -} - -// ---------------------------------------------------------------------------------------------- // - -/// - `descriptor = "..."` - prefix shown before all variant messages (default: enum name) -/// - `exit = true|false` - whether to call process::exit (default: false) -/// - `colorizer = fn` - fn(String) -> String applied to all variants unless overridden -#[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() { - if ch.is_uppercase() && i > 0 { out.push(' '); } - out.push(ch); - } - - 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())), - } -} - -// ---------------------------------------------------------------------------------------------- // - -/// 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 enum_attrs: EnumAttrs = parse_pmp_attr(&raw_input.attrs)?; - - let enum_ident = &shape.ident; - let descriptor = enum_attrs.descriptor - .map(|s| s.0) - .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; - - // 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") - })?; - - // 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) - { - Ok(a) => a, - Err(e) => - { - match &mut errors { - None => errors = Some(e), - Some(existing) => existing.combine(e), - } - continue; // keep parsing remaining variants - } - }; - - let message = v_attrs.message.map(|s| s.0).unwrap_or_else(|| title.clone()); - let should_exit = v_attrs.exit.map(|b| b.0).unwrap_or(global_exit); - - // ┌─ 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(StrictPath(path)) => quote! { #path(#header) }, - None => quote! { #header.to_string() }, - }; - - let exit_tokens = if should_exit { quote! { std::process::exit(1); } } else { quote! {} }; - - // 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 => - { - let pattern = if v.fields.len() == 1 { quote! { (trace) } } else { quote! { (trace, ..) } }; - - quote_spanned! { var_ident.span() => - #enum_ident::#var_ident #pattern => - { - println!("{}\n\n{}", #formatted_header, trace); - #exit_tokens - } - } - }, - _ => quote_spanned! { var_ident.span() => - #enum_ident::#var_ident => - { - println!("{}", #formatted_header); - #exit_tokens - } - }, - }; - - 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) { - match self { #(#match_arms),* } - } - } - - impl ::pmp_emitter::pmp_emitter_misc::Emittable for #enum_ident - { - fn emit(&self) { - self.emit() - } - } - }) -} - -// ---------------------------------------------------------------------------------------------- // - -/// 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 { @@ -262,7 +19,7 @@ pub fn pmp_emitter(item: TokenStream) -> TokenStream Err(e) => return e.write_errors().into(), }; - expand(shape, &input) + expand::expand(shape, &input) .unwrap_or_else(syn::Error::into_compile_error) .into() }