From d39e1f08446bc30f3343adbce53be5738f19d1fa Mon Sep 17 00:00:00 2001 From: Serhii Potapov Date: Thu, 29 Jan 2026 18:35:08 +0100 Subject: [PATCH 1/2] Add support for rename attribute --- CHANGELOG.md | 4 + README.md | 29 ++++++ kinded/src/lib.rs | 29 ++++++ kinded_macros/src/generate/kind_enum.rs | 40 +++++++- kinded_macros/src/models.rs | 3 + kinded_macros/src/parse.rs | 26 +++++ sandbox/src/main.rs | 14 +-- test_suite/src/lib.rs | 130 ++++++++++++++++++++++++ 8 files changed, 264 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07463b9..8ee81b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [Unreleased] +- Add `#[kinded(rename = "...")]` attribute for variants to customize display/parse names. + This is useful when the automatic case conversion doesn't produce the desired result. + ## v0.4.0 - 2025-10-30 - Update to Rust 2024 edition. - no_std support. diff --git a/README.md b/README.md index a660931..a4f27b1 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,35 @@ assert_eq!(tea.to_string(), "very_hot_black_tea"); The possible values are `"snake_case"`, `"camelCase"`, `"PascalCase"`, `"SCREAMING_SNAKE_CASE"`, `"kebab-case"`, `"SCREAMING-KEBAB-CASE"`, `"Title Case"`, `"lowercase"`, `"UPPERCASE"`. +### Rename variants + +Individual variants can have custom display/parse names using the `rename` attribute. +This is useful when the automatic case conversion doesn't produce the desired result: + +```rs +use kinded::Kinded; + +#[derive(Kinded)] +#[kinded(display = "snake_case")] +enum Validator { + NotEmpty, + // Without rename, this would display as "len_utf_16_min" (with extra underscore) + #[kinded(rename = "len_utf16_min")] + LenUtf16Min, + #[kinded(rename = "len_utf16_max")] + LenUtf16Max, +} + +assert_eq!(ValidatorKind::NotEmpty.to_string(), "not_empty"); +assert_eq!(ValidatorKind::LenUtf16Min.to_string(), "len_utf16_min"); +assert_eq!(ValidatorKind::LenUtf16Max.to_string(), "len_utf16_max"); + +// Parsing also works with the renamed values +assert_eq!("len_utf16_min".parse::().unwrap(), ValidatorKind::LenUtf16Min); +``` + +Note: The original variant name and its case alternatives can still be parsed (e.g., `"LenUtf16Min"`, `"len_utf_16_min"`). + ### FromStr trait The kind type implements `FromStr` trait. The implementation tries it's best to parse, checking all the possible cases mentioned above. diff --git a/kinded/src/lib.rs b/kinded/src/lib.rs index 501ed71..d4f623a 100644 --- a/kinded/src/lib.rs +++ b/kinded/src/lib.rs @@ -166,6 +166,35 @@ //! //! The possible values are `"snake_case"`, `"camelCase"`, `"PascalCase"`, `"SCREAMING_SNAKE_CASE"`, `"kebab-case"`, `"SCREAMING-KEBAB-CASE"`, `"Title Case"`, `"lowercase"`, `"UPPERCASE"`. //! +//! ### Rename variants +//! +//! Individual variants can have custom display/parse names using the `rename` attribute. +//! This is useful when the automatic case conversion doesn't produce the desired result: +//! +//! ``` +//! use kinded::Kinded; +//! +//! #[derive(Kinded)] +//! #[kinded(display = "snake_case")] +//! enum Validator { +//! NotEmpty, +//! // Without rename, this would display as "len_utf_16_min" (with extra underscore) +//! #[kinded(rename = "len_utf16_min")] +//! LenUtf16Min, +//! #[kinded(rename = "len_utf16_max")] +//! LenUtf16Max, +//! } +//! +//! assert_eq!(ValidatorKind::NotEmpty.to_string(), "not_empty"); +//! assert_eq!(ValidatorKind::LenUtf16Min.to_string(), "len_utf16_min"); +//! assert_eq!(ValidatorKind::LenUtf16Max.to_string(), "len_utf16_max"); +//! +//! // Parsing also works with the renamed values +//! assert_eq!("len_utf16_min".parse::().unwrap(), ValidatorKind::LenUtf16Min); +//! ``` +//! +//! Note: The original variant name and its case alternatives can still be parsed (e.g., `"LenUtf16Min"`, `"len_utf_16_min"`). +//! //! ## A note about the war in Ukraine πŸ‡ΊπŸ‡¦ //! //! Today I live in Berlin, I have the luxury to live a physically safe life. diff --git a/kinded_macros/src/generate/kind_enum.rs b/kinded_macros/src/generate/kind_enum.rs index d0e0ded..119a2dc 100644 --- a/kinded_macros/src/generate/kind_enum.rs +++ b/kinded_macros/src/generate/kind_enum.rs @@ -65,11 +65,16 @@ fn gen_impl_display_trait(meta: &Meta) -> TokenStream { let maybe_case = meta.kinded_attrs.display; let match_branches = meta.variants.iter().map(|variant| { - let original_variant_name_str = variant.ident.to_string(); - let cased_variant_name = apply_maybe_case(original_variant_name_str, maybe_case); + // Use custom rename if specified, otherwise apply case conversion + let display_name = if let Some(ref rename) = variant.rename { + rename.clone() + } else { + let original_variant_name_str = variant.ident.to_string(); + apply_maybe_case(original_variant_name_str, maybe_case) + }; let variant_name = &variant.ident; quote!( - #kind_name::#variant_name => write!(f, #cased_variant_name) + #kind_name::#variant_name => write!(f, #display_name) ) }); @@ -95,6 +100,18 @@ fn apply_maybe_case(original: String, maybe_display_case: Option) - fn gen_impl_from_str_trait(meta: &Meta) -> TokenStream { let kind_name = meta.kind_name(); + // First priority: match custom renames (if any variant has a rename) + let rename_match_branches: Vec<_> = meta + .variants + .iter() + .filter_map(|variant| { + variant.rename.as_ref().map(|rename| { + let ident = &variant.ident; + quote!(#rename => return Ok(#kind_name::#ident),) + }) + }) + .collect(); + let original_match_branches = meta.variants.iter().map(|variant| { let ident = &variant.ident; let name_str = ident.to_string(); @@ -108,12 +125,27 @@ fn gen_impl_from_str_trait(meta: &Meta) -> TokenStream { quote!(#(#alternatives)|* => return Ok(#kind_name::#ident),) }); + // Only generate the rename match block if there are any renames + let rename_match_block = if rename_match_branches.is_empty() { + quote!() + } else { + quote!( + // First try to match custom renames + match s { + #(#rename_match_branches)* + _ => () + } + ) + }; + quote!( impl ::core::str::FromStr for #kind_name { type Err = ::kinded::ParseKindError; fn from_str(s: &str) -> ::core::result::Result { - // First try to match the variants as they are + #rename_match_block + + // Try to match the variants as they are (original names) match s { // match s { #(#original_match_branches)* // "HotMate" => Mate::HotMate, _ => () // _ => (), diff --git a/kinded_macros/src/models.rs b/kinded_macros/src/models.rs index e41e6e3..9dcd896 100644 --- a/kinded_macros/src/models.rs +++ b/kinded_macros/src/models.rs @@ -61,6 +61,9 @@ impl Meta { pub struct Variant { pub ident: Ident, pub fields_type: FieldsType, + /// Custom display/parse name specified with `#[kinded(rename = "...")]`. + /// When set, this overrides the automatic case conversion for Display and FromStr. + pub rename: Option, } /// This mimics syn::Fields, but without payload. diff --git a/kinded_macros/src/parse.rs b/kinded_macros/src/parse.rs index b30b060..513d030 100644 --- a/kinded_macros/src/parse.rs +++ b/kinded_macros/src/parse.rs @@ -35,12 +35,38 @@ pub fn parse_derive_input(input: DeriveInput) -> Result { } fn parse_variant(variant: &syn::Variant) -> Variant { + let rename = find_variant_kinded_rename(&variant.attrs); Variant { ident: variant.ident.clone(), fields_type: parse_fields_type(&variant.fields), + rename, } } +/// Find `#[kinded(rename = "...")]` attribute on a variant and extract the rename value. +fn find_variant_kinded_rename(attrs: &[Attribute]) -> Option { + 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) + } + }) { + if parsed.is_some() { + return parsed; + } + } + } + } + None +} + fn parse_fields_type(fields: &syn::Fields) -> FieldsType { match fields { syn::Fields::Named(..) => FieldsType::Named, diff --git a/sandbox/src/main.rs b/sandbox/src/main.rs index 6d65a6a..a3b97c1 100644 --- a/sandbox/src/main.rs +++ b/sandbox/src/main.rs @@ -2,7 +2,7 @@ use kinded::Kinded; #[allow(dead_code)] #[derive(Kinded)] -enum Drink { +enum Beverage { Mate, Coffee(String), Tea { variety: String, caffeine: bool }, @@ -11,22 +11,22 @@ enum Drink { fn main() { // Mate { - let drink = Drink::Mate; - assert_eq!(drink.kind(), DrinkKind::Mate); + let drink = Beverage::Mate; + assert_eq!(drink.kind(), BeverageKind::Mate); } // Coffee { - let drink = Drink::Coffee("Espresso".to_owned()); - assert_eq!(drink.kind(), DrinkKind::Coffee); + let drink = Beverage::Coffee("Espresso".to_owned()); + assert_eq!(drink.kind(), BeverageKind::Coffee); } // Tea { - let drink = Drink::Tea { + let drink = Beverage::Tea { variety: "Green".to_owned(), caffeine: true, }; - assert_eq!(drink.kind(), DrinkKind::Tea); + assert_eq!(drink.kind(), BeverageKind::Tea); } } diff --git a/test_suite/src/lib.rs b/test_suite/src/lib.rs index fe35949..e5bfb4f 100644 --- a/test_suite/src/lib.rs +++ b/test_suite/src/lib.rs @@ -352,3 +352,133 @@ fn should_work_with_lifetimes() { let identifier: Identifier = Identifier::Name("Xen"); assert_eq!(identifier.kind(), IdentifierKind::Name); } + +mod rename { + extern crate alloc; + use alloc::string::ToString; + use kinded::Kinded; + + /// Test that rename overrides the Display output + #[test] + fn should_display_renamed_variant() { + #[derive(Kinded)] + enum Validator { + NotEmpty, + #[kinded(rename = "len_utf16_min")] + LenUtf16Min, + } + + assert_eq!(ValidatorKind::NotEmpty.to_string(), "NotEmpty"); + assert_eq!(ValidatorKind::LenUtf16Min.to_string(), "len_utf16_min"); + } + + /// Test that rename overrides the automatic case conversion + #[test] + fn should_override_display_case_with_rename() { + #[derive(Kinded)] + #[kinded(display = "snake_case")] + enum Validator { + NotEmpty, + // Without rename, this would display as "len_utf_16_min" (with extra underscore) + #[kinded(rename = "len_utf16_min")] + LenUtf16Min, + } + + assert_eq!(ValidatorKind::NotEmpty.to_string(), "not_empty"); + assert_eq!(ValidatorKind::LenUtf16Min.to_string(), "len_utf16_min"); + } + + /// Test that FromStr parses the renamed value + #[test] + fn should_parse_renamed_value() { + #[derive(Kinded)] + #[kinded(display = "snake_case")] + enum Validator { + NotEmpty, + #[kinded(rename = "len_utf16_min")] + LenUtf16Min, + } + + // Parse the renamed value + let kind: ValidatorKind = "len_utf16_min".parse().unwrap(); + assert_eq!(kind, ValidatorKind::LenUtf16Min); + } + + /// Test that original variant name and alternatives still parse correctly + #[test] + fn should_still_parse_original_names() { + #[derive(Kinded)] + #[kinded(display = "snake_case")] + enum Validator { + NotEmpty, + #[kinded(rename = "len_utf16_min")] + LenUtf16Min, + } + + // Original name should still work + assert_eq!( + "LenUtf16Min".parse::().unwrap(), + ValidatorKind::LenUtf16Min + ); + + // Alternative cases should also work + assert_eq!( + "len_utf_16_min".parse::().unwrap(), + ValidatorKind::LenUtf16Min + ); + assert_eq!( + "LEN_UTF_16_MIN".parse::().unwrap(), + ValidatorKind::LenUtf16Min + ); + } + + /// Test rename with multiple renamed variants + #[test] + fn should_work_with_multiple_renames() { + #[derive(Kinded)] + #[kinded(display = "snake_case")] + enum Validator { + #[kinded(rename = "len_utf16_min")] + LenUtf16Min, + #[kinded(rename = "len_utf16_max")] + LenUtf16Max, + NotEmpty, + } + + assert_eq!(ValidatorKind::LenUtf16Min.to_string(), "len_utf16_min"); + assert_eq!(ValidatorKind::LenUtf16Max.to_string(), "len_utf16_max"); + assert_eq!(ValidatorKind::NotEmpty.to_string(), "not_empty"); + + assert_eq!( + "len_utf16_min".parse::().unwrap(), + ValidatorKind::LenUtf16Min + ); + assert_eq!( + "len_utf16_max".parse::().unwrap(), + ValidatorKind::LenUtf16Max + ); + } + + /// Test rename with variants that have data + #[test] + fn should_work_with_data_variants() { + #[derive(Kinded)] + enum Action { + #[kinded(rename = "custom_action")] + DoSomething(i32), + #[kinded(rename = "other")] + DoOther { value: i32 }, + Plain, + } + + assert_eq!(ActionKind::DoSomething.to_string(), "custom_action"); + assert_eq!(ActionKind::DoOther.to_string(), "other"); + assert_eq!(ActionKind::Plain.to_string(), "Plain"); + + assert_eq!( + "custom_action".parse::().unwrap(), + ActionKind::DoSomething + ); + assert_eq!("other".parse::().unwrap(), ActionKind::DoOther); + } +} From b5d6af8a1cdebc67a556e8b83b79288123c059ab Mon Sep 17 00:00:00 2001 From: Serhii Potapov Date: Thu, 29 Jan 2026 19:26:10 +0100 Subject: [PATCH 2/2] Fix clippy --- kinded_macros/src/parse.rs | 7 +++---- test_suite/src/lib.rs | 4 +++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/kinded_macros/src/parse.rs b/kinded_macros/src/parse.rs index 513d030..ca553ad 100644 --- a/kinded_macros/src/parse.rs +++ b/kinded_macros/src/parse.rs @@ -57,10 +57,9 @@ fn find_variant_kinded_rename(attrs: &[Attribute]) -> Option { } else { Ok(None) } - }) { - if parsed.is_some() { - return parsed; - } + }) && parsed.is_some() + { + return parsed; } } } diff --git a/test_suite/src/lib.rs b/test_suite/src/lib.rs index e5bfb4f..c20f4c3 100644 --- a/test_suite/src/lib.rs +++ b/test_suite/src/lib.rs @@ -467,7 +467,9 @@ mod rename { #[kinded(rename = "custom_action")] DoSomething(i32), #[kinded(rename = "other")] - DoOther { value: i32 }, + DoOther { + value: i32, + }, Plain, }