From 28b396bc5340f789572ee19a26c68caec3900eec Mon Sep 17 00:00:00 2001 From: tison Date: Thu, 15 Jan 2026 18:09:11 +0800 Subject: [PATCH 1/4] feat(from_str): support rename attribute --- impl/src/from_str.rs | 136 ++++++++++++++++++++++++++++++++++++++----- impl/src/utils.rs | 49 ++++++++++++++++ 2 files changed, 170 insertions(+), 15 deletions(-) diff --git a/impl/src/from_str.rs b/impl/src/from_str.rs index ed5b9b76..907aafbd 100644 --- a/impl/src/from_str.rs +++ b/impl/src/from_str.rs @@ -136,6 +136,7 @@ struct FlatExpansion<'i> { matches: Vec<( &'i syn::Ident, Either<&'i syn::DataStruct, &'i syn::Variant>, + Option, Option, )>, @@ -165,7 +166,7 @@ impl<'i> TryFrom<&'i syn::DeriveInput> for FlatExpansion<'i> { "only structs with no fields can derive `FromStr`", )); } - vec![(&input.ident, Either::Left(data), None)] + vec![(&input.ident, Either::Left(data), None, None)] } syn::Data::Enum(data) => data .variants @@ -177,10 +178,16 @@ impl<'i> TryFrom<&'i syn::DeriveInput> for FlatExpansion<'i> { "only enums with no fields can derive `FromStr`", )); } - let attr = - attr::RenameAll::parse_attrs(&variant.attrs, attr_ident)? - .map(Spanning::into_inner); - Ok((&variant.ident, Either::Right(variant), attr)) + let attrs = + FlatVariantAttributes::parse_attrs(&variant.attrs, attr_ident)? + .map(Spanning::into_inner) + .unwrap_or_default(); + Ok(( + &variant.ident, + Either::Right(variant), + attrs.rename, + attrs.rename_all, + )) }) .collect::>()?, syn::Data::Union(_) => { @@ -200,10 +207,18 @@ impl<'i> TryFrom<&'i syn::DeriveInput> for FlatExpansion<'i> { let mut similar_matches = >>::new(); if rename_all.is_none() { - for (ident, _, renaming) in &matches { + for (ident, _, rename, renaming) in &matches { let name = ident.to_string(); let lowercased = name.to_lowercase(); - if let Some(rename) = renaming { + if let Some(rename) = rename { + let renamed_lowercased = rename.as_str().to_lowercase(); + if renamed_lowercased != lowercased { + similar_matches + .entry(renamed_lowercased) + .or_default() + .push(*ident); + } + } else if let Some(rename) = renaming { let renamed_lowercased = rename.convert_case(&name); if renamed_lowercased != lowercased { similar_matches @@ -217,9 +232,11 @@ impl<'i> TryFrom<&'i syn::DeriveInput> for FlatExpansion<'i> { } let mut exact_matches = >>::new(); - for (ident, _, renaming) in &matches { + for (ident, _, rename, renaming) in &matches { let name = ident.to_string(); - let exact = if let Some(default_renaming) = &rename_all { + let exact = if let Some(rename) = rename { + rename.as_str().to_owned() + } else if let Some(default_renaming) = &rename_all { renaming .as_ref() .unwrap_or(default_renaming) @@ -273,10 +290,14 @@ impl ToTokens for FlatExpansion<'_> { let match_arms = if let Some(default_renaming) = self.rename_all { self.matches .iter() - .map(|(ident, value, renaming)| { - let converted = renaming - .unwrap_or(default_renaming) - .convert_case(&ident.to_string()); + .map(|(ident, value, rename, renaming)| { + let converted = if let Some(rename) = rename { + rename.as_str().to_owned() + } else { + renaming + .unwrap_or(default_renaming) + .convert_case(&ident.to_string()) + }; let constructor = value.self_constructor_empty(); quote! { #converted => #constructor, } @@ -285,10 +306,14 @@ impl ToTokens for FlatExpansion<'_> { } else { self.matches .iter() - .map(|(ident, value, renaming)| { + .map(|(ident, value, rename, renaming)| { let name = ident.to_string(); let constructor = value.self_constructor_empty(); - if let Some(rename) = renaming { + if let Some(rename) = rename { + let exact_name = rename.as_str(); + + quote! { _ if s == #exact_name => #constructor, } + } else if let Some(rename) = renaming { let exact_name = rename.convert_case(&name); quote! { _ if s == #exact_name => #constructor, } @@ -465,6 +490,87 @@ impl FieldsExt for Either<&L, &R> { } } +/// Representation of possible [`FromStr`] derive macro attributes placed on a variant. +/// +/// ```rust,ignore +/// #[(rename = "...")] +/// #[(rename_all = "")] +/// ``` +#[derive(Default)] +struct FlatVariantAttributes { + /// [`attr::Rename`] for custom renaming. + rename: Option, + + /// [`attr::RenameAll`] for case conversion. + rename_all: Option, +} + +impl Parse for FlatVariantAttributes { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + mod ident { + use syn::custom_keyword; + + custom_keyword!(rename); + custom_keyword!(rename_all); + } + + let ahead = input.lookahead1(); + if ahead.peek(ident::rename) { + Ok(Self { + rename: Some(input.parse()?), + ..Default::default() + }) + } else if ahead.peek(ident::rename_all) { + Ok(Self { + rename_all: Some(input.parse()?), + ..Default::default() + }) + } else { + Err(ahead.error()) + } + } +} + +impl attr::ParseMultiple for FlatVariantAttributes { + fn merge_attrs( + prev: Spanning, + new: Spanning, + name: &syn::Ident, + ) -> syn::Result> { + let Spanning { + span: prev_span, + item: mut prev, + } = prev; + let Spanning { + span: new_span, + item: new, + } = new; + + if new.rename.and_then(|n| prev.rename.replace(n)).is_some() { + return Err(syn::Error::new( + new_span, + format!("multiple `#[{name}(rename=\"...\")]` attributes aren't allowed"), + )); + } + + if new + .rename_all + .and_then(|n| prev.rename_all.replace(n)) + .is_some() + { + return Err(syn::Error::new( + new_span, + format!("multiple `#[{name}(rename_all=\"...\")]` attributes aren't allowed"), + )); + } + + Ok(Spanning::new( + prev, + prev_span.join(new_span).unwrap_or(prev_span), + )) + } +} + /// Representation of possible [`FromStr`] derive macro attributes placed on an enum or a struct for /// a [`FlatExpansion`]. /// diff --git a/impl/src/utils.rs b/impl/src/utils.rs index de2d2bf0..bdcb839b 100644 --- a/impl/src/utils.rs +++ b/impl/src/utils.rs @@ -1541,6 +1541,8 @@ pub(crate) mod attr { pub(crate) use self::forward::Forward; #[cfg(any(feature = "display", feature = "from_str"))] pub(crate) use self::rename_all::RenameAll; + #[cfg(any(feature = "display", feature = "from_str"))] + pub(crate) use self::rename::Rename; #[cfg(any( feature = "add", feature = "add_assign", @@ -2389,6 +2391,53 @@ pub(crate) mod attr { impl ParseMultiple for RenameAll {} } + + #[cfg(any(feature = "display", feature = "from_str"))] + mod rename { + use syn::{ + parse::{Parse, ParseStream}, + spanned::Spanned as _, + token, + }; + + use super::ParseMultiple; + + /// Representation of a `rename` macro attribute. + /// + /// ```rust,ignore + /// #[(rename = "...")] + /// ``` + #[derive(Clone, Debug)] + pub(crate) struct Rename(String); + + impl Rename { + pub(crate) fn as_str(&self) -> &str { + &self.0 + } + } + + impl Parse for Rename { + fn parse(input: ParseStream<'_>) -> syn::Result { + let _ = input.parse::().and_then(|p| { + if p.is_ident("rename") { + Ok(p) + } else { + Err(syn::Error::new( + p.span(), + "unknown attribute argument, expected `rename = \"...\"`", + )) + } + })?; + + input.parse::()?; + + let lit: syn::LitStr = input.parse()?; + Ok(Self(lit.value())) + } + } + + impl ParseMultiple for Rename {} + } } #[cfg(any(feature = "from", feature = "into"))] From 7df1200a237c62c7e14232aca47cd45b1dd38c32 Mon Sep 17 00:00:00 2001 From: tison Date: Thu, 15 Jan 2026 18:20:45 +0800 Subject: [PATCH 2/4] test(from_str): add compile-fail tests for rename attribute --- tests/compile_fail/from_str/rename_collision.rs | 11 +++++++++++ tests/compile_fail/from_str/rename_collision.stderr | 5 +++++ tests/compile_fail/from_str/rename_duplicate.rs | 11 +++++++++++ tests/compile_fail/from_str/rename_duplicate.stderr | 5 +++++ tests/compile_fail/from_str/rename_multiple.rs | 10 ++++++++++ tests/compile_fail/from_str/rename_multiple.stderr | 5 +++++ 6 files changed, 47 insertions(+) create mode 100644 tests/compile_fail/from_str/rename_collision.rs create mode 100644 tests/compile_fail/from_str/rename_collision.stderr create mode 100644 tests/compile_fail/from_str/rename_duplicate.rs create mode 100644 tests/compile_fail/from_str/rename_duplicate.stderr create mode 100644 tests/compile_fail/from_str/rename_multiple.rs create mode 100644 tests/compile_fail/from_str/rename_multiple.stderr diff --git a/tests/compile_fail/from_str/rename_collision.rs b/tests/compile_fail/from_str/rename_collision.rs new file mode 100644 index 00000000..791bb996 --- /dev/null +++ b/tests/compile_fail/from_str/rename_collision.rs @@ -0,0 +1,11 @@ +use derive_more::FromStr; + +#[derive(FromStr)] +#[from_str(rename_all = "lowercase")] +enum E { + #[from_str(rename = "b")] + A, + B, +} + +fn main() {} \ No newline at end of file diff --git a/tests/compile_fail/from_str/rename_collision.stderr b/tests/compile_fail/from_str/rename_collision.stderr new file mode 100644 index 00000000..d407b71c --- /dev/null +++ b/tests/compile_fail/from_str/rename_collision.stderr @@ -0,0 +1,5 @@ +error: `A`, `B` variants cannot have the same "b" string representation + --> tests/compile_fail/from_str/rename_collision.rs:5:6 + | +5 | enum E { + | ^ diff --git a/tests/compile_fail/from_str/rename_duplicate.rs b/tests/compile_fail/from_str/rename_duplicate.rs new file mode 100644 index 00000000..bf9aeb53 --- /dev/null +++ b/tests/compile_fail/from_str/rename_duplicate.rs @@ -0,0 +1,11 @@ +use derive_more::FromStr; + +#[derive(FromStr)] +enum E { + #[from_str(rename = "a")] + A, + #[from_str(rename = "a")] + B, +} + +fn main() {} diff --git a/tests/compile_fail/from_str/rename_duplicate.stderr b/tests/compile_fail/from_str/rename_duplicate.stderr new file mode 100644 index 00000000..47294632 --- /dev/null +++ b/tests/compile_fail/from_str/rename_duplicate.stderr @@ -0,0 +1,5 @@ +error: `A`, `B` variants cannot have the same "a" string representation + --> tests/compile_fail/from_str/rename_duplicate.rs:4:6 + | +4 | enum E { + | ^ diff --git a/tests/compile_fail/from_str/rename_multiple.rs b/tests/compile_fail/from_str/rename_multiple.rs new file mode 100644 index 00000000..15ee80de --- /dev/null +++ b/tests/compile_fail/from_str/rename_multiple.rs @@ -0,0 +1,10 @@ +use derive_more::FromStr; + +#[derive(FromStr)] +enum E { + #[from_str(rename = "a")] + #[from_str(rename = "b")] + A, +} + +fn main() {} diff --git a/tests/compile_fail/from_str/rename_multiple.stderr b/tests/compile_fail/from_str/rename_multiple.stderr new file mode 100644 index 00000000..d1bd85fe --- /dev/null +++ b/tests/compile_fail/from_str/rename_multiple.stderr @@ -0,0 +1,5 @@ +error: multiple `#[from_str(rename="...")]` attributes aren't allowed + --> tests/compile_fail/from_str/rename_multiple.rs:6:5 + | +6 | #[from_str(rename = "b")] + | ^ From 3e2471b02828721d081baae7703244935bfc5341 Mon Sep 17 00:00:00 2001 From: tison Date: Thu, 15 Jan 2026 18:23:40 +0800 Subject: [PATCH 3/4] feat(from_str): support rename attribute on variants and flat structs --- impl/src/from_str.rs | 38 +++++++++++++++----- tests/from_str.rs | 86 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/impl/src/from_str.rs b/impl/src/from_str.rs index 907aafbd..fc3085e1 100644 --- a/impl/src/from_str.rs +++ b/impl/src/from_str.rs @@ -158,6 +158,14 @@ impl<'i> TryFrom<&'i syn::DeriveInput> for FlatExpansion<'i> { fn try_from(input: &'i syn::DeriveInput) -> syn::Result { let attr_ident = &format_ident!("from_str"); + let FlatContainerAttributes { + rename, + rename_all, + error: custom_error, + } = FlatContainerAttributes::parse_attrs(&input.attrs, attr_ident)? + .map(Spanning::into_inner) + .unwrap_or_default(); + let matches = match &input.data { syn::Data::Struct(data) => { if !data.fields.is_empty() { @@ -166,7 +174,12 @@ impl<'i> TryFrom<&'i syn::DeriveInput> for FlatExpansion<'i> { "only structs with no fields can derive `FromStr`", )); } - vec![(&input.ident, Either::Left(data), None, None)] + vec![( + &input.ident, + Either::Left(data), + rename.clone(), + rename_all.clone(), + )] } syn::Data::Enum(data) => data .variants @@ -198,13 +211,6 @@ impl<'i> TryFrom<&'i syn::DeriveInput> for FlatExpansion<'i> { } }; - let FlatContainerAttributes { - rename_all, - error: custom_error, - } = FlatContainerAttributes::parse_attrs(&input.attrs, attr_ident)? - .map(Spanning::into_inner) - .unwrap_or_default(); - let mut similar_matches = >>::new(); if rename_all.is_none() { for (ident, _, rename, renaming) in &matches { @@ -584,6 +590,9 @@ impl attr::ParseMultiple for FlatVariantAttributes { /// be specified only once. #[derive(Default)] struct FlatContainerAttributes { + /// [`attr::Rename`] for custom renaming. + rename: Option, + /// [`attr::RenameAll`] for case conversion. rename_all: Option, @@ -597,6 +606,7 @@ impl Parse for FlatContainerAttributes { use syn::custom_keyword; custom_keyword!(error); + custom_keyword!(rename); custom_keyword!(rename_all); } @@ -606,6 +616,11 @@ impl Parse for FlatContainerAttributes { error: Some(input.parse()?), ..Default::default() }) + } else if ahead.peek(ident::rename) { + Ok(Self { + rename: Some(input.parse()?), + ..Default::default() + }) } else if ahead.peek(ident::rename_all) { Ok(Self { rename_all: Some(input.parse()?), @@ -632,6 +647,13 @@ impl attr::ParseMultiple for FlatContainerAttributes { item: new, } = new; + if new.rename.and_then(|n| prev.rename.replace(n)).is_some() { + return Err(syn::Error::new( + new_span, + format!("multiple `#[{name}(rename=\"...\")]` attributes aren't allowed"), + )); + } + if new .rename_all .and_then(|n| prev.rename_all.replace(n)) diff --git a/tests/from_str.rs b/tests/from_str.rs index 49961225..e6bfd447 100644 --- a/tests/from_str.rs +++ b/tests/from_str.rs @@ -442,6 +442,19 @@ mod structs { ); } + #[test] + fn rename() { + #[derive(Debug, Eq, FromStr, PartialEq)] + #[from_str(rename = "Bar")] + struct Foo; + + assert_eq!("Bar".parse::().unwrap(), Foo); + assert_eq!( + "Foo".parse::().unwrap_err().to_string(), + "Invalid `Foo` string representation", + ); + } + mod rename_all { use super::*; @@ -726,6 +739,79 @@ mod enums { ); } + mod rename { + use super::*; + + #[test] + fn basic() { + #[derive(Debug, Eq, FromStr, PartialEq)] + enum Enum { + #[from_str(rename = "foo")] + Foo, + #[from_str(rename = "bar")] + Bar, + } + + assert_eq!("foo".parse::().unwrap(), Enum::Foo); + assert_eq!("bar".parse::().unwrap(), Enum::Bar); + + assert_eq!( + "Foo".parse::().unwrap_err().to_string(), + "Invalid `Enum` string representation", + ); + } + + #[test] + fn override_rename_all() { + #[derive(Debug, Eq, FromStr, PartialEq)] + #[from_str(rename_all = "UPPERCASE")] + enum Enum { + #[from_str(rename = "foo")] + Foo, + Bar, + } + + assert_eq!("foo".parse::().unwrap(), Enum::Foo); + assert_eq!("BAR".parse::().unwrap(), Enum::Bar); + + assert_eq!( + "FOO".parse::().unwrap_err().to_string(), + "Invalid `Enum` string representation", + ); + } + + #[test] + fn mixed() { + #[derive(Debug, Eq, FromStr, PartialEq)] + enum Enum { + #[from_str(rename = "foo")] + Foo, + Bar, + } + + assert_eq!("foo".parse::().unwrap(), Enum::Foo); + // Default is case-insensitive + assert_eq!("Bar".parse::().unwrap(), Enum::Bar); + assert_eq!("bar".parse::().unwrap(), Enum::Bar); + } + + #[test] + fn case_sensitivity() { + #[derive(Debug, Eq, FromStr, PartialEq)] + enum Enum { + #[from_str(rename = "Foo")] + Foo, + } + + assert_eq!("Foo".parse::().unwrap(), Enum::Foo); + // rename attribute is strict + assert_eq!( + "foo".parse::().unwrap_err().to_string(), + "Invalid `Enum` string representation", + ); + } + } + mod rename_all { use super::*; From 6615354882418080d0e313f5ea2bccd03ca62c03 Mon Sep 17 00:00:00 2001 From: tison Date: Thu, 15 Jan 2026 21:46:15 +0800 Subject: [PATCH 4/4] fixup Signed-off-by: tison --- impl/src/from_str.rs | 16 ++++++++-------- impl/src/utils.rs | 4 ++-- tests/compile_fail/as_mut/renamed_generic.stderr | 3 +++ tests/compile_fail/as_ref/renamed_generic.stderr | 3 +++ .../from_str/enum_unknown_attribute.stderr | 2 +- .../struct_flat_unknown_attribute.stderr | 2 +- 6 files changed, 18 insertions(+), 12 deletions(-) diff --git a/impl/src/from_str.rs b/impl/src/from_str.rs index fc3085e1..98e8b792 100644 --- a/impl/src/from_str.rs +++ b/impl/src/from_str.rs @@ -133,6 +133,7 @@ struct FlatExpansion<'i> { /// a value-specific [`attr::RenameAll`] overriding [`FlatExpansion::rename_all`], if any. /// /// [`syn::Ident`]: struct@syn::Ident + #[allow(clippy::type_complexity)] matches: Vec<( &'i syn::Ident, Either<&'i syn::DataStruct, &'i syn::Variant>, @@ -174,12 +175,7 @@ impl<'i> TryFrom<&'i syn::DeriveInput> for FlatExpansion<'i> { "only structs with no fields can derive `FromStr`", )); } - vec![( - &input.ident, - Either::Left(data), - rename.clone(), - rename_all.clone(), - )] + vec![(&input.ident, Either::Left(data), rename, rename_all)] } syn::Data::Enum(data) => data .variants @@ -555,7 +551,9 @@ impl attr::ParseMultiple for FlatVariantAttributes { if new.rename.and_then(|n| prev.rename.replace(n)).is_some() { return Err(syn::Error::new( new_span, - format!("multiple `#[{name}(rename=\"...\")]` attributes aren't allowed"), + format!( + "multiple `#[{name}(rename=\"...\")]` attributes aren't allowed" + ), )); } @@ -650,7 +648,9 @@ impl attr::ParseMultiple for FlatContainerAttributes { if new.rename.and_then(|n| prev.rename.replace(n)).is_some() { return Err(syn::Error::new( new_span, - format!("multiple `#[{name}(rename=\"...\")]` attributes aren't allowed"), + format!( + "multiple `#[{name}(rename=\"...\")]` attributes aren't allowed" + ), )); } diff --git a/impl/src/utils.rs b/impl/src/utils.rs index bdcb839b..67758ef3 100644 --- a/impl/src/utils.rs +++ b/impl/src/utils.rs @@ -1539,10 +1539,10 @@ pub(crate) mod attr { feature = "mul_assign", ))] pub(crate) use self::forward::Forward; + #[cfg(feature = "from_str")] + pub(crate) use self::rename::Rename; #[cfg(any(feature = "display", feature = "from_str"))] pub(crate) use self::rename_all::RenameAll; - #[cfg(any(feature = "display", feature = "from_str"))] - pub(crate) use self::rename::Rename; #[cfg(any( feature = "add", feature = "add_assign", diff --git a/tests/compile_fail/as_mut/renamed_generic.stderr b/tests/compile_fail/as_mut/renamed_generic.stderr index 1a7dfe1b..9f584cfe 100644 --- a/tests/compile_fail/as_mut/renamed_generic.stderr +++ b/tests/compile_fail/as_mut/renamed_generic.stderr @@ -13,6 +13,9 @@ error[E0599]: the method `as_mut` exists for struct `Baz`, but its trait bo = note: trait bound `Foo: AsMut>` was not satisfied note: the trait `AsMut` must be implemented --> $RUST/core/src/convert/mod.rs + | + | pub const trait AsMut: PointeeSized { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ = help: items from traits can only be used if the trait is implemented and in scope = note: the following trait defines an item `as_mut`, perhaps you need to implement it: candidate #1: `AsMut` diff --git a/tests/compile_fail/as_ref/renamed_generic.stderr b/tests/compile_fail/as_ref/renamed_generic.stderr index 304c3d9a..3728ae63 100644 --- a/tests/compile_fail/as_ref/renamed_generic.stderr +++ b/tests/compile_fail/as_ref/renamed_generic.stderr @@ -13,6 +13,9 @@ error[E0599]: the method `as_ref` exists for struct `Baz`, but its trait bo = note: trait bound `Foo: AsRef>` was not satisfied note: the trait `AsRef` must be implemented --> $RUST/core/src/convert/mod.rs + | + | pub const trait AsRef: PointeeSized { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ = help: items from traits can only be used if the trait is implemented and in scope = note: the following trait defines an item `as_ref`, perhaps you need to implement it: candidate #1: `AsRef` diff --git a/tests/compile_fail/from_str/enum_unknown_attribute.stderr b/tests/compile_fail/from_str/enum_unknown_attribute.stderr index aaa9a39d..4a391f80 100644 --- a/tests/compile_fail/from_str/enum_unknown_attribute.stderr +++ b/tests/compile_fail/from_str/enum_unknown_attribute.stderr @@ -1,4 +1,4 @@ -error: expected `error` or `rename_all` +error: expected one of: `error`, `rename`, `rename_all` --> tests/compile_fail/from_str/enum_unknown_attribute.rs:2:12 | 2 | #[from_str(unknown = "unknown")] diff --git a/tests/compile_fail/from_str/struct_flat_unknown_attribute.stderr b/tests/compile_fail/from_str/struct_flat_unknown_attribute.stderr index 2798db61..240172c6 100644 --- a/tests/compile_fail/from_str/struct_flat_unknown_attribute.stderr +++ b/tests/compile_fail/from_str/struct_flat_unknown_attribute.stderr @@ -1,4 +1,4 @@ -error: expected `error` or `rename_all` +error: expected one of: `error`, `rename`, `rename_all` --> tests/compile_fail/from_str/struct_flat_unknown_attribute.rs:2:12 | 2 | #[from_str(unknown = "unknown")]