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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions kinded/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 19 additions & 5 deletions kinded_macros/src/generate/kind_enum.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::models::{DisplayCase, Meta};
use crate::models::{DisplayCase, Meta, Variant};
use proc_macro2::{Ident, TokenStream};
use quote::quote;

Expand All @@ -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<TokenStream> =
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 {
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions kinded_macros/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Extra attributes to apply to the generated kind variant (e.g., `#[default]`, `#[serde(rename = "...")]`).
pub attrs: Vec<SynMeta>,
}

/// This mimics syn::Fields, but without payload.
Expand Down
66 changes: 50 additions & 16 deletions kinded_macros/src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,34 +36,68 @@ 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);
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<String> {
/// Parsed variant-level #[kinded(...)] attributes
struct VariantKindedAttrs {
rename: Option<String>,
attrs: Vec<SynMeta>,
}

/// 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<String> {
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<SynMeta> {
parse_variant_kinded_attrs(attrs).attrs
}

fn parse_fields_type(fields: &syn::Fields) -> FieldsType {
Expand Down
160 changes: 160 additions & 0 deletions test_suite/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -680,3 +680,163 @@ mod rename {
assert_eq!("other".parse::<ActionKind>().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);
}
}
Loading