diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92c683b..c9de397 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,4 +61,23 @@ jobs: uses: actions-rs/cargo@v1 with: command: clippy - args: -- -D warnings + args: --workspace -- -D warnings + + examples: + name: Examples + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + + - name: Run examples + run: | + for example in examples/*/; do + echo "Running $example" + cargo run --manifest-path "${example}Cargo.toml" + done diff --git a/CHANGELOG.md b/CHANGELOG.md index 24af51c..91184f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ -## Unreleased +## v0.5.0 - 2026-02-xx +- Add `skip_derive` attribute to opt out of default trait implementations (fixes #19). - 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) diff --git a/Cargo.lock b/Cargo.lock index a536904..00cf1f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,13 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "basic" +version = "0.1.0" +dependencies = [ + "kinded", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -11,6 +18,81 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "enumset_example" +version = "0.1.0" +dependencies = [ + "enumset", + "kinded", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "itoa" version = "1.0.10" @@ -36,9 +118,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.79" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] diff --git a/Cargo.toml b/Cargo.toml index e14247b..20dee46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,4 +7,6 @@ members = [ "kinded_macros", "sandbox", "test_suite", + "examples/basic", + "examples/enumset_example", ] diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..0b959c4 --- /dev/null +++ b/Justfile @@ -0,0 +1,35 @@ +all: fmt test-all clippy examples typos + +test-all: test test-doc + +test: + cargo test --workspace + +test-doc: + cd kinded && cargo test --doc + cd kinded_macros && cargo test --doc + +fmt: + cargo fmt + +clippy: + cargo clippy --workspace -- -D warnings + +examples: + #!/usr/bin/env bash + set -euxo pipefail + ROOT_DIR=$(pwd) + for EXAMPLE in $(ls examples); do + cd "$ROOT_DIR/examples/$EXAMPLE" + cargo run + done + +watch: + bacon + +watch-sandbox: + bacon run --path sandbox + +typos: + which typos >/dev/null || cargo install typos-cli + typos diff --git a/README.md b/README.md index a2789b9..81fa550 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,44 @@ let mut drink_kinds = HashSet::new(); drink_kinds.insert(DrinkKind::Mate); ``` +### Skip default traits + +In some cases you may need to opt out of default trait implementations. +For example, when using `kinded` with crates like `enumset` that provide their own trait implementations, +you can use `skip_derive(..)` to avoid conflicts: + +```rs +use kinded::Kinded; + +#[derive(Kinded)] +#[kinded(skip_derive(Display, FromStr))] +enum Task { + Download { url: String }, + Process(Vec), +} + +// Display and FromStr are not implemented for TaskKind +// You can provide your own custom implementations if needed +``` + +The following traits can be skipped: +- Derived traits: `Debug`, `Clone`, `Copy`, `PartialEq`, `Eq` +- Implemented traits: `Display`, `FromStr`, `From` + +You can combine `skip_derive` with `derive` to replace default traits: + +```rs +use kinded::Kinded; + +#[derive(Kinded)] +#[kinded(skip_derive(Display), derive(Hash))] +enum Expr { + Literal(i64), + Variable(String), + BinaryOp { left: Box, op: char, right: Box }, +} +``` + ### Extra attributes If you're using derive macros from other libraries like Serde or Strum, you may want to add @@ -298,7 +336,7 @@ But I am Ukrainian. The first 25 years of my life I spent in [Kharkiv](https://e the second-largest city in Ukraine, 60km away from the border with russia. Today about [a third of my home city is destroyed](https://www.youtube.com/watch?v=ihoufBFSZds) by russians. My parents, my relatives and my friends had to survive the artillery and air attack, living for over a month in basements. -Some of them have managed to evacuate to EU. Some others are trying to live "normal lifes" in Kharkiv, doing there daily duties. +Some of them have managed to evacuate to EU. Some others are trying to live "normal lives" in Kharkiv, doing there daily duties. And some are at the front line right now, risking their lives every second to protect the rest. I encourage you to donate to [Charity foundation of Serhiy Prytula](https://prytulafoundation.org/en). diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml new file mode 100644 index 0000000..afe369c --- /dev/null +++ b/examples/basic/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "basic" +version = "0.1.0" +edition = "2024" + +[dependencies] +kinded = { path = "../../kinded" } diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs new file mode 100644 index 0000000..18c76c8 --- /dev/null +++ b/examples/basic/src/main.rs @@ -0,0 +1,67 @@ +use kinded::Kinded; + +#[derive(Kinded)] +enum Drink { + Mate, + Coffee(String), + Tea { variety: String, caffeine: bool }, +} + +fn main() { + // Create enum variants with associated data + let espresso = Drink::Coffee("Espresso".to_owned()); + let green_tea = Drink::Tea { + variety: "Sencha".to_owned(), + caffeine: true, + }; + let mate = Drink::Mate; + + // Use .kind() to get the kind without associated data + assert_eq!(espresso.kind(), DrinkKind::Coffee); + assert_eq!(green_tea.kind(), DrinkKind::Tea); + assert_eq!(mate.kind(), DrinkKind::Mate); + + // Get all kind variants + assert_eq!( + DrinkKind::all(), + [DrinkKind::Mate, DrinkKind::Coffee, DrinkKind::Tea] + ); + + // Pattern match on kind and access original data + let description = match &espresso { + Drink::Mate => "Just mate".to_owned(), + Drink::Coffee(name) => format!("Coffee: {name}"), + Drink::Tea { variety, caffeine } => { + format!("Tea: {variety}, caffeine: {caffeine}") + } + }; + assert_eq!(description, "Coffee: Espresso"); + + // Display trait + assert_eq!(DrinkKind::Coffee.to_string(), "Coffee"); + assert_eq!(DrinkKind::Tea.to_string(), "Tea"); + assert_eq!(DrinkKind::Mate.to_string(), "Mate"); + + // FromStr trait + assert_eq!("Tea".parse::().unwrap(), DrinkKind::Tea); + assert_eq!("Coffee".parse::().unwrap(), DrinkKind::Coffee); + assert_eq!("Mate".parse::().unwrap(), DrinkKind::Mate); + + // FromStr is case-insensitive + assert_eq!("tea".parse::().unwrap(), DrinkKind::Tea); + assert_eq!("TEA".parse::().unwrap(), DrinkKind::Tea); + + // From trait - convert from Drink to DrinkKind + assert_eq!(DrinkKind::from(&espresso), DrinkKind::Coffee); + assert_eq!(DrinkKind::from(&green_tea), DrinkKind::Tea); + assert_eq!(DrinkKind::from(&mate), DrinkKind::Mate); + + // Kind implements Copy, Clone, PartialEq, Eq, Debug + let kind = DrinkKind::Coffee; + let kind_copy = kind; // Copy + #[allow(clippy::clone_on_copy)] + let kind_clone = kind.clone(); // Clone + assert_eq!(kind, kind_copy); // PartialEq + assert_eq!(kind, kind_clone); + assert_eq!(format!("{:?}", kind), "Coffee"); // Debug +} diff --git a/examples/enumset_example/Cargo.toml b/examples/enumset_example/Cargo.toml new file mode 100644 index 0000000..63b748d --- /dev/null +++ b/examples/enumset_example/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "enumset_example" +version = "0.1.0" +edition = "2024" + +[dependencies] +kinded = { path = "../../kinded" } +enumset = "1" diff --git a/examples/enumset_example/src/main.rs b/examples/enumset_example/src/main.rs new file mode 100644 index 0000000..0794cf4 --- /dev/null +++ b/examples/enumset_example/src/main.rs @@ -0,0 +1,121 @@ +//! This example demonstrates how to use `kinded` with `enumset`. +//! +//! The problem: `enumset`'s `EnumSetType` only works on fieldless enums, +//! but you may have an enum with associated data that you want to use with EnumSet. +//! +//! The solution: Use `kinded` to generate a fieldless "kind" enum, then derive +//! `EnumSetType` on that kind enum. +//! +//! The catch: Both `kinded` and `enumset` implement `Copy`, `Clone`, `PartialEq`, `Eq` +//! by default, which causes trait implementation conflicts. +//! +//! The fix: Use `skip_derive` to prevent `kinded` from implementing those traits, +//! letting `enumset` handle them instead. + +use enumset::{EnumSet, EnumSetType}; +use kinded::Kinded; + +/// A permission with associated data - cannot directly use EnumSetType. +/// +/// We use kinded to generate `PermissionKind` (a fieldless enum), and: +/// - `skip_derive(Clone, Copy, PartialEq, Eq)` to avoid conflicts with enumset +/// - `derive(EnumSetType)` to make the kind enum work with EnumSet +/// - `attrs(enumset(...), repr(u8))` to configure enumset +#[derive(Kinded)] +#[kinded( + skip_derive(Clone, Copy, PartialEq, Eq), + derive(EnumSetType), + attrs(enumset(repr = "u8"), repr(u8)) +)] +enum Permission { + Read { path: String }, + Write { path: String }, + Execute { command: String }, + Admin, +} + +/// Check if a permission is allowed by a permission set, and return a description. +fn check_permission(perm: &Permission, allowed: EnumSet) -> String { + if allowed.contains(perm.kind()) { + match perm { + Permission::Read { path } => format!("Allowed to read: {path}"), + Permission::Write { path } => format!("Allowed to write: {path}"), + Permission::Execute { command } => format!("Allowed to execute: {command}"), + Permission::Admin => "Admin access granted".to_owned(), + } + } else { + format!("Permission denied: {:?}", perm.kind()) + } +} + +fn main() { + // Create permissions with associated data + let read_home = Permission::Read { + path: "/home".to_owned(), + }; + let write_tmp = Permission::Write { + path: "/tmp".to_owned(), + }; + let exec_ls = Permission::Execute { + command: "ls".to_owned(), + }; + let admin = Permission::Admin; + + // Extract kinds from permissions + assert_eq!(read_home.kind(), PermissionKind::Read); + assert_eq!(write_tmp.kind(), PermissionKind::Write); + assert_eq!(exec_ls.kind(), PermissionKind::Execute); + assert_eq!(admin.kind(), PermissionKind::Admin); + + // Now use EnumSet with the kind enum + let user_permissions: EnumSet = PermissionKind::Read | PermissionKind::Write; + + let admin_permissions: EnumSet = EnumSet::all(); + + // Check if a permission kind is in the set + assert!(user_permissions.contains(PermissionKind::Read)); + assert!(user_permissions.contains(PermissionKind::Write)); + assert!(!user_permissions.contains(PermissionKind::Admin)); + + // Admin has all permissions + assert!(admin_permissions.contains(PermissionKind::Admin)); + assert!(admin_permissions.contains(PermissionKind::Execute)); + + // Check if a specific permission instance is allowed + assert!(user_permissions.contains(read_home.kind())); + assert!(user_permissions.contains(write_tmp.kind())); + assert!(!user_permissions.contains(exec_ls.kind())); + + // Set operations + let execute_only: EnumSet = EnumSet::only(PermissionKind::Execute); + let combined = user_permissions | execute_only; + assert_eq!(combined.len(), 3); + assert!(combined.contains(PermissionKind::Read)); + assert!(combined.contains(PermissionKind::Write)); + assert!(combined.contains(PermissionKind::Execute)); + + // Iterate over permission kinds in a set + let mut count = 0; + for _kind in user_permissions { + count += 1; + } + assert_eq!(count, 2); + + // Use the helper function to check permissions with their data + assert_eq!( + check_permission(&read_home, user_permissions), + "Allowed to read: /home" + ); + assert_eq!( + check_permission(&write_tmp, user_permissions), + "Allowed to write: /tmp" + ); + assert_eq!( + check_permission(&exec_ls, user_permissions), + "Permission denied: Execute" + ); + assert_eq!( + check_permission(&admin, admin_permissions), + "Admin access granted" + ); +} diff --git a/kinded/src/lib.rs b/kinded/src/lib.rs index 2c85015..7b2a78b 100644 --- a/kinded/src/lib.rs +++ b/kinded/src/lib.rs @@ -120,7 +120,7 @@ //! //! ### Derive traits //! -//! By default the kind type implements the following traits: `Debug`, `Clone`, `Copy`, `PartialEq`, `Eq`, `From`, `From<&T>`. +//! By default the kind type implements the following traits: `Debug`, `Clone`, `Copy`, `PartialEq`, `Eq`, `Display`, `FromStr`, `From`, `From<&T>`. //! //! Extra traits can be derived with `derive(..)` attribute: //! @@ -140,6 +140,41 @@ //! drink_kinds.insert(DrinkKind::Mate); //! ``` //! +//! ### Skip default traits +//! +//! In some cases you may need to opt out of default trait implementations. +//! For example, when using `kinded` with crates like `enumset` that provide their own trait implementations, +//! you can use `skip_derive(..)` to avoid conflicts: +//! +//! ``` +//! use kinded::Kinded; +//! +//! #[derive(Kinded)] +//! #[kinded(skip_derive(Display, FromStr))] +//! enum Task { +//! Download { url: String }, +//! Process(Vec), +//! } +//! +//! // Display and FromStr are not implemented for TaskKind +//! // You can provide your own custom implementations if needed +//! ``` +//! +//! The following traits can be skipped: +//! - Derived traits: `Debug`, `Clone`, `Copy`, `PartialEq`, `Eq` +//! - Implemented traits: `Display`, `FromStr`, `From` +//! +//! You can combine `skip_derive` with `derive` to replace default traits: +//! +//! ``` +//! #[derive(kinded::Kinded)] +//! #[kinded(skip_derive(Display), derive(Hash))] +//! enum Expr { +//! Literal(i64), +//! Variable(String), +//! } +//! ``` +//! //! ### Generic attributes //! //! If you're using derive traits from other libraries like Serde or Sqlx, you might want to add @@ -274,7 +309,7 @@ //! the second-largest city in Ukraine, 60km away from the border with russia. Today about [a third of my home city is destroyed](https://www.youtube.com/watch?v=ihoufBFSZds) by russians. //! My parents, my relatives and my friends had to survive the artillery and air attack, living for over a month in basements. //! -//! Some of them have managed to evacuate to EU. Some others are trying to live "normal lifes" in Kharkiv, doing there daily duties. +//! Some of them have managed to evacuate to EU. Some others are trying to live "normal lives" in Kharkiv, doing there daily duties. //! And some are at the front line right now, risking their lives every second to protect the rest. //! //! I encourage you to donate to [Charity foundation of Serhiy Prytula](https://prytulafoundation.org/en). diff --git a/kinded_macros/src/generate/kind_enum.rs b/kinded_macros/src/generate/kind_enum.rs index e0d0b07..cc45420 100644 --- a/kinded_macros/src/generate/kind_enum.rs +++ b/kinded_macros/src/generate/kind_enum.rs @@ -1,14 +1,30 @@ -use crate::models::{DisplayCase, Meta, Variant}; +use crate::models::{DisplayCase, Meta, Trait, Variant}; use proc_macro2::{Ident, TokenStream}; use quote::quote; pub fn gen_kind_enum(meta: &Meta) -> TokenStream { let kind_enum_definition = gen_definition(meta); - let impl_from_traits = gen_impl_from_traits(meta); - let impl_display_trait = gen_impl_display_trait(meta); - let impl_from_str_trait = gen_impl_from_str_trait(meta); let impl_kind_trait = gen_impl_kind_trait(meta); + // Conditionally generate trait implementations based on skip_derive + let impl_from_traits = if meta.kinded_attrs.should_skip(Trait::From) { + quote!() + } else { + gen_impl_from_traits(meta) + }; + + let impl_display_trait = if meta.kinded_attrs.should_skip(Trait::Display) { + quote!() + } else { + gen_impl_display_trait(meta) + }; + + let impl_from_str_trait = if meta.kinded_attrs.should_skip(Trait::FromStr) { + quote!() + } else { + gen_impl_from_str_trait(meta) + }; + quote!( #kind_enum_definition #impl_from_traits diff --git a/kinded_macros/src/models.rs b/kinded_macros/src/models.rs index c83a987..d177b89 100644 --- a/kinded_macros/src/models.rs +++ b/kinded_macros/src/models.rs @@ -1,7 +1,77 @@ use proc_macro2::{Ident, TokenStream}; use quote::{format_ident, quote}; +use std::collections::HashSet; use syn::{Generics, Meta as SynMeta, Path, Visibility}; +/// Traits that are automatically implemented for the generated kind enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Trait { + // Derived via #[derive(...)] + Debug, + Clone, + Copy, + PartialEq, + Eq, + // Manually implemented + Display, + FromStr, + From, +} + +impl Trait { + /// Traits that are derived by default via `#[derive(...)]`. + pub const fn default_derives() -> &'static [Trait] { + &[ + Trait::Debug, + Trait::Clone, + Trait::Copy, + Trait::PartialEq, + Trait::Eq, + ] + } + + /// All traits that can be skipped. + pub const fn all() -> &'static [Trait] { + &[ + Trait::Debug, + Trait::Clone, + Trait::Copy, + Trait::PartialEq, + Trait::Eq, + Trait::Display, + Trait::FromStr, + Trait::From, + ] + } + + pub const fn as_str(&self) -> &'static str { + match self { + Trait::Debug => "Debug", + Trait::Clone => "Clone", + Trait::Copy => "Copy", + Trait::PartialEq => "PartialEq", + Trait::Eq => "Eq", + Trait::Display => "Display", + Trait::FromStr => "FromStr", + Trait::From => "From", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "Debug" => Some(Trait::Debug), + "Clone" => Some(Trait::Clone), + "Copy" => Some(Trait::Copy), + "PartialEq" => Some(Trait::PartialEq), + "Eq" => Some(Trait::Eq), + "Display" => Some(Trait::Display), + "FromStr" => Some(Trait::FromStr), + "From" => Some(Trait::From), + _ => None, + } + } +} + #[derive(Debug)] pub struct Meta { /// Visibility of enum. @@ -30,11 +100,10 @@ impl Meta { /// Get the traits that need to be derived. pub fn derive_traits(&self) -> Vec { - const DEFAULT_DERIVE_TRAITS: &[&str] = &["Debug", "Clone", "Copy", "PartialEq", "Eq"]; - - let mut traits: Vec = DEFAULT_DERIVE_TRAITS + let mut traits: Vec = Trait::default_derives() .iter() - .map(|trait_name| Path::from(format_ident!("{trait_name}"))) + .filter(|t| !self.kinded_attrs.should_skip(**t)) + .map(|t| Path::from(format_ident!("{}", t.as_str()))) .collect(); // Add the extra specified traits, if they're different from the default ones @@ -94,6 +163,11 @@ pub struct KindedAttributes { /// Traits to derive, specified with `derive(...)` pub derive: Option>, + /// Opt out default derives/implementations for traits like Debug, Clone, Copy, PartialEq, Eq, + /// FromStr, Display, etc. It may be needed in some cases for compatibility with other crates + /// that provide similar macros. See https://github.com/greyblake/kinded/pull/19 + pub skip_derive: Option>, + /// Attributes to customize implementation for Display trait pub display: Option, @@ -101,6 +175,16 @@ pub struct KindedAttributes { pub meta_attrs: Option>, } +impl KindedAttributes { + /// Check if a trait should be skipped from derive/implementation. + pub fn should_skip(&self, t: Trait) -> bool { + self.skip_derive + .as_ref() + .map(|traits| traits.contains(&t)) + .unwrap_or(false) + } +} + /// This uses the same names as serde + "Title Case" variant. /// Some names are different from what `convert_case` crate uses. #[derive(Debug, Clone, Copy)] @@ -175,3 +259,119 @@ impl DisplayCase { s.to_case(case) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + use syn::parse_quote; + + fn create_meta(kinded_attrs: KindedAttributes) -> Meta { + Meta { + vis: Visibility::Inherited, + ident: format_ident!("TestEnum"), + generics: Generics::default(), + variants: vec![], + kinded_attrs, + } + } + + #[test] + fn derive_traits_default() { + let meta = create_meta(KindedAttributes::default()); + let traits: Vec = meta + .derive_traits() + .iter() + .map(|p| quote!(#p).to_string()) + .collect(); + assert_eq!(traits, vec!["Debug", "Clone", "Copy", "PartialEq", "Eq"]); + } + + #[test] + fn derive_traits_with_extra() { + let meta = create_meta(KindedAttributes { + derive: Some(vec![parse_quote!(Hash), parse_quote!(Serialize)]), + ..Default::default() + }); + let traits: Vec = meta + .derive_traits() + .iter() + .map(|p| quote!(#p).to_string()) + .collect(); + assert_eq!( + traits, + vec![ + "Debug", + "Clone", + "Copy", + "PartialEq", + "Eq", + "Hash", + "Serialize" + ] + ); + } + + #[test] + fn derive_traits_skip_one() { + let meta = create_meta(KindedAttributes { + skip_derive: Some(HashSet::from([Trait::Clone])), + ..Default::default() + }); + let traits: Vec = meta + .derive_traits() + .iter() + .map(|p| quote!(#p).to_string()) + .collect(); + assert_eq!(traits, vec!["Debug", "Copy", "PartialEq", "Eq"]); + } + + #[test] + fn derive_traits_skip_multiple() { + let meta = create_meta(KindedAttributes { + skip_derive: Some(HashSet::from([Trait::Clone, Trait::Copy, Trait::Eq])), + ..Default::default() + }); + let traits: Vec = meta + .derive_traits() + .iter() + .map(|p| quote!(#p).to_string()) + .collect(); + assert_eq!(traits, vec!["Debug", "PartialEq"]); + } + + #[test] + fn derive_traits_skip_all() { + let meta = create_meta(KindedAttributes { + skip_derive: Some(HashSet::from([ + Trait::Debug, + Trait::Clone, + Trait::Copy, + Trait::PartialEq, + Trait::Eq, + ])), + ..Default::default() + }); + let traits: Vec = meta + .derive_traits() + .iter() + .map(|p| quote!(#p).to_string()) + .collect(); + assert!(traits.is_empty()); + } + + #[test] + fn derive_traits_skip_and_add() { + let meta = create_meta(KindedAttributes { + derive: Some(vec![parse_quote!(Hash)]), + skip_derive: Some(HashSet::from([Trait::Clone, Trait::Copy])), + ..Default::default() + }); + let traits: Vec = meta + .derive_traits() + .iter() + .map(|p| quote!(#p).to_string()) + .collect(); + assert_eq!(traits, vec!["Debug", "PartialEq", "Eq", "Hash"]); + } +} diff --git a/kinded_macros/src/parse.rs b/kinded_macros/src/parse.rs index de26a2f..17c58f2 100644 --- a/kinded_macros/src/parse.rs +++ b/kinded_macros/src/parse.rs @@ -1,6 +1,7 @@ -use crate::models::{DisplayCase, FieldsType, KindedAttributes, Meta, Variant}; +use crate::models::{DisplayCase, FieldsType, KindedAttributes, Meta, Trait, Variant}; use proc_macro2::Ident; use quote::ToTokens; +use std::collections::HashSet; use syn::{ Attribute, Data, DeriveInput, LitStr, Meta as SynMeta, Path, Token, bracketed, parenthesized, parse::{Parse, ParseStream}, @@ -168,6 +169,36 @@ impl Parse for KindedAttributes { let msg = format!("Duplicated attribute: {attr_name}"); return Err(syn::Error::new(attr_name.span(), msg)); } + } else if attr_name == "skip_derive" { + let skip_input; + parenthesized!(skip_input in input); + let parsed_idents = skip_input.parse_terminated(Ident::parse, Token![,])?; + + // Convert Idents to Trait enum values with validation + let mut traits: HashSet = HashSet::new(); + for ident in parsed_idents { + match Trait::from_str(&ident.to_string()) { + Some(t) => { + traits.insert(t); + } + None => { + let allowed: Vec<&str> = + Trait::all().iter().map(|t| t.as_str()).collect(); + let msg = format!( + "Unknown trait to skip: `{ident}`. Allowed traits: {}", + allowed.join(", ") + ); + return Err(syn::Error::new(ident.span(), msg)); + } + } + } + + if kinded_attrs.skip_derive.is_none() { + kinded_attrs.skip_derive = Some(traits); + } else { + let msg = format!("Duplicated attribute: {attr_name}"); + return Err(syn::Error::new(attr_name.span(), msg)); + } } else if attr_name == "display" { let _: Token!(=) = input.parse()?; let case_lit_str: LitStr = input.parse()?; @@ -228,3 +259,93 @@ impl Parse for KindedAttributes { Ok(kinded_attrs) } } + +#[cfg(test)] +mod tests { + use super::*; + use quote::quote; + + fn parse_kinded_attrs(tokens: proc_macro2::TokenStream) -> syn::Result { + syn::parse2(tokens) + } + + #[test] + fn parse_skip_derive_single() { + let attrs = parse_kinded_attrs(quote! { #[kinded(skip_derive(Clone))] }).unwrap(); + let skip = attrs.skip_derive.unwrap(); + assert_eq!(skip, HashSet::from([Trait::Clone])); + } + + #[test] + fn parse_skip_derive_multiple() { + let attrs = + parse_kinded_attrs(quote! { #[kinded(skip_derive(Clone, Copy, Debug))] }).unwrap(); + let skip = attrs.skip_derive.unwrap(); + assert_eq!( + skip, + HashSet::from([Trait::Clone, Trait::Copy, Trait::Debug]) + ); + } + + #[test] + fn parse_skip_derive_all_allowed() { + let attrs = parse_kinded_attrs(quote! { + #[kinded(skip_derive(Debug, Clone, Copy, PartialEq, Eq, Display, FromStr, From))] + }) + .unwrap(); + let skip = attrs.skip_derive.unwrap(); + assert_eq!( + skip, + HashSet::from([ + Trait::Debug, + Trait::Clone, + Trait::Copy, + Trait::PartialEq, + Trait::Eq, + Trait::Display, + Trait::FromStr, + Trait::From, + ]) + ); + } + + #[test] + fn parse_skip_derive_with_other_attrs() { + let attrs = parse_kinded_attrs(quote! { + #[kinded(kind = MyKind, skip_derive(Clone, Copy), derive(Hash))] + }) + .unwrap(); + + assert_eq!(attrs.kind.unwrap().to_string(), "MyKind"); + + let skip = attrs.skip_derive.unwrap(); + assert_eq!(skip, HashSet::from([Trait::Clone, Trait::Copy])); + + let derive: Vec = attrs + .derive + .unwrap() + .iter() + .map(|p| quote!(#p).to_string()) + .collect(); + assert_eq!(derive, vec!["Hash"]); + } + + #[test] + fn parse_skip_derive_invalid_trait() { + let result = parse_kinded_attrs(quote! { #[kinded(skip_derive(Hash))] }); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Unknown trait to skip")); + assert!(err.contains("Hash")); + } + + #[test] + fn parse_skip_derive_duplicated() { + let result = parse_kinded_attrs(quote! { + #[kinded(skip_derive(Clone), skip_derive(Copy))] + }); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Duplicated attribute")); + } +} diff --git a/test_suite/src/lib.rs b/test_suite/src/lib.rs index fe2bd33..3391acc 100644 --- a/test_suite/src/lib.rs +++ b/test_suite/src/lib.rs @@ -840,3 +840,189 @@ mod variant_attrs { assert_eq!(kind, ActionKind::Update); } } + +mod skip_derive { + use super::*; + + mod skip_display { + use super::*; + + #[derive(Kinded)] + #[kinded(skip_derive(Display))] + enum Priority { + Low, + High, + } + + // Display is skipped, so we can't use format!("{}", kind) + // but Debug should still work + #[test] + fn should_still_have_debug() { + let kind = PriorityKind::Low; + let debug_str = alloc::format!("{:?}", kind); + assert_eq!(debug_str, "Low"); + } + + #[test] + fn should_still_have_from_str() { + use core::str::FromStr; + let kind = PriorityKind::from_str("Low").unwrap(); + assert_eq!(kind, PriorityKind::Low); + } + + #[test] + fn should_still_have_clone_copy() { + let kind = PriorityKind::Low; + let cloned = kind.clone(); + let copied = kind; + assert_eq!(cloned, copied); + } + } + + mod skip_from_str { + use super::*; + + #[derive(Kinded)] + #[kinded(skip_derive(FromStr))] + enum Level { + Info, + Warn, + } + + // FromStr is skipped, but Display should still work + #[test] + fn should_still_have_display() { + let kind = LevelKind::Info; + let display_str = alloc::format!("{}", kind); + assert_eq!(display_str, "Info"); + } + + #[test] + fn should_still_have_from() { + let level = Level::Info; + let kind: LevelKind = level.into(); + assert_eq!(kind, LevelKind::Info); + } + } + + mod skip_from { + use super::*; + + #[derive(Kinded)] + #[kinded(skip_derive(From))] + enum Event { + Click, + Hover, + } + + // From is skipped, but kind() method should still work + #[test] + fn should_still_have_kind_method() { + let event = Event::Click; + assert_eq!(event.kind(), EventKind::Click); + } + + #[test] + fn should_still_have_display() { + let kind = EventKind::Click; + let display_str = alloc::format!("{}", kind); + assert_eq!(display_str, "Click"); + } + + #[test] + fn should_still_have_from_str() { + use core::str::FromStr; + let kind = EventKind::from_str("Click").unwrap(); + assert_eq!(kind, EventKind::Click); + } + } + + mod skip_multiple_impl_traits { + use super::*; + + #[derive(Kinded)] + #[kinded(skip_derive(Display, FromStr, From))] + enum Minimal { + A, + B, + } + + #[test] + fn should_still_have_debug() { + let kind = MinimalKind::A; + let debug_str = alloc::format!("{:?}", kind); + assert_eq!(debug_str, "A"); + } + + #[test] + fn should_still_have_partial_eq_eq() { + assert_eq!(MinimalKind::A, MinimalKind::A); + assert_ne!(MinimalKind::A, MinimalKind::B); + } + + #[test] + fn should_still_have_clone_copy() { + let kind = MinimalKind::A; + let cloned = kind.clone(); + let copied = kind; + assert_eq!(cloned, copied); + } + + #[test] + fn should_still_have_kind_method() { + let val = Minimal::A; + assert_eq!(val.kind(), MinimalKind::A); + } + + #[test] + fn should_still_have_all_method() { + assert_eq!(MinimalKind::all(), &[MinimalKind::A, MinimalKind::B]); + } + } + + mod skip_display_and_add_hash { + use super::*; + use core::hash::{Hash, Hasher}; + + #[derive(Kinded)] + #[kinded(skip_derive(Display), derive(Hash))] + enum Token { + Ident, + Number, + } + + #[test] + fn should_have_hash() { + // Verify Hash is implemented by using it + struct DummyHasher(u64); + impl Hasher for DummyHasher { + fn finish(&self) -> u64 { + self.0 + } + fn write(&mut self, bytes: &[u8]) { + for &b in bytes { + self.0 = self.0.wrapping_add(b as u64); + } + } + } + + let mut hasher = DummyHasher(0); + TokenKind::Ident.hash(&mut hasher); + let hash1 = hasher.finish(); + + let mut hasher = DummyHasher(0); + TokenKind::Number.hash(&mut hasher); + let hash2 = hasher.finish(); + + // Different variants should (likely) have different hashes + assert_ne!(hash1, hash2); + } + + #[test] + fn should_still_have_from_str() { + use core::str::FromStr; + let kind = TokenKind::from_str("Ident").unwrap(); + assert_eq!(kind, TokenKind::Ident); + } + } +}