diff --git a/CHANGELOG.md b/CHANGELOG.md index ab323fb..24af51c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## Unreleased - Add `attrs` attribute to pass extra attributes to the generated kind enum (e.g., `#[kinded(attrs(serde(rename_all = "snake_case")))]`) +- Add per-variant `attrs` attribute to pass attributes to individual kind variants (e.g., `#[kinded(attrs(default))]`) (fixes #22) - Make `kind()` method `const fn`, allowing usage in const contexts (fixes #12) ## v0.4.1 - 2026-01-29 diff --git a/README.md b/README.md index a897e67..a2789b9 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,47 @@ You can pass multiple attributes: ))] ``` +### Variant attributes + +You can also apply attributes to individual variants of the generated kind enum using `attrs` on the variant: + +```rs +use kinded::Kinded; +use serde::Serialize; + +#[derive(Kinded, Serialize)] +#[kinded(derive(Default, Serialize), attrs(serde(rename_all = "snake_case")))] +enum Priority { + Low, + #[kinded(attrs(default, serde(rename = "normal")))] + Medium, + High, +} + +// Medium is the default +assert_eq!(PriorityKind::default(), PriorityKind::Medium); + +// Serde uses the custom rename for Medium +let json = serde_json::to_string(&PriorityKind::Medium).unwrap(); +assert_eq!(json, r#""normal""#); + +// Other variants use the enum-level rename_all +let json = serde_json::to_string(&PriorityKind::High).unwrap(); +assert_eq!(json, r#""high""#); +``` + +You can combine `attrs` with `rename` on the same variant: + +```rs +#[derive(Kinded)] +#[kinded(derive(Default))] +enum Level { + #[kinded(rename = "low_level", attrs(default))] + Low, + Medium, +} +``` + ### Display trait Implementation of `Display` trait can be customized in the `serde` fashion: diff --git a/kinded/src/lib.rs b/kinded/src/lib.rs index a7838e7..2c85015 100644 --- a/kinded/src/lib.rs +++ b/kinded/src/lib.rs @@ -161,6 +161,38 @@ //! assert_eq!(json_value, json!("very_hot_black_tea")); //! ``` //! +//! ### Variant attributes +//! +//! You can also apply attributes to individual variants of the generated kind enum: +//! +//! ```ignore +//! use kinded::Kinded; +//! +//! #[derive(Kinded)] +//! #[kinded(derive(Default))] +//! enum Priority { +//! Low, +//! #[kinded(attrs(default))] +//! Medium, +//! High, +//! } +//! +//! // Medium is the default +//! assert_eq!(PriorityKind::default(), PriorityKind::Medium); +//! ``` +//! +//! You can combine `attrs` with `rename` on the same variant: +//! +//! ```ignore +//! #[derive(Kinded)] +//! #[kinded(derive(Default))] +//! enum Level { +//! #[kinded(rename = "low_level", attrs(default))] +//! Low, +//! Medium, +//! } +//! ``` +//! //! ### Customize Display trait //! //! Implementation of `Display` trait can be customized in the `serde` fashion: diff --git a/kinded_macros/src/generate/kind_enum.rs b/kinded_macros/src/generate/kind_enum.rs index 6d2c8fd..e0d0b07 100644 --- a/kinded_macros/src/generate/kind_enum.rs +++ b/kinded_macros/src/generate/kind_enum.rs @@ -1,4 +1,4 @@ -use crate::models::{DisplayCase, Meta}; +use crate::models::{DisplayCase, Meta, Variant}; use proc_macro2::{Ident, TokenStream}; use quote::quote; @@ -21,15 +21,18 @@ pub fn gen_kind_enum(meta: &Meta) -> TokenStream { fn gen_definition(meta: &Meta) -> TokenStream { let vis = &meta.vis; let kind_name = meta.kind_name(); - let variant_names: Vec<&Ident> = meta.variants.iter().map(|v| &v.ident).collect(); let traits = meta.derive_traits(); - let attrs = meta.meta_attrs(); + let enum_attrs = meta.meta_attrs(); + let variant_names: Vec<&Ident> = meta.variants.iter().map(|v| &v.ident).collect(); + + let variants_with_attrs: Vec = + meta.variants.iter().map(gen_variant_definition).collect(); quote!( #[derive(#(#traits),*)] // #[derive(Debug, Clone, Copy, PartialEq, Eq)] - #(#[#attrs])* // #[serde(rename_all = "camelCase")] + #(#[#enum_attrs])* // #[serde(rename_all = "camelCase")] #vis enum #kind_name { // pub enum DrinkKind { - #(#variant_names),* // Mate, Coffee, Tea + #(#variants_with_attrs),* // #[default] Mate, Coffee, Tea } // } impl #kind_name { // impl DrinkKind { @@ -42,6 +45,17 @@ fn gen_definition(meta: &Meta) -> TokenStream { ) } +/// Generate a single variant definition with its attributes +fn gen_variant_definition(variant: &Variant) -> TokenStream { + let variant_name = &variant.ident; + let variant_attrs = &variant.attrs; + + quote!( + #(#[#variant_attrs])* + #variant_name + ) +} + fn gen_impl_from_traits(meta: &Meta) -> TokenStream { let kind_name = meta.kind_name(); let generics = &meta.generics; diff --git a/kinded_macros/src/models.rs b/kinded_macros/src/models.rs index 547a30b..c83a987 100644 --- a/kinded_macros/src/models.rs +++ b/kinded_macros/src/models.rs @@ -68,6 +68,8 @@ pub struct Variant { /// Custom display/parse name specified with `#[kinded(rename = "...")]`. /// When set, this overrides the automatic case conversion for Display and FromStr. pub rename: Option, + /// Extra attributes to apply to the generated kind variant (e.g., `#[default]`, `#[serde(rename = "...")]`). + pub attrs: Vec, } /// This mimics syn::Fields, but without payload. diff --git a/kinded_macros/src/parse.rs b/kinded_macros/src/parse.rs index 3f13de0..de26a2f 100644 --- a/kinded_macros/src/parse.rs +++ b/kinded_macros/src/parse.rs @@ -36,34 +36,68 @@ pub fn parse_derive_input(input: DeriveInput) -> Result { fn parse_variant(variant: &syn::Variant) -> Variant { let rename = find_variant_kinded_rename(&variant.attrs); + let attrs = find_variant_kinded_attrs(&variant.attrs); Variant { ident: variant.ident.clone(), fields_type: parse_fields_type(&variant.fields), rename, + attrs, } } -/// Find `#[kinded(rename = "...")]` attribute on a variant and extract the rename value. -fn find_variant_kinded_rename(attrs: &[Attribute]) -> Option { +/// Parsed variant-level #[kinded(...)] attributes +struct VariantKindedAttrs { + rename: Option, + attrs: Vec, +} + +/// Parse all #[kinded(...)] attributes on a variant. +/// Handles combined attributes like #[kinded(rename = "...", attrs(...))] +fn parse_variant_kinded_attrs(attrs: &[Attribute]) -> VariantKindedAttrs { + let mut result = VariantKindedAttrs { + rename: None, + attrs: Vec::new(), + }; + for attr in attrs { if attr.path().is_ident("kinded") { - // Try to parse the attribute content - if let Ok(parsed) = attr.parse_args_with(|input: ParseStream| { - let attr_name: Ident = input.parse()?; - if attr_name == "rename" { - let _: Token!(=) = input.parse()?; - let lit_str: LitStr = input.parse()?; - Ok(Some(lit_str.value())) - } else { - Ok(None) + let _ = attr.parse_args_with(|input: ParseStream| { + while !input.is_empty() { + let attr_name: Ident = input.parse()?; + + if attr_name == "rename" { + let _: Token!(=) = input.parse()?; + let lit_str: LitStr = input.parse()?; + result.rename = Some(lit_str.value()); + } else if attr_name == "attrs" { + let content; + parenthesized!(content in input); + let parsed_attrs = content.parse_terminated(SynMeta::parse, Token![,])?; + result.attrs.extend(parsed_attrs); + } + // Ignore unknown attributes at variant level + + // Parse `,` if not at end + if !input.is_empty() { + let _: Token![,] = input.parse()?; + } } - }) && parsed.is_some() - { - return parsed; - } + Ok(()) + }); } } - None + + result +} + +/// Find `#[kinded(rename = "...")]` attribute on a variant and extract the rename value. +fn find_variant_kinded_rename(attrs: &[Attribute]) -> Option { + parse_variant_kinded_attrs(attrs).rename +} + +/// Find `#[kinded(attrs(...))]` attribute on a variant and extract the attrs. +fn find_variant_kinded_attrs(attrs: &[Attribute]) -> Vec { + parse_variant_kinded_attrs(attrs).attrs } fn parse_fields_type(fields: &syn::Fields) -> FieldsType { diff --git a/test_suite/src/lib.rs b/test_suite/src/lib.rs index 54bfee3..fe2bd33 100644 --- a/test_suite/src/lib.rs +++ b/test_suite/src/lib.rs @@ -680,3 +680,163 @@ mod rename { assert_eq!("other".parse::().unwrap(), ActionKind::DoOther); } } + +mod variant_attrs { + extern crate alloc; + use alloc::string::ToString; + use kinded::Kinded; + use serde::{Deserialize, Serialize}; + + /// Test that a single attribute can be applied to a variant + #[test] + fn should_apply_single_attr_to_variant() { + #[derive(Kinded)] + #[kinded(derive(Default))] + enum Priority { + Low, + #[kinded(attrs(default))] + Medium, + High, + } + + assert_eq!(PriorityKind::default(), PriorityKind::Medium); + } + + /// Test that multiple attributes can be applied to a variant + #[test] + fn should_apply_multiple_attrs_to_variant() { + #[derive(Kinded, Serialize)] + #[kinded(derive(Default, Serialize), attrs(serde(rename_all = "snake_case")))] + enum Status { + #[kinded(attrs(default, serde(rename = "waiting")))] + Pending, + Active, + Done, + } + + // Test default + assert_eq!(StatusKind::default(), StatusKind::Pending); + + // Test serde rename on variant + let json = serde_json::to_string(&StatusKind::Pending).unwrap(); + assert_eq!(json, r#""waiting""#); + + // Other variants should use enum-level rename_all + let json = serde_json::to_string(&StatusKind::Active).unwrap(); + assert_eq!(json, r#""active""#); + } + + /// Test attrs on multiple variants + #[test] + fn should_apply_attrs_to_multiple_variants() { + #[derive(Kinded, Serialize)] + #[kinded(derive(Serialize), attrs(serde(rename_all = "SCREAMING_SNAKE_CASE")))] + enum Event { + #[kinded(attrs(serde(rename = "UserLoggedIn")))] + Login, + #[kinded(attrs(serde(rename = "UserLoggedOut")))] + Logout, + // This one uses the enum-level rename_all + SessionExpired, + } + + assert_eq!( + serde_json::to_string(&EventKind::Login).unwrap(), + r#""UserLoggedIn""# + ); + assert_eq!( + serde_json::to_string(&EventKind::Logout).unwrap(), + r#""UserLoggedOut""# + ); + assert_eq!( + serde_json::to_string(&EventKind::SessionExpired).unwrap(), + r#""SESSION_EXPIRED""# + ); + } + + /// Test combining variant attrs with rename + #[test] + fn should_combine_with_rename() { + #[derive(Kinded, Serialize)] + #[kinded(derive(Default, Serialize))] + enum Level { + #[kinded(rename = "low_level", attrs(default))] + Low, + Medium, + High, + } + + // Default should work + assert_eq!(LevelKind::default(), LevelKind::Low); + + // Display should use rename + assert_eq!(LevelKind::Low.to_string(), "low_level"); + } + + /// Test doc attribute on variants + #[test] + fn should_support_doc_attr() { + #[derive(Kinded)] + enum Color { + #[kinded(attrs(doc = "The color red"))] + Red, + #[kinded(attrs(doc = "The color green"))] + Green, + Blue, + } + + // If it compiles, the doc attributes were applied correctly + let _ = ColorKind::Red; + let _ = ColorKind::Green; + let _ = ColorKind::Blue; + } + + /// Test attrs on variants with data + #[test] + fn should_work_with_data_variants() { + #[derive(Kinded)] + #[kinded(derive(Default))] + enum Container { + #[kinded(attrs(default))] + Empty, + Single(i32), + Multiple { + items: i32, + }, + } + + assert_eq!(ContainerKind::default(), ContainerKind::Empty); + } + + /// Test deserialization with variant attrs + #[test] + fn should_support_deserialization_with_variant_attrs() { + #[derive(Kinded, Serialize, Deserialize)] + #[kinded(derive(Serialize, Deserialize))] + enum Action { + #[kinded(attrs(serde(rename = "create_new")))] + Create, + #[kinded(attrs(serde(rename = "read_existing")))] + Read, + Update, + Delete, + } + + // Serialize + assert_eq!( + serde_json::to_string(&ActionKind::Create).unwrap(), + r#""create_new""# + ); + + // Deserialize + let kind: ActionKind = serde_json::from_str(r#""create_new""#).unwrap(); + assert_eq!(kind, ActionKind::Create); + + let kind: ActionKind = serde_json::from_str(r#""read_existing""#).unwrap(); + assert_eq!(kind, ActionKind::Read); + + // Variants without attrs use default names + let kind: ActionKind = serde_json::from_str(r#""Update""#).unwrap(); + assert_eq!(kind, ActionKind::Update); + } +}