Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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::<ValidatorKind>().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.
Expand Down
29 changes: 29 additions & 0 deletions kinded/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<ValidatorKind>().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.
Expand Down
40 changes: 36 additions & 4 deletions kinded_macros/src/generate/kind_enum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
});

Expand All @@ -95,6 +100,18 @@ fn apply_maybe_case(original: String, maybe_display_case: Option<DisplayCase>) -
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();
Expand All @@ -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<Self, Self::Err> {
// 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,
_ => () // _ => (),
Expand Down
3 changes: 3 additions & 0 deletions kinded_macros/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

/// This mimics syn::Fields, but without payload.
Expand Down
25 changes: 25 additions & 0 deletions kinded_macros/src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,37 @@ pub fn parse_derive_input(input: DeriveInput) -> Result<Meta, syn::Error> {
}

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<String> {
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)
}
}) && parsed.is_some()
{
return parsed;
}
}
}
None
}

fn parse_fields_type(fields: &syn::Fields) -> FieldsType {
match fields {
syn::Fields::Named(..) => FieldsType::Named,
Expand Down
14 changes: 7 additions & 7 deletions sandbox/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use kinded::Kinded;

#[allow(dead_code)]
#[derive(Kinded)]
enum Drink {
enum Beverage {
Mate,
Coffee(String),
Tea { variety: String, caffeine: bool },
Expand All @@ -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);
}
}
132 changes: 132 additions & 0 deletions test_suite/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,3 +352,135 @@ fn should_work_with_lifetimes() {
let identifier: Identifier<i32> = 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::<ValidatorKind>().unwrap(),
ValidatorKind::LenUtf16Min
);

// Alternative cases should also work
assert_eq!(
"len_utf_16_min".parse::<ValidatorKind>().unwrap(),
ValidatorKind::LenUtf16Min
);
assert_eq!(
"LEN_UTF_16_MIN".parse::<ValidatorKind>().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::<ValidatorKind>().unwrap(),
ValidatorKind::LenUtf16Min
);
assert_eq!(
"len_utf16_max".parse::<ValidatorKind>().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::<ActionKind>().unwrap(),
ActionKind::DoSomething
);
assert_eq!("other".parse::<ActionKind>().unwrap(), ActionKind::DoOther);
}
}
Loading