From 1643f9c74c6e23603d2cd8b902fe62b345a9552f Mon Sep 17 00:00:00 2001 From: Serhii Potapov Date: Fri, 12 Jun 2026 16:01:15 +0200 Subject: [PATCH 1/2] feat: attribute passthrough and serde customization Implements attribute passthrough (#229): * Struct-level attributes (e.g. #[repr(transparent)], #[sqlx(transparent)]) and field-level attributes (e.g. #[garde(length(min = 1))]) are forwarded verbatim onto the generated type, so third-party derives pulled in via derive_unchecked can read them. Previously struct attributes were rejected and field attributes were silently dropped. Resolves #228 and the attribute half of #191. Forwarded attributes are emitted after the generated #[derive(...)], because derive-helper attributes are only legal after the derive that introduces them (legacy_derive_helpers). * Serde customization (#201): nutype's generated Serialize/Deserialize impls honor field-level #[serde(with = "...")], serialize_with, deserialize_with and struct-level #[serde(transparent)], using serde's own syntax. The serialize_with path mirrors serde's derive (a wrapper struct inside newtype framing); transparent drops the framing entirely. Sanitization and validation ALWAYS still run on deserialization: a custom deserialize_with function only produces the raw inner value, which is then routed through try_new/new. * Other serde keys, #[schemars(...)], #[cfg] on the inner field and native #[derive(...)] get targeted errors. Item-level #[cfg] needs no handling: rustc strips it before the macro expands (pinned by a functional test). * Fix: field attributes on "any" inner types no longer leak into type positions of the generated code (AnyInnerType carried the whole syn::Field including attributes, which broke e.g. fn try_new(raw_value: #[attr] T)). Tests: functional suite in test_suite/tests/attr_passthrough.rs (forwarding, serde matrix incl. RON framing, guarantee tests proving validation gates custom deserialization), UI fixtures for every targeted error plus an observable forwarding proof via deny(deprecated), and third-party derive proofs (arbitrary field attr, derive_more::Display type attr) in examples/derive_unchecked_example. The test_suite deliberately does not gain a derive_unchecked feature: several UI fixtures pin the feature-off error messages and would flip under --all-features. --- CHANGELOG.md | 6 +- Cargo.lock | 20 +- examples/derive_unchecked_example/Cargo.toml | 3 +- examples/derive_unchecked_example/src/main.rs | 33 ++ nutype_macros/src/any/generate/mod.rs | 6 +- nutype_macros/src/any/generate/traits/mod.rs | 11 +- nutype_macros/src/any/models.rs | 6 + nutype_macros/src/common/generate/mod.rs | 72 ++- nutype_macros/src/common/generate/traits.rs | 187 +++++-- nutype_macros/src/common/models.rs | 99 ++++ nutype_macros/src/common/parse/meta.rs | 194 +++++++- nutype_macros/src/decimal/generate/mod.rs | 5 +- .../src/decimal/generate/traits/mod.rs | 15 +- nutype_macros/src/decimal/models.rs | 6 + nutype_macros/src/float/generate/mod.rs | 6 +- .../src/float/generate/traits/mod.rs | 15 +- nutype_macros/src/float/models.rs | 6 + nutype_macros/src/integer/generate/mod.rs | 5 +- .../src/integer/generate/traits/mod.rs | 15 +- nutype_macros/src/integer/models.rs | 6 + nutype_macros/src/string/generate/mod.rs | 6 +- .../src/string/generate/traits/mod.rs | 17 +- nutype_macros/src/string/models.rs | 6 + test_suite/tests/attr_passthrough.rs | 460 ++++++++++++++++++ .../ui/common/attrs/field_cfg_rejected.rs | 8 + .../ui/common/attrs/field_cfg_rejected.stderr | 7 + .../ui/common/attrs/field_serde_bad_path.rs | 7 + .../common/attrs/field_serde_bad_path.stderr | 5 + .../common/attrs/field_serde_duplicate_key.rs | 7 + .../attrs/field_serde_duplicate_key.stderr | 5 + .../attrs/field_serde_requires_derive.rs | 8 + .../attrs/field_serde_requires_derive.stderr | 6 + .../common/attrs/field_serde_unknown_key.rs | 8 + .../attrs/field_serde_unknown_key.stderr | 7 + .../common/attrs/field_serde_with_conflict.rs | 8 + .../attrs/field_serde_with_conflict.stderr | 5 + .../ui/common/attrs/native_derive_rejected.rs | 9 + .../attrs/native_derive_rejected.stderr | 10 + .../serde_transparent_requires_derive.rs | 8 + .../serde_transparent_requires_derive.stderr | 6 + .../attrs/struct_attr_deprecated_forwarded.rs | 13 + .../struct_attr_deprecated_forwarded.stderr | 41 ++ .../common/attrs/struct_schemars_rejected.rs | 9 + .../attrs/struct_schemars_rejected.stderr | 6 + .../common/attrs/struct_serde_unsupported.rs | 9 + .../attrs/struct_serde_unsupported.stderr | 8 + 46 files changed, 1324 insertions(+), 81 deletions(-) create mode 100644 test_suite/tests/attr_passthrough.rs create mode 100644 test_suite/tests/ui/common/attrs/field_cfg_rejected.rs create mode 100644 test_suite/tests/ui/common/attrs/field_cfg_rejected.stderr create mode 100644 test_suite/tests/ui/common/attrs/field_serde_bad_path.rs create mode 100644 test_suite/tests/ui/common/attrs/field_serde_bad_path.stderr create mode 100644 test_suite/tests/ui/common/attrs/field_serde_duplicate_key.rs create mode 100644 test_suite/tests/ui/common/attrs/field_serde_duplicate_key.stderr create mode 100644 test_suite/tests/ui/common/attrs/field_serde_requires_derive.rs create mode 100644 test_suite/tests/ui/common/attrs/field_serde_requires_derive.stderr create mode 100644 test_suite/tests/ui/common/attrs/field_serde_unknown_key.rs create mode 100644 test_suite/tests/ui/common/attrs/field_serde_unknown_key.stderr create mode 100644 test_suite/tests/ui/common/attrs/field_serde_with_conflict.rs create mode 100644 test_suite/tests/ui/common/attrs/field_serde_with_conflict.stderr create mode 100644 test_suite/tests/ui/common/attrs/native_derive_rejected.rs create mode 100644 test_suite/tests/ui/common/attrs/native_derive_rejected.stderr create mode 100644 test_suite/tests/ui/common/attrs/serde_transparent_requires_derive.rs create mode 100644 test_suite/tests/ui/common/attrs/serde_transparent_requires_derive.stderr create mode 100644 test_suite/tests/ui/common/attrs/struct_attr_deprecated_forwarded.rs create mode 100644 test_suite/tests/ui/common/attrs/struct_attr_deprecated_forwarded.stderr create mode 100644 test_suite/tests/ui/common/attrs/struct_schemars_rejected.rs create mode 100644 test_suite/tests/ui/common/attrs/struct_schemars_rejected.stderr create mode 100644 test_suite/tests/ui/common/attrs/struct_serde_unsupported.rs create mode 100644 test_suite/tests/ui/common/attrs/struct_serde_unsupported.stderr diff --git a/CHANGELOG.md b/CHANGELOG.md index 259d556a..f6b53b85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ -### v0.7.1 - Unreleased +### v0.8.0 - Unreleased +- **[FEATURE]** Attribute passthrough (see [#229](https://github.com/greyblake/nutype/issues/229)): attributes written on the struct and on the inner field are now forwarded verbatim onto the generated type, so they can be read by third-party derives and the compiler. Examples: `#[repr(transparent)]`, `#[sqlx(transparent)]` (with `derive_unchecked(sqlx::Type)`), `#[garde(length(min = 1))]` (with `derive_unchecked(garde::Validate)`). Resolves [#228](https://github.com/greyblake/nutype/issues/228) and the attribute half of [#191](https://github.com/greyblake/nutype/issues/191). +- **[FEATURE]** Serde customization (see [#201](https://github.com/greyblake/nutype/issues/201)): nutype's generated `Serialize`/`Deserialize` impls now honor field-level `#[serde(with = "...")]`, `#[serde(serialize_with = "...")]`, `#[serde(deserialize_with = "...")]` and struct-level `#[serde(transparent)]`. Sanitization and validation still always run on deserialization. +- **[FIX]** Field attributes on newtypes with custom ("any") inner types no longer leak into type positions of the generated code (previously they could produce confusing compile errors). +- **[BEHAVIOR]** Previously, non-doc attributes on the struct were rejected with an error and attributes on the inner field were silently dropped. Both are now forwarded (serde attributes are consumed and integrated instead; `#[cfg]` on the inner field is rejected explicitly). - **[FEATURE]** Support `rust_decimal::Decimal` as an inner type behind the `rust_decimal` feature flag, with the standard numeric validators and sanitizers (see [#242](https://github.com/greyblake/nutype/issues/242)). - **[FEATURE]** Friendlier error when a `#[nutype(...)]` attribute is mistyped: suggests the closest match (e.g. `validte` -> `validate`) and lists the available nutype attributes (see [#240](https://github.com/greyblake/nutype/issues/240)). - **[FIX]** Fix misleading error for value-type mismatches in validators (see [#241](https://github.com/greyblake/nutype/issues/241)). diff --git a/Cargo.lock b/Cargo.lock index 72f19c98..30235105 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -192,6 +192,15 @@ dependencies = [ "nutype", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "convert_case" version = "0.11.0" @@ -244,16 +253,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", "syn 2.0.115", + "unicode-xid", ] [[package]] name = "derive_unchecked_example" version = "0.1.0" dependencies = [ + "arbitrary", "derive_more", "nutype", ] @@ -434,7 +446,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "feeb717349480c3d8125f84afdd78e9d7128004d8e2a561c35b3bbd76363d085" dependencies = [ - "convert_case", + "convert_case 0.11.0", "proc-macro2", "quote", "syn 2.0.115", @@ -1185,6 +1197,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "urlencoding" version = "2.1.3" diff --git a/examples/derive_unchecked_example/Cargo.toml b/examples/derive_unchecked_example/Cargo.toml index 50962c3b..57d3837c 100644 --- a/examples/derive_unchecked_example/Cargo.toml +++ b/examples/derive_unchecked_example/Cargo.toml @@ -6,5 +6,6 @@ edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -derive_more = { version = "2.0.1", features = ["deref", "deref_mut"] } +arbitrary = { version = "1", features = ["derive"] } +derive_more = { version = "2.0.1", features = ["deref", "deref_mut", "display"] } nutype = { path = "../../nutype", features = ["derive_unchecked"] } diff --git a/examples/derive_unchecked_example/src/main.rs b/examples/derive_unchecked_example/src/main.rs index 3a39a482..ac1b773d 100644 --- a/examples/derive_unchecked_example/src/main.rs +++ b/examples/derive_unchecked_example/src/main.rs @@ -19,3 +19,36 @@ fn main() { // OH no, we've just violated the validation rule! assert_eq!(temperature.as_ref(), &2.5); } + +#[cfg(test)] +mod tests { + use super::*; + + // Proof that field attributes are forwarded to the generated field and + // read by a real third-party derive: `arbitrary`'s derive honors + // `#[arbitrary(value = ...)]`. If the attribute were dropped (the old + // nutype behavior), `arbitrary` would produce 0 from empty input + // instead of 42. + #[test] + fn field_attr_reaches_third_party_derive() { + use arbitrary::{Arbitrary, Unstructured}; + + #[nutype(derive(Debug), derive_unchecked(::arbitrary::Arbitrary))] + struct Fixed(#[arbitrary(value = 42)] i32); + + let mut u = Unstructured::new(&[]); + let value = Fixed::arbitrary(&mut u).unwrap(); + assert_eq!(value.into_inner(), 42); + } + + // Type-level attributes are forwarded as well: `derive_more::Display` + // reads the `#[display(...)]` attribute from the type. + #[test] + fn type_attr_reaches_third_party_derive() { + #[nutype(derive(Debug), derive_unchecked(derive_more::Display))] + #[display("ID-{_0}")] + struct Id(u32); + + assert_eq!(Id::new(7).to_string(), "ID-7"); + } +} diff --git a/nutype_macros/src/any/generate/mod.rs b/nutype_macros/src/any/generate/mod.rs index 8de786c8..c719e59e 100644 --- a/nutype_macros/src/any/generate/mod.rs +++ b/nutype_macros/src/any/generate/mod.rs @@ -12,8 +12,8 @@ use crate::common::{ GenerateNewtype, tests::gen_test_should_have_valid_default_value, traits::GeneratedTraits, }, models::{ - ConditionalDeriveGroup, ConstFn, ErrorTypePath, Guard, SpannedDeriveUnsafeTrait, TypeName, - TypedCustomFunction, + ConditionalDeriveGroup, ConstFn, ErrorTypePath, Guard, SerdeCustomization, + SpannedDeriveUnsafeTrait, TypeName, TypedCustomFunction, }, }; @@ -127,6 +127,7 @@ impl GenerateNewtype for AnyNewtype { maybe_default_value: Option, guard: &AnyGuard, conditional_derives: &[ConditionalDeriveGroup], + serde_customization: &SerdeCustomization, ) -> Result { gen_traits( type_name, @@ -137,6 +138,7 @@ impl GenerateNewtype for AnyNewtype { maybe_default_value, guard, conditional_derives, + serde_customization, ) } diff --git a/nutype_macros/src/any/generate/traits/mod.rs b/nutype_macros/src/any/generate/traits/mod.rs index 65278c4d..6f0efe38 100644 --- a/nutype_macros/src/any/generate/traits/mod.rs +++ b/nutype_macros/src/any/generate/traits/mod.rs @@ -16,7 +16,7 @@ use crate::{ gen_impl_trait_serde_deserialize, gen_impl_trait_serde_serialize, gen_impl_trait_try_from, process_conditional_derives, split_into_generatable_traits, }, - models::{ConditionalDeriveGroup, SpannedDeriveUnsafeTrait, TypeName}, + models::{ConditionalDeriveGroup, SerdeCustomization, SpannedDeriveUnsafeTrait, TypeName}, }, }; @@ -133,6 +133,7 @@ pub fn gen_traits( maybe_default_value: Option, guard: &AnyGuard, conditional_derives: &[ConditionalDeriveGroup], + serde_customization: &SerdeCustomization, ) -> Result { let GeneratableTraits { transparent_traits, @@ -153,6 +154,7 @@ pub fn gen_traits( irregular_traits, maybe_default_value.clone(), guard, + serde_customization, )?; let ConditionalTraits { @@ -167,6 +169,7 @@ pub fn gen_traits( irregular, maybe_default_value.clone(), guard, + serde_customization, ) })?; @@ -179,6 +182,7 @@ pub fn gen_traits( }) } +#[allow(clippy::too_many_arguments)] fn gen_implemented_traits( type_name: &TypeName, generics: &syn::Generics, @@ -186,6 +190,7 @@ fn gen_implemented_traits( impl_traits: Vec, maybe_default_value: Option, guard: &AnyGuard, + serde_customization: &SerdeCustomization, ) -> Result { let maybe_error_type_name = guard.maybe_error_type_path(); impl_traits @@ -218,10 +223,10 @@ fn gen_implemented_traits( Ok(into_iter::gen_impl_trait_into_iter(type_name, generics, inner_type)) } AnyIrregularTrait::SerdeSerialize => Ok( - gen_impl_trait_serde_serialize(type_name, generics) + gen_impl_trait_serde_serialize(type_name, generics, inner_type, serde_customization) ), AnyIrregularTrait::SerdeDeserialize => Ok( - gen_impl_trait_serde_deserialize(type_name, generics, inner_type, maybe_error_type_name) + gen_impl_trait_serde_deserialize(type_name, generics, inner_type, maybe_error_type_name, serde_customization) ), AnyIrregularTrait::ArbitraryArbitrary => arbitrary::gen_impl_trait_arbitrary(type_name, generics, inner_type, guard), }) diff --git a/nutype_macros/src/any/models.rs b/nutype_macros/src/any/models.rs index 6d8dea48..c60297d7 100644 --- a/nutype_macros/src/any/models.rs +++ b/nutype_macros/src/any/models.rs @@ -60,6 +60,12 @@ impl TypeTrait for AnyDeriveTrait { fn is_default(&self) -> bool { self == &AnyDeriveTrait::Default } + fn is_serde_serialize(&self) -> bool { + self == &AnyDeriveTrait::SerdeSerialize + } + fn is_serde_deserialize(&self) -> bool { + self == &AnyDeriveTrait::SerdeDeserialize + } } pub type AnyRawGuard = RawGuard; diff --git a/nutype_macros/src/common/generate/mod.rs b/nutype_macros/src/common/generate/mod.rs index 11a367e0..65fc4191 100644 --- a/nutype_macros/src/common/generate/mod.rs +++ b/nutype_macros/src/common/generate/mod.rs @@ -13,8 +13,8 @@ use self::traits::GeneratedTraits; use super::models::{ ConditionalDeriveGroup, ConstFn, ConstructorVisibility, CustomFunction, ErrorTypePath, - GenerateParams, Guard, NewUnchecked, ParseErrorTypeName, SpannedDeriveUnsafeTrait, TypeName, - TypeTrait, + GenerateParams, Guard, NewUnchecked, ParseErrorTypeName, SerdeCustomization, + SpannedDeriveUnsafeTrait, TypeName, TypeTrait, }; use crate::common::{ generate::{new_unchecked::gen_new_unchecked, parse_error::gen_parse_error_name}, @@ -225,6 +225,7 @@ pub trait GenerateNewtype { maybe_default_value: Option, guard: &Guard, conditional_derives: &[ConditionalDeriveGroup], + serde_customization: &SerdeCustomization, ) -> Result; fn gen_try_new( @@ -419,8 +420,13 @@ pub trait GenerateNewtype { inner_type, generics, conditional_derives, + forwarded_attrs, + inner_field_attrs, + serde_customization, } = params; + validate_serde_customization(&serde_customization, &traits, &conditional_derives)?; + let module_name = gen_module_name_for_type(&type_name); let implementation = Self::gen_implementation( &type_name, @@ -474,6 +480,7 @@ pub trait GenerateNewtype { maybe_default_value, &guard, &conditional_derives, + &serde_customization, )?; let reimports = gen_reimports( @@ -505,7 +512,12 @@ pub trait GenerateNewtype { #(#doc_attrs)* #derive_transparent_traits #conditional_derive_transparent_traits - pub struct #type_name #struct_generics (#inner_type) #struct_where_clause; + // Forwarded attributes come AFTER the derives: derive-helper + // attributes (e.g. `#[sqlx(transparent)]`, `#[display(...)]`) + // are only legal after the derive that introduces them + // (see the `legacy_derive_helpers` lint). + #(#forwarded_attrs)* + pub struct #type_name #struct_generics (#(#inner_field_attrs)* #inner_type) #struct_where_clause; #implementation #implement_traits @@ -531,6 +543,60 @@ pub trait GenerateNewtype { ) -> TokenStream; } +/// Serde customization requires the corresponding traits to be derived through +/// `#[nutype(derive(...))]` (unconditionally or in a `cfg_attr` group), +/// because nutype weaves the custom functions into its own generated impls. +fn validate_serde_customization( + serde_customization: &SerdeCustomization, + traits: &HashSet, + conditional_derives: &[ConditionalDeriveGroup], +) -> Result<(), syn::Error> { + let has_serialize = traits.iter().any(|t| t.is_serde_serialize()) + || conditional_derives + .iter() + .any(|group| group.typed_traits.iter().any(|t| t.is_serde_serialize())); + let has_deserialize = traits.iter().any(|t| t.is_serde_deserialize()) + || conditional_derives + .iter() + .any(|group| group.typed_traits.iter().any(|t| t.is_serde_deserialize())); + + if let Some(span) = serde_customization.transparent + && !has_serialize + && !has_deserialize + { + let msg = "#[serde(transparent)] requires `Serialize` or `Deserialize` to be derived.\n\ + Add it with #[nutype(derive(Serialize, Deserialize))]."; + return Err(syn::Error::new(span, msg)); + } + + if let Some(with) = &serde_customization.with + && !has_serialize + && !has_deserialize + { + let msg = "#[serde(with = ...)] on the inner field requires `Serialize` or `Deserialize` to be derived.\n\ + Add it with #[nutype(derive(Serialize, Deserialize))]."; + return Err(syn::Error::new(with.span(), msg)); + } + + if let Some(serialize_with) = &serde_customization.serialize_with + && !has_serialize + { + let msg = "#[serde(serialize_with = ...)] on the inner field requires `Serialize` to be derived.\n\ + Add it with #[nutype(derive(Serialize))]."; + return Err(syn::Error::new(serialize_with.span(), msg)); + } + + if let Some(deserialize_with) = &serde_customization.deserialize_with + && !has_deserialize + { + let msg = "#[serde(deserialize_with = ...)] on the inner field requires `Deserialize` to be derived.\n\ + Add it with #[nutype(derive(Deserialize))]."; + return Err(syn::Error::new(deserialize_with.span(), msg)); + } + + Ok(()) +} + fn gen_fn_validate_custom( inner_type: &InnerType, with: &CustomFunction, diff --git a/nutype_macros/src/common/generate/traits.rs b/nutype_macros/src/common/generate/traits.rs index d6b93310..2c3bf4fd 100644 --- a/nutype_macros/src/common/generate/traits.rs +++ b/nutype_macros/src/common/generate/traits.rs @@ -7,7 +7,10 @@ use syn::Generics; use crate::common::{ generate::generics::{SplitGenerics, add_bound_to_all_type_params}, - models::{ConditionalDeriveGroup, ErrorTypePath, InnerType, ParseErrorTypeName, TypeName}, + models::{ + ConditionalDeriveGroup, ErrorTypePath, InnerType, ParseErrorTypeName, SerdeCustomization, + TypeName, + }, }; use super::parse_error::{gen_def_parse_error, gen_parse_error_name}; @@ -421,7 +424,14 @@ pub fn gen_impl_trait_from_str( } } -pub fn gen_impl_trait_serde_serialize(type_name: &TypeName, generics: &Generics) -> TokenStream { +pub fn gen_impl_trait_serde_serialize( + type_name: &TypeName, + generics: &Generics, + inner_type: impl Into, + serde_customization: &SerdeCustomization, +) -> TokenStream { + let inner_type: InnerType = inner_type.into(); + // Turn `` into `` let all_generics_with_serialize_bound = add_bound_to_all_type_params(generics, syn::parse_quote!(::serde::Serialize)); @@ -432,13 +442,66 @@ pub fn gen_impl_trait_serde_serialize(type_name: &TypeName, generics: &Generics) } = SplitGenerics::new(&all_generics_with_serialize_bound); let type_name_str = type_name.to_string(); + let maybe_serialize_with = serde_customization.effective_serialize_with(); + + let body = match (serde_customization.is_transparent(), maybe_serialize_with) { + // Plain newtype framing (the default serde derive behavior). + (false, None) => quote! { + ::serde::ser::Serializer::serialize_newtype_struct(serializer, #type_name_str, &self.0) + }, + // Transparent: serialize exactly as the inner value. + (true, None) => quote! { + ::serde::Serialize::serialize(&self.0, serializer) + }, + // Transparent + custom function: the function gets the serializer directly. + (true, Some(serialize_with)) => quote! { + #serialize_with(&self.0, serializer) + }, + // Newtype framing + custom function: mirror serde's own derive, which + // wraps the field in a private struct whose Serialize impl calls the + // custom function. This keeps the framing identical to a plain struct + // using `#[serde(serialize_with = ...)]`. + (false, Some(serialize_with)) => { + // The wrapper needs the type generics (the inner type may use them) + // plus a lifetime for the borrowed inner value. + let mut wrapper_generics = all_generics_with_serialize_bound.clone(); + wrapper_generics + .params + .push(syn::parse_quote!('__nutype_sw)); + let SplitGenerics { + impl_generics: wrapper_impl_generics, + type_generics: wrapper_type_generics, + where_clause: wrapper_where_clause, + } = SplitGenerics::new(&wrapper_generics); + + quote! { + struct __SerializeWith #wrapper_impl_generics #wrapper_where_clause { + value: &'__nutype_sw #inner_type, + } + impl #wrapper_impl_generics ::serde::Serialize for __SerializeWith #wrapper_type_generics #wrapper_where_clause { + fn serialize<__S>(&self, serializer: __S) -> ::core::result::Result<__S::Ok, __S::Error> + where + __S: ::serde::Serializer + { + #serialize_with(self.value, serializer) + } + } + ::serde::ser::Serializer::serialize_newtype_struct( + serializer, + #type_name_str, + &__SerializeWith { value: &self.0 }, + ) + } + } + }; + quote! { impl #impl_generics ::serde::Serialize for #type_name #type_generics #where_clause { fn serialize(&self, serializer: S) -> ::core::result::Result where S: ::serde::Serializer { - ::serde::ser::Serializer::serialize_newtype_struct(serializer, #type_name_str, &self.0) + #body } } } @@ -449,19 +512,36 @@ pub fn gen_impl_trait_serde_deserialize( type_generics: &Generics, inner_type: impl Into, maybe_error_type_name: Option<&ErrorTypePath>, + serde_customization: &SerdeCustomization, ) -> TokenStream { let inner_type: InnerType = inner_type.into(); - let raw_value_to_result: TokenStream = if maybe_error_type_name.is_some() { - let type_name_str = type_name.to_string(); - quote! { - #type_name::try_new(raw_value).map_err(|validation_error| { - // Add a hint about which type is causing the error, - ::custom(core::format_args!("{validation_error} Expected valid {}", #type_name_str)) - }) + + // How a raw inner value becomes the newtype: ALWAYS through the + // constructor, so sanitization and validation run regardless of any custom + // deserialization function. `deserializer_generic` is the ident of the + // deserializer's generic parameter in the surrounding scope. + let raw_value_to_result = |deserializer_generic: TokenStream| -> TokenStream { + if maybe_error_type_name.is_some() { + let type_name_str = type_name.to_string(); + quote! { + #type_name::try_new(raw_value).map_err(|validation_error| { + // Add a hint about which type is causing the error, + <#deserializer_generic::Error as serde::de::Error>::custom(core::format_args!("{validation_error} Expected valid {}", #type_name_str)) + }) + } + } else { + quote! { + Ok(#type_name::new(raw_value)) + } } - } else { - quote! { - Ok(#type_name::new(raw_value)) + }; + + // How the raw inner value is obtained from a deserializer: + // the custom function if given, the inner type's Deserialize otherwise. + let gen_deserialize_call = |deserializer: TokenStream| -> TokenStream { + match serde_customization.effective_deserialize_with() { + Some(deserialize_with) => quote!(#deserialize_with(#deserializer)), + None => quote!(<#inner_type as ::serde::Deserialize>::deserialize(#deserializer)), } }; @@ -499,41 +579,62 @@ pub fn gen_impl_trait_serde_deserialize( where_clause: visitor_where_clause, } = SplitGenerics::new(&all_generics); - quote! { - impl #all_impl_generics ::serde::Deserialize<'de> for #type_name #inner_type_generics #all_where_clause { - fn deserialize>(deserializer: D) -> ::core::result::Result { - struct __Visitor #visitor_impl_generics #visitor_where_clause { - marker: ::core::marker::PhantomData<#type_name #inner_type_generics>, - lifetime: ::core::marker::PhantomData<&'de ()>, + if serde_customization.is_transparent() { + // Transparent: no newtype framing, deserialize exactly as the inner + // value (mirrors serde's own `#[serde(transparent)]`). No visitor is + // needed because there is no framing to negotiate. + let deserialize_call = gen_deserialize_call(quote!(deserializer)); + let to_result = raw_value_to_result(quote!(D)); + quote! { + impl #all_impl_generics ::serde::Deserialize<'de> for #type_name #inner_type_generics #all_where_clause { + fn deserialize>(deserializer: D) -> ::core::result::Result { + let raw_value: #inner_type = match #deserialize_call { + Ok(val) => val, + Err(err) => return Err(err) + }; + #to_result } - - impl #all_impl_generics ::serde::de::Visitor<'de> for __Visitor #all_type_generics #all_where_clause { - type Value = #type_name #inner_type_generics; - - fn expecting(&self, formatter: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { - write!(formatter, #expecting_str) + } + } + } else { + let deserialize_call = gen_deserialize_call(quote!(deserializer)); + let to_result = raw_value_to_result(quote!(DE)); + quote! { + impl #all_impl_generics ::serde::Deserialize<'de> for #type_name #inner_type_generics #all_where_clause { + fn deserialize>(deserializer: D) -> ::core::result::Result { + struct __Visitor #visitor_impl_generics #visitor_where_clause { + marker: ::core::marker::PhantomData<#type_name #inner_type_generics>, + lifetime: ::core::marker::PhantomData<&'de ()>, } - fn visit_newtype_struct(self, deserializer: DE) -> ::core::result::Result - where - DE: ::serde::Deserializer<'de> - { - let raw_value: #inner_type = match <#inner_type as ::serde::Deserialize>::deserialize(deserializer) { - Ok(val) => val, - Err(err) => return Err(err) - }; - #raw_value_to_result + impl #all_impl_generics ::serde::de::Visitor<'de> for __Visitor #all_type_generics #all_where_clause { + type Value = #type_name #inner_type_generics; + + fn expecting(&self, formatter: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + write!(formatter, #expecting_str) + } + + fn visit_newtype_struct(self, deserializer: DE) -> ::core::result::Result + where + DE: ::serde::Deserializer<'de> + { + let raw_value: #inner_type = match #deserialize_call { + Ok(val) => val, + Err(err) => return Err(err) + }; + #to_result + } } - } - ::serde::de::Deserializer::deserialize_newtype_struct( - deserializer, - #type_name_str, - __Visitor { - marker: Default::default(), - lifetime: Default::default(), - } - ) + ::serde::de::Deserializer::deserialize_newtype_struct( + deserializer, + #type_name_str, + __Visitor { + marker: Default::default(), + lifetime: Default::default(), + } + ) + } } } } diff --git a/nutype_macros/src/common/models.rs b/nutype_macros/src/common/models.rs index 85e39fd9..9c51f17a 100644 --- a/nutype_macros/src/common/models.rs +++ b/nutype_macros/src/common/models.rs @@ -184,6 +184,18 @@ pub struct Meta { pub vis: syn::Visibility, pub doc_attrs: Vec, pub generics: Generics, + + /// Struct-level attributes that are forwarded verbatim onto the generated + /// struct (e.g. `#[repr(transparent)]`, `#[sqlx(transparent)]`). + pub forwarded_attrs: Vec, + + /// Field-level attributes that are forwarded verbatim onto the generated + /// inner field (e.g. `#[garde(length(min = 1))]`). + pub inner_field_attrs: Vec, + + /// Serde attributes consumed by nutype (never forwarded), see + /// [`SerdeCustomization`]. + pub serde_customization: SerdeCustomization, } impl Meta { @@ -194,6 +206,9 @@ impl Meta { inner_type, vis, generics, + forwarded_attrs, + inner_field_attrs, + serde_customization, } = self; let typed_meta = TypedMeta { doc_attrs, @@ -201,6 +216,9 @@ impl Meta { generics, attrs, vis, + forwarded_attrs, + inner_field_attrs, + serde_customization, }; (typed_meta, inner_type) } @@ -218,6 +236,15 @@ pub struct TypedMeta { pub vis: syn::Visibility, pub doc_attrs: Vec, pub generics: Generics, + + /// Struct-level attributes forwarded verbatim onto the generated struct. + pub forwarded_attrs: Vec, + + /// Field-level attributes forwarded verbatim onto the generated inner field. + pub inner_field_attrs: Vec, + + /// Serde attributes consumed by nutype (never forwarded). + pub serde_customization: SerdeCustomization, } /// Validated model, that represents precisely what needs to be generated. @@ -535,6 +562,66 @@ impl Parse for SpannedDeriveUnsafeTrait { pub trait TypeTrait { fn is_from_str(&self) -> bool; fn is_default(&self) -> bool; + fn is_serde_serialize(&self) -> bool; + fn is_serde_deserialize(&self) -> bool; +} + +/// Serde customization consumed by nutype from native serde attributes: +/// field-level `#[serde(with = "..")]` / `#[serde(serialize_with = "..")]` / +/// `#[serde(deserialize_with = "..")]` and struct-level `#[serde(transparent)]`. +/// +/// nutype hand-writes its `Serialize`/`Deserialize` impls, so these attributes +/// cannot be forwarded to a real serde derive; instead they are woven into the +/// generated impls. Deserialization always still runs sanitization and +/// validation, regardless of any custom function. +#[derive(Debug, Default)] +pub struct SerdeCustomization { + /// `#[serde(transparent)]` on the struct: drop the newtype framing and + /// serialize/deserialize exactly as the inner value. + pub transparent: Option, + + /// `#[serde(with = "module")]` on the field. + pub with: Option>, + + /// `#[serde(serialize_with = "path")]` on the field. + pub serialize_with: Option>, + + /// `#[serde(deserialize_with = "path")]` on the field. + pub deserialize_with: Option>, +} + +impl SerdeCustomization { + pub fn is_transparent(&self) -> bool { + self.transparent.is_some() + } + + /// The function to use for serialization, if any: + /// explicit `serialize_with`, or `::serialize`. + pub fn effective_serialize_with(&self) -> Option { + self.serialize_with + .as_ref() + .map(|spanned| spanned.item.clone()) + .or_else(|| { + self.with.as_ref().map(|spanned| { + let module = &spanned.item; + syn::parse_quote!(#module::serialize) + }) + }) + } + + /// The function to use for deserialization, if any: + /// explicit `deserialize_with`, or `::deserialize`. + pub fn effective_deserialize_with(&self) -> Option { + self.deserialize_with + .as_ref() + .map(|spanned| spanned.item.clone()) + .or_else(|| { + self.with.as_ref().map(|spanned| { + let module = &spanned.item; + syn::parse_quote!(#module::deserialize) + }) + }) + } } /// The flag that indicates that a newtype will be generated with extra constructor, @@ -621,6 +708,12 @@ pub struct GenerateParams { pub maybe_default_value: Option, /// Conditional derive groups, one per predicate. pub conditional_derives: Vec>, + /// Struct-level attributes forwarded verbatim onto the generated struct. + pub forwarded_attrs: Vec, + /// Field-level attributes forwarded verbatim onto the generated inner field. + pub inner_field_attrs: Vec, + /// Serde attributes consumed by nutype (never forwarded). + pub serde_customization: SerdeCustomization, } pub trait Newtype { @@ -662,6 +755,9 @@ pub trait Newtype { attrs, vis, generics, + forwarded_attrs, + inner_field_attrs, + serde_customization, } = typed_meta; let Attributes { guard, @@ -699,6 +795,9 @@ pub trait Newtype { maybe_default_value, inner_type, conditional_derives, + forwarded_attrs, + inner_field_attrs, + serde_customization, })?; Ok(generated_output) } diff --git a/nutype_macros/src/common/parse/meta.rs b/nutype_macros/src/common/parse/meta.rs index 435d1710..8c1e5842 100644 --- a/nutype_macros/src/common/parse/meta.rs +++ b/nutype_macros/src/common/parse/meta.rs @@ -5,7 +5,7 @@ use syn::{Attribute, DeriveInput, Visibility, spanned::Spanned}; use crate::{ any::models::AnyInnerType, common::{ - models::{InnerType, Meta, TypeName}, + models::{InnerType, Meta, SerdeCustomization, SpannedItem, TypeName}, parse::{intercept_derive_macro, is_derive_attribute, is_doc_attribute}, }, float::models::FloatInnerType, @@ -27,10 +27,13 @@ pub fn parse_meta(token_stream: TokenStream) -> Result { let type_name = TypeName::new(type_name); - validate_supported_attrs(&attrs)?; - intercept_derive_macro(&attrs)?; - let doc_attrs: Vec = attrs.into_iter().filter(is_doc_attribute).collect(); + + let mut serde_customization = SerdeCustomization::default(); + let PartitionedStructAttrs { + doc_attrs, + forwarded_attrs, + } = partition_struct_attrs(attrs, &mut serde_customization)?; let data_struct = match &data { syn::Data::Struct(v) => v.clone(), @@ -65,6 +68,16 @@ pub fn parse_meta(token_stream: TokenStream) -> Result { })?; validate_inner_field_visibility(&seg.vis)?; + // Detach the field attributes: + // * `#[serde(...)]` is consumed into `serde_customization`; + // * the rest are forwarded verbatim onto the generated field. + // The field embedded into `AnyInnerType` must NOT carry attributes, + // because its tokens are also used in type positions (e.g. function + // signatures), where attributes are not allowed. + let mut seg = seg.clone(); + let field_attrs = core::mem::take(&mut seg.attrs); + let inner_field_attrs = partition_field_attrs(field_attrs, &mut serde_customization)?; + let type_path_str = seg.ty.clone().into_token_stream().to_string(); // `into_token_stream().to_string()` renders paths with spaces around `::` @@ -117,24 +130,179 @@ pub fn parse_meta(token_stream: TokenStream) -> Result { generics, inner_type, vis, + forwarded_attrs, + inner_field_attrs, + serde_customization, }) } -fn validate_supported_attrs(attrs: &[syn::Attribute]) -> Result<(), syn::Error> { - fn is_supported_attr(attr: &syn::Attribute) -> bool { - is_doc_attribute(attr) || is_derive_attribute(attr) +struct PartitionedStructAttrs { + doc_attrs: Vec, + forwarded_attrs: Vec, +} + +fn attr_first_segment_is(attr: &Attribute, name: &str) -> bool { + match attr.path().segments.first() { + Some(path_segment) => path_segment.ident == name, + None => false, } +} + +/// Partition the attributes found on the struct itself: +/// * doc comments are kept separately (emitted first, as before); +/// * `#[derive(...)]` has been intercepted earlier with a targeted error; +/// * `#[serde(transparent)]` is consumed; any other serde key is rejected; +/// * `#[schemars(...)]` is rejected (nutype hand-writes JsonSchema); +/// * everything else is forwarded verbatim onto the generated struct. +/// +/// Note: `#[cfg(...)]` never reaches this partition. rustc strips item-level +/// `cfg`/`cfg_attr` attributes before the attribute macro expands, so a +/// cfg'd-out nutype struct simply disappears (the desired semantics), no +/// matter whether the `#[cfg]` is written above or below `#[nutype]`. +fn partition_struct_attrs( + attrs: Vec, + serde_customization: &mut SerdeCustomization, +) -> Result { + let mut doc_attrs: Vec = Vec::new(); + let mut forwarded_attrs: Vec = Vec::new(); + + for attr in attrs { + if is_doc_attribute(&attr) { + doc_attrs.push(attr); + } else if is_derive_attribute(&attr) { + // Already rejected by intercept_derive_macro(); unreachable here, + // but keep the arm so the partition stays exhaustive in intent. + continue; + } else if attr_first_segment_is(&attr, "serde") { + parse_struct_level_serde_attr(&attr, serde_customization)?; + } else if attr_first_segment_is(&attr, "schemars") { + let msg = "#[nutype] does not support `#[schemars(...)]` attributes.\n\ + nutype generates its own implementation of `JsonSchema`, so there is no schemars derive that could read this attribute."; + return Err(syn::Error::new(attr.span(), msg)); + } else { + forwarded_attrs.push(attr); + } + } + + Ok(PartitionedStructAttrs { + doc_attrs, + forwarded_attrs, + }) +} + +/// Parse a struct-level `#[serde(...)]` attribute. +/// The only supported key is `transparent`. +fn parse_struct_level_serde_attr( + attr: &Attribute, + serde_customization: &mut SerdeCustomization, +) -> Result<(), syn::Error> { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("transparent") { + if serde_customization.transparent.is_some() { + return Err(meta.error("Duplicated serde attribute `transparent`.")); + } + serde_customization.transparent = Some(meta.path.span()); + Ok(()) + } else { + Err(meta.error( + "#[nutype] does not support this serde attribute on the type.\n\ + The only supported type-level serde attribute is `#[serde(transparent)]`.\n\ + To customize how the inner value is serialized, use the field-level attributes\n\ + `#[serde(with = \"...\")]`, `#[serde(serialize_with = \"...\")]` or `#[serde(deserialize_with = \"...\")]`.", + )) + } + }) +} + +/// Partition the attributes found on the inner field: +/// * `#[serde(...)]` is consumed (`with`, `serialize_with`, `deserialize_with`); +/// * `#[cfg(...)]` is rejected (it could cfg away the only field); +/// * everything else is forwarded verbatim onto the generated field. +fn partition_field_attrs( + attrs: Vec, + serde_customization: &mut SerdeCustomization, +) -> Result, syn::Error> { + let mut forwarded: Vec = Vec::new(); for attr in attrs { - if !is_supported_attr(attr) { - return Err(syn::Error::new( - attr.span(), - "#[nutype] does not support this attribute.", - )); + if attr_first_segment_is(&attr, "serde") { + parse_field_level_serde_attr(&attr, serde_customization)?; + } else if attr_first_segment_is(&attr, "cfg") { + let msg = "#[nutype] does not support `#[cfg(...)]` on the inner field:\n\ + it could remove the only field of the newtype and break the generated code.\n\ + Note: `#[cfg_attr(...)]` is supported and forwarded as is."; + return Err(syn::Error::new(attr.span(), msg)); + } else { + forwarded.push(attr); } } - Ok(()) + Ok(forwarded) +} + +/// Parse a field-level `#[serde(...)]` attribute. +/// Supported keys: `with = "module"`, `serialize_with = "path"`, +/// `deserialize_with = "path"` (string literals, like in serde itself). +fn parse_field_level_serde_attr( + attr: &Attribute, + serde_customization: &mut SerdeCustomization, +) -> Result<(), syn::Error> { + fn parse_path_value( + meta: &syn::meta::ParseNestedMeta, + ) -> Result, syn::Error> { + let lit: syn::LitStr = meta.value()?.parse()?; + let path: syn::Path = lit.parse().map_err(|err| { + let msg = + format!("Expected a path (e.g. `my_module::my_function`), like in serde: {err}"); + syn::Error::new(lit.span(), msg) + })?; + Ok(SpannedItem::new(path, lit.span())) + } + + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("with") { + if serde_customization.with.is_some() { + return Err(meta.error("Duplicated serde attribute `with`.")); + } + if serde_customization.serialize_with.is_some() + || serde_customization.deserialize_with.is_some() + { + return Err(meta.error( + "`with` cannot be combined with `serialize_with` or `deserialize_with`.", + )); + } + serde_customization.with = Some(parse_path_value(&meta)?); + Ok(()) + } else if meta.path.is_ident("serialize_with") { + if serde_customization.serialize_with.is_some() { + return Err(meta.error("Duplicated serde attribute `serialize_with`.")); + } + if serde_customization.with.is_some() { + return Err(meta.error( + "`serialize_with` cannot be combined with `with`.", + )); + } + serde_customization.serialize_with = Some(parse_path_value(&meta)?); + Ok(()) + } else if meta.path.is_ident("deserialize_with") { + if serde_customization.deserialize_with.is_some() { + return Err(meta.error("Duplicated serde attribute `deserialize_with`.")); + } + if serde_customization.with.is_some() { + return Err(meta.error( + "`deserialize_with` cannot be combined with `with`.", + )); + } + serde_customization.deserialize_with = Some(parse_path_value(&meta)?); + Ok(()) + } else { + Err(meta.error( + "Unsupported serde attribute on the inner field.\n\ + #[nutype] supports only `with`, `serialize_with` and `deserialize_with` here.\n\ + If you need support for more, please open an issue: https://github.com/greyblake/nutype/issues", + )) + } + }) } fn validate_inner_field_visibility(vis: &Visibility) -> Result<(), syn::Error> { diff --git a/nutype_macros/src/decimal/generate/mod.rs b/nutype_macros/src/decimal/generate/mod.rs index 5dbc7580..5c6a439c 100644 --- a/nutype_macros/src/decimal/generate/mod.rs +++ b/nutype_macros/src/decimal/generate/mod.rs @@ -27,7 +27,8 @@ use crate::common::{ traits::GeneratedTraits, }, models::{ - ConditionalDeriveGroup, ConstFn, ErrorTypePath, Guard, SpannedDeriveUnsafeTrait, TypeName, + ConditionalDeriveGroup, ConstFn, ErrorTypePath, Guard, SerdeCustomization, + SpannedDeriveUnsafeTrait, TypeName, }, }; @@ -74,6 +75,7 @@ where maybe_default_value: Option, guard: &DecimalGuard, conditional_derives: &[ConditionalDeriveGroup], + serde_customization: &SerdeCustomization, ) -> Result { gen_traits( type_name, @@ -84,6 +86,7 @@ where maybe_default_value, guard, conditional_derives, + serde_customization, ) } diff --git a/nutype_macros/src/decimal/generate/traits/mod.rs b/nutype_macros/src/decimal/generate/traits/mod.rs index a9f45eb4..5a9b6442 100644 --- a/nutype_macros/src/decimal/generate/traits/mod.rs +++ b/nutype_macros/src/decimal/generate/traits/mod.rs @@ -16,7 +16,7 @@ use crate::{ gen_impl_trait_serde_deserialize, gen_impl_trait_serde_serialize, gen_impl_trait_try_from, process_conditional_derives, split_into_generatable_traits, }, - models::{ConditionalDeriveGroup, SpannedDeriveUnsafeTrait, TypeName}, + models::{ConditionalDeriveGroup, SerdeCustomization, SpannedDeriveUnsafeTrait, TypeName}, }, decimal::models::{DecimalDeriveTrait, DecimalGuard, DecimalInnerType}, }; @@ -33,6 +33,7 @@ pub fn gen_traits( maybe_default_value: Option, guard: &DecimalGuard, conditional_derives: &[ConditionalDeriveGroup], + serde_customization: &SerdeCustomization, ) -> Result { let GeneratableTraits { transparent_traits, @@ -53,6 +54,7 @@ pub fn gen_traits( irregular_traits, maybe_default_value.clone(), guard, + serde_customization, )?; let ConditionalTraits { @@ -67,6 +69,7 @@ pub fn gen_traits( irregular, maybe_default_value.clone(), guard, + serde_customization, ) })?; @@ -201,6 +204,7 @@ impl ToTokens for DecimalTransparentTrait { } } +#[allow(clippy::too_many_arguments)] fn gen_implemented_traits( type_name: &TypeName, generics: &Generics, @@ -208,6 +212,7 @@ fn gen_implemented_traits( impl_traits: Vec, maybe_default_value: Option, guard: &DecimalGuard, + serde_customization: &SerdeCustomization, ) -> Result { let maybe_error_type_name = guard.maybe_error_type_path(); impl_traits @@ -238,12 +243,18 @@ fn gen_implemented_traits( } } } - DecimalIrregularTrait::SerdeSerialize => Ok(gen_impl_trait_serde_serialize(type_name, generics)), + DecimalIrregularTrait::SerdeSerialize => Ok(gen_impl_trait_serde_serialize( + type_name, + generics, + inner_type, + serde_customization, + )), DecimalIrregularTrait::SerdeDeserialize => Ok(gen_impl_trait_serde_deserialize( type_name, generics, inner_type, maybe_error_type_name, + serde_customization, )), DecimalIrregularTrait::ArbitraryArbitrary => { arbitrary::gen_impl_trait_arbitrary(type_name, inner_type, guard) diff --git a/nutype_macros/src/decimal/models.rs b/nutype_macros/src/decimal/models.rs index a0743962..f57e7ec1 100644 --- a/nutype_macros/src/decimal/models.rs +++ b/nutype_macros/src/decimal/models.rs @@ -187,6 +187,12 @@ impl TypeTrait for DecimalDeriveTrait { fn is_default(&self) -> bool { self == &DecimalDeriveTrait::Default } + fn is_serde_serialize(&self) -> bool { + self == &DecimalDeriveTrait::SerdeSerialize + } + fn is_serde_deserialize(&self) -> bool { + self == &DecimalDeriveTrait::SerdeDeserialize + } } pub type DecimalRawGuard = RawGuard, SpannedDecimalValidator>; diff --git a/nutype_macros/src/float/generate/mod.rs b/nutype_macros/src/float/generate/mod.rs index 1af56a04..7b8e2a0c 100644 --- a/nutype_macros/src/float/generate/mod.rs +++ b/nutype_macros/src/float/generate/mod.rs @@ -24,8 +24,8 @@ use crate::{ traits::GeneratedTraits, }, models::{ - ConditionalDeriveGroup, ConstFn, ErrorTypePath, Guard, SpannedDeriveUnsafeTrait, - TypeName, + ConditionalDeriveGroup, ConstFn, ErrorTypePath, Guard, SerdeCustomization, + SpannedDeriveUnsafeTrait, TypeName, }, }, float::models::FloatInnerType, @@ -75,6 +75,7 @@ where maybe_default_value: Option, guard: &FloatGuard, conditional_derives: &[ConditionalDeriveGroup], + serde_customization: &SerdeCustomization, ) -> Result { gen_traits( type_name, @@ -85,6 +86,7 @@ where unsafe_traits, guard, conditional_derives, + serde_customization, ) } diff --git a/nutype_macros/src/float/generate/traits/mod.rs b/nutype_macros/src/float/generate/traits/mod.rs index 9c503c5d..469aeeb8 100644 --- a/nutype_macros/src/float/generate/traits/mod.rs +++ b/nutype_macros/src/float/generate/traits/mod.rs @@ -15,7 +15,7 @@ use crate::{ gen_impl_trait_serde_deserialize, gen_impl_trait_serde_serialize, gen_impl_trait_try_from, process_conditional_derives, split_into_generatable_traits, }, - models::{ConditionalDeriveGroup, SpannedDeriveUnsafeTrait, TypeName}, + models::{ConditionalDeriveGroup, SerdeCustomization, SpannedDeriveUnsafeTrait, TypeName}, }, float::models::{FloatDeriveTrait, FloatGuard, FloatInnerType}, }; @@ -145,6 +145,7 @@ pub fn gen_traits( unsafe_traits: &[SpannedDeriveUnsafeTrait], guard: &FloatGuard, conditional_derives: &[ConditionalDeriveGroup], + serde_customization: &SerdeCustomization, ) -> Result { let GeneratableTraits { transparent_traits, @@ -181,6 +182,7 @@ pub fn gen_traits( maybe_default_value.clone(), irregular_traits, guard, + serde_customization, )?; let ConditionalTraits { @@ -195,6 +197,7 @@ pub fn gen_traits( maybe_default_value.clone(), irregular, guard, + serde_customization, ) })?; @@ -207,6 +210,7 @@ pub fn gen_traits( }) } +#[allow(clippy::too_many_arguments)] fn gen_implemented_traits( type_name: &TypeName, generics: &Generics, @@ -214,6 +218,7 @@ fn gen_implemented_traits( maybe_default_value: Option, impl_traits: Vec, guard: &FloatGuard, + serde_customization: &SerdeCustomization, ) -> Result { let maybe_error_type_name = guard.maybe_error_type_path(); impl_traits @@ -242,12 +247,18 @@ fn gen_implemented_traits( Err(syn::Error::new(span, msg)) } }, - FloatIrregularTrait::SerdeSerialize => Ok(gen_impl_trait_serde_serialize(type_name, generics)), + FloatIrregularTrait::SerdeSerialize => Ok(gen_impl_trait_serde_serialize( + type_name, + generics, + inner_type, + serde_customization, + )), FloatIrregularTrait::SerdeDeserialize => Ok(gen_impl_trait_serde_deserialize( type_name, generics, inner_type, maybe_error_type_name, + serde_customization, )), FloatIrregularTrait::Eq => Ok(gen_impl_trait_eq(type_name)), FloatIrregularTrait::Ord => Ok(gen_impl_trait_ord(type_name)), diff --git a/nutype_macros/src/float/models.rs b/nutype_macros/src/float/models.rs index 57537f62..35008f25 100644 --- a/nutype_macros/src/float/models.rs +++ b/nutype_macros/src/float/models.rs @@ -100,6 +100,12 @@ impl TypeTrait for FloatDeriveTrait { fn is_default(&self) -> bool { self == &FloatDeriveTrait::Default } + fn is_serde_serialize(&self) -> bool { + self == &FloatDeriveTrait::SerdeSerialize + } + fn is_serde_deserialize(&self) -> bool { + self == &FloatDeriveTrait::SerdeDeserialize + } } pub type FloatRawGuard = RawGuard, SpannedFloatValidator>; diff --git a/nutype_macros/src/integer/generate/mod.rs b/nutype_macros/src/integer/generate/mod.rs index 4999499b..6a19e8fc 100644 --- a/nutype_macros/src/integer/generate/mod.rs +++ b/nutype_macros/src/integer/generate/mod.rs @@ -27,7 +27,8 @@ use crate::common::{ traits::GeneratedTraits, }, models::{ - ConditionalDeriveGroup, ConstFn, ErrorTypePath, Guard, SpannedDeriveUnsafeTrait, TypeName, + ConditionalDeriveGroup, ConstFn, ErrorTypePath, Guard, SerdeCustomization, + SpannedDeriveUnsafeTrait, TypeName, }, }; @@ -74,6 +75,7 @@ where maybe_default_value: Option, guard: &IntegerGuard, conditional_derives: &[ConditionalDeriveGroup], + serde_customization: &SerdeCustomization, ) -> Result { gen_traits( type_name, @@ -84,6 +86,7 @@ where maybe_default_value, guard, conditional_derives, + serde_customization, ) } diff --git a/nutype_macros/src/integer/generate/traits/mod.rs b/nutype_macros/src/integer/generate/traits/mod.rs index 98710b9c..378296ce 100644 --- a/nutype_macros/src/integer/generate/traits/mod.rs +++ b/nutype_macros/src/integer/generate/traits/mod.rs @@ -16,7 +16,7 @@ use crate::{ gen_impl_trait_serde_deserialize, gen_impl_trait_serde_serialize, gen_impl_trait_try_from, process_conditional_derives, split_into_generatable_traits, }, - models::{ConditionalDeriveGroup, SpannedDeriveUnsafeTrait, TypeName}, + models::{ConditionalDeriveGroup, SerdeCustomization, SpannedDeriveUnsafeTrait, TypeName}, }, integer::models::{IntegerDeriveTrait, IntegerGuard, IntegerInnerType}, }; @@ -33,6 +33,7 @@ pub fn gen_traits( maybe_default_value: Option, guard: &IntegerGuard, conditional_derives: &[ConditionalDeriveGroup], + serde_customization: &SerdeCustomization, ) -> Result { let GeneratableTraits { transparent_traits, @@ -53,6 +54,7 @@ pub fn gen_traits( irregular_traits, maybe_default_value.clone(), guard, + serde_customization, )?; let ConditionalTraits { @@ -67,6 +69,7 @@ pub fn gen_traits( irregular, maybe_default_value.clone(), guard, + serde_customization, ) })?; @@ -211,6 +214,7 @@ impl ToTokens for IntegerTransparentTrait { } } +#[allow(clippy::too_many_arguments)] fn gen_implemented_traits( type_name: &TypeName, generics: &Generics, @@ -218,6 +222,7 @@ fn gen_implemented_traits( impl_traits: Vec, maybe_default_value: Option, guard: &IntegerGuard, + serde_customization: &SerdeCustomization, ) -> Result { let maybe_error_type_name = guard.maybe_error_type_path(); impl_traits @@ -248,12 +253,18 @@ fn gen_implemented_traits( } } } - IntegerIrregularTrait::SerdeSerialize => Ok(gen_impl_trait_serde_serialize(type_name, generics)), + IntegerIrregularTrait::SerdeSerialize => Ok(gen_impl_trait_serde_serialize( + type_name, + generics, + inner_type, + serde_customization, + )), IntegerIrregularTrait::SerdeDeserialize => Ok(gen_impl_trait_serde_deserialize( type_name, generics, inner_type, maybe_error_type_name, + serde_customization, )), IntegerIrregularTrait::ArbitraryArbitrary => { arbitrary::gen_impl_trait_arbitrary(type_name, inner_type, guard) diff --git a/nutype_macros/src/integer/models.rs b/nutype_macros/src/integer/models.rs index 6b4b5f0e..ddee2c6b 100644 --- a/nutype_macros/src/integer/models.rs +++ b/nutype_macros/src/integer/models.rs @@ -99,6 +99,12 @@ impl TypeTrait for IntegerDeriveTrait { fn is_default(&self) -> bool { self == &IntegerDeriveTrait::Default } + fn is_serde_serialize(&self) -> bool { + self == &IntegerDeriveTrait::SerdeSerialize + } + fn is_serde_deserialize(&self) -> bool { + self == &IntegerDeriveTrait::SerdeDeserialize + } } pub type IntegerRawGuard = RawGuard, SpannedIntegerValidator>; diff --git a/nutype_macros/src/string/generate/mod.rs b/nutype_macros/src/string/generate/mod.rs index dd022509..2457e5cb 100644 --- a/nutype_macros/src/string/generate/mod.rs +++ b/nutype_macros/src/string/generate/mod.rs @@ -15,8 +15,8 @@ use crate::{ traits::GeneratedTraits, }, models::{ - ConditionalDeriveGroup, ConstFn, ErrorTypePath, Guard, SpannedDeriveUnsafeTrait, - TypeName, + ConditionalDeriveGroup, ConstFn, ErrorTypePath, Guard, SerdeCustomization, + SpannedDeriveUnsafeTrait, TypeName, }, }, string::models::{RegexDef, StringInnerType, StringSanitizer, StringValidator}, @@ -213,6 +213,7 @@ impl GenerateNewtype for StringNewtype { maybe_default_value: Option, guard: &StringGuard, conditional_derives: &[ConditionalDeriveGroup], + serde_customization: &SerdeCustomization, ) -> Result { gen_traits( type_name, @@ -222,6 +223,7 @@ impl GenerateNewtype for StringNewtype { maybe_default_value, guard, conditional_derives, + serde_customization, ) } diff --git a/nutype_macros/src/string/generate/traits/mod.rs b/nutype_macros/src/string/generate/traits/mod.rs index aefa5c3e..189ea59c 100644 --- a/nutype_macros/src/string/generate/traits/mod.rs +++ b/nutype_macros/src/string/generate/traits/mod.rs @@ -16,7 +16,10 @@ use crate::{ gen_impl_trait_serde_serialize, gen_impl_trait_try_from, process_conditional_derives, split_into_generatable_traits, }, - models::{ConditionalDeriveGroup, ErrorTypePath, SpannedDeriveUnsafeTrait, TypeName}, + models::{ + ConditionalDeriveGroup, ErrorTypePath, SerdeCustomization, SpannedDeriveUnsafeTrait, + TypeName, + }, }, string::models::{StringDeriveTrait, StringGuard, StringInnerType}, }; @@ -160,6 +163,7 @@ pub fn gen_traits( maybe_default_value: Option, guard: &StringGuard, conditional_derives: &[ConditionalDeriveGroup], + serde_customization: &SerdeCustomization, ) -> Result { let GeneratableTraits { transparent_traits, @@ -179,6 +183,7 @@ pub fn gen_traits( maybe_default_value.clone(), irregular_traits, guard, + serde_customization, )?; let ConditionalTraits { @@ -192,6 +197,7 @@ pub fn gen_traits( maybe_default_value.clone(), irregular, guard, + serde_customization, ) })?; @@ -210,6 +216,7 @@ fn gen_implemented_traits( maybe_default_value: Option, impl_traits: Vec, guard: &StringGuard, + serde_customization: &SerdeCustomization, ) -> Result { let inner_type = StringInnerType; let maybe_error_type_name = guard.maybe_error_type_path(); @@ -245,12 +252,18 @@ fn gen_implemented_traits( Err(syn::Error::new(span, msg)) } }, - StringIrregularTrait::SerdeSerialize => Ok(gen_impl_trait_serde_serialize(type_name, generics)), + StringIrregularTrait::SerdeSerialize => Ok(gen_impl_trait_serde_serialize( + type_name, + generics, + inner_type, + serde_customization, + )), StringIrregularTrait::SerdeDeserialize => Ok(gen_impl_trait_serde_deserialize( type_name, generics, inner_type, maybe_error_type_name, + serde_customization, )), StringIrregularTrait::ArbitraryArbitrary => { arbitrary::gen_impl_trait_arbitrary(type_name, guard) diff --git a/nutype_macros/src/string/models.rs b/nutype_macros/src/string/models.rs index e077ac53..b13f3a39 100644 --- a/nutype_macros/src/string/models.rs +++ b/nutype_macros/src/string/models.rs @@ -89,6 +89,12 @@ impl TypeTrait for StringDeriveTrait { fn is_default(&self) -> bool { self == &Self::Default } + fn is_serde_serialize(&self) -> bool { + self == &Self::SerdeSerialize + } + fn is_serde_deserialize(&self) -> bool { + self == &Self::SerdeDeserialize + } } pub type StringRawGuard = RawGuard; diff --git a/test_suite/tests/attr_passthrough.rs b/test_suite/tests/attr_passthrough.rs new file mode 100644 index 00000000..7adda56a --- /dev/null +++ b/test_suite/tests/attr_passthrough.rs @@ -0,0 +1,460 @@ +//! Tests for attribute passthrough (issue #229 and friends): +//! * struct-level attributes are forwarded onto the generated struct; +//! * field-level attributes are forwarded onto the inner field; +//! * `#[serde(...)]` field attributes (`with`, `serialize_with`, +//! `deserialize_with`) and struct-level `#[serde(transparent)]` are consumed +//! by nutype and woven into its generated serde impls, with sanitization and +//! validation still running on deserialization. + +use nutype::nutype; + +// --------------------------------------------------------------------------- +// Struct-level attribute forwarding +// --------------------------------------------------------------------------- +mod struct_attr_forwarding { + use super::*; + + #[test] + fn repr_transparent_is_forwarded() { + #[nutype(derive(Debug, PartialEq))] + #[repr(transparent)] + struct Amount(i32); + + assert_eq!(Amount::new(5), Amount::new(5)); + } + + #[test] + fn lint_attr_is_forwarded() { + #[nutype(derive(Debug))] + #[allow(missing_docs)] + struct Tag(String); + + assert_eq!(Tag::new("x").into_inner(), "x"); + } + + #[test] + fn cfg_attr_wrapped_attr_is_forwarded() { + #[nutype(derive(Debug))] + #[cfg_attr(test, allow(missing_docs))] + struct Label(String); + + assert_eq!(Label::new("x").into_inner(), "x"); + } + + #[test] + fn attr_above_nutype_is_forwarded() { + #[allow(missing_docs)] + #[nutype(derive(Debug))] + struct Above(i32); + + assert_eq!(Above::new(1).into_inner(), 1); + } + + #[test] + fn attrs_mix_with_doc_comments() { + /// Mixed is documented. + #[nutype(derive(Debug))] + #[repr(transparent)] + #[allow(missing_docs)] + struct Mixed(f64); + + assert_eq!(Mixed::new(1.5).into_inner(), 1.5); + } + + // rustc strips item-level `cfg` before the attribute macro expands, so + // `#[cfg(...)]` composes natively with #[nutype], in both positions: + // a false predicate removes the whole type (nutype never runs), a true + // predicate just drops the cfg attribute. + #[test] + fn cfg_on_the_item_is_handled_natively_by_rustc() { + #[cfg(test)] + #[nutype(derive(Debug))] + struct AboveTrue(i32); + + #[nutype(derive(Debug))] + #[cfg(test)] + struct BelowTrue(i32); + + // `any()` is always false: the whole item (including #[nutype]) is + // stripped before expansion. If nutype were invoked on it, this would + // not compile (the type is referenced nowhere, but a nutype parse + // error would still abort compilation). + #[nutype(derive(Debug), validate(unknown_validator_that_would_error))] + #[cfg(any())] + struct StrippedEntirely(i32); + + assert_eq!(AboveTrue::new(1).into_inner(), 1); + assert_eq!(BelowTrue::new(2).into_inner(), 2); + } + + #[test] + fn const_fn_with_forwarded_attrs() { + #[nutype(const_fn, derive(Debug), validate(greater_or_equal = 0))] + #[repr(transparent)] + struct Positive(i32); + + const P: Positive = match Positive::try_new(3) { + Ok(value) => value, + Err(_) => panic!("invalid"), + }; + assert_eq!(P.into_inner(), 3); + } +} + +// --------------------------------------------------------------------------- +// Field-level attribute forwarding +// --------------------------------------------------------------------------- +mod field_attr_forwarding { + use super::*; + + #[test] + fn builtin_field_attr_on_string_kind() { + #[nutype(derive(Debug))] + struct Name(#[allow(unused)] String); + + assert_eq!(Name::new("x").into_inner(), "x"); + } + + #[test] + fn builtin_field_attr_on_integer_kind() { + #[nutype(derive(Debug))] + struct Count(#[allow(unused)] u32); + + assert_eq!(Count::new(7).into_inner(), 7); + } + + // Regression guard: for "any" inner types the field used to be embedded + // into the generated code as a whole `syn::Field`, leaking field + // attributes into type positions (e.g. `fn try_new(raw_value: #[attr] Vec)`). + #[test] + fn builtin_field_attr_on_any_kind() { + #[nutype(derive(Debug))] + struct Wrapper(#[allow(unused)] Vec); + + assert_eq!(Wrapper::new(vec![1]).into_inner(), vec![1]); + } + + #[test] + fn field_attr_on_generic_newtype() { + #[nutype(derive(Debug))] + struct Many(#[allow(unused)] Vec); + + assert_eq!(Many::new(vec![1, 2]).into_inner(), vec![1, 2]); + } + + #[test] + fn cfg_attr_wrapped_field_attr() { + #[nutype(derive(Debug))] + struct Guarded(#[cfg_attr(test, allow(unused))] i64); + + assert_eq!(Guarded::new(3).into_inner(), 3); + } +} + +// NOTE: the proof that forwarded field attributes are read by a real +// third-party derive (via derive_unchecked) lives in +// examples/derive_unchecked_example, because the test_suite deliberately does +// not enable the `derive_unchecked` feature (several UI fixtures pin the +// feature-off error messages and would flip under --all-features). + +// --------------------------------------------------------------------------- +// Serde customization: with / serialize_with / deserialize_with / transparent +// --------------------------------------------------------------------------- +#[cfg(feature = "serde")] +mod serde_customization { + use super::*; + + /// Toy codec: Vec as a hex string. + pub mod hex_bytes { + pub fn serialize(v: &Vec, s: S) -> Result { + let hex: String = v.iter().map(|b| format!("{b:02x}")).collect(); + s.serialize_str(&hex) + } + + pub fn deserialize<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + use serde::Deserialize; + let s = String::deserialize(d)?; + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(serde::de::Error::custom)) + .collect() + } + } + + pub fn ser_i32_as_string(v: &i32, s: S) -> Result { + s.serialize_str(&v.to_string()) + } + + pub fn de_i32_from_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + use serde::Deserialize; + let s = String::deserialize(d)?; + s.parse::().map_err(serde::de::Error::custom) + } + + pub fn ser_string_uppercase(v: &String, s: S) -> Result { + s.serialize_str(&v.to_uppercase()) + } + + mod with_module { + use super::*; + + #[test] + fn roundtrip_through_custom_codec() { + #[nutype( + validate(predicate = |v| !v.is_empty()), + derive(Debug, PartialEq, Serialize, Deserialize), + )] + struct Token(#[serde(with = "hex_bytes")] Vec); + + let token = Token::try_new(vec![0xde, 0xad]).unwrap(); + let json = serde_json::to_string(&token).unwrap(); + assert_eq!(json, "\"dead\""); + + let back: Token = serde_json::from_str(&json).unwrap(); + assert_eq!(back, token); + } + + #[test] + fn validation_still_runs_after_custom_deserialization() { + #[nutype( + validate(predicate = |v| !v.is_empty()), + derive(Debug, PartialEq, Serialize, Deserialize), + )] + struct Token(#[serde(with = "hex_bytes")] Vec); + + // Empty hex string decodes fine, but violates the predicate. + let err = serde_json::from_str::("\"\"").unwrap_err(); + assert!( + err.to_string().contains("Expected valid Token"), + "unexpected error: {err}" + ); + } + + #[test] + fn sanitization_still_runs_after_custom_deserialization() { + #[nutype( + sanitize(with = |mut v| { v.sort(); v }), + derive(Debug, PartialEq, Serialize, Deserialize), + )] + struct SortedBytes(#[serde(with = "hex_bytes")] Vec); + + // 0x02, 0x01 in the wire format must come out sorted. + let value: SortedBytes = serde_json::from_str("\"0201\"").unwrap(); + assert_eq!(value.into_inner(), vec![0x01, 0x02]); + } + } + + mod serialize_with_only { + use super::*; + + #[test] + fn integer_kind() { + #[nutype(derive(Debug, Serialize))] + struct Id(#[serde(serialize_with = "ser_i32_as_string")] i32); + + let json = serde_json::to_string(&Id::new(7)).unwrap(); + assert_eq!(json, "\"7\""); + } + + #[test] + fn string_kind() { + #[nutype(sanitize(trim), derive(Debug, Serialize))] + struct Code(#[serde(serialize_with = "ser_string_uppercase")] String); + + let json = serde_json::to_string(&Code::new(" abc ")).unwrap(); + assert_eq!(json, "\"ABC\""); + } + } + + mod deserialize_with_only { + use super::*; + + #[test] + fn integer_kind_with_validation() { + #[nutype(validate(greater_or_equal = 0), derive(Debug, Deserialize))] + struct Id(#[serde(deserialize_with = "de_i32_from_string")] i32); + + let id: Id = serde_json::from_str("\"7\"").unwrap(); + assert_eq!(id.into_inner(), 7); + + // Custom deserialization succeeded, validation must still reject. + let err = serde_json::from_str::("\"-3\"").unwrap_err(); + assert!( + err.to_string().contains("Expected valid Id"), + "unexpected error: {err}" + ); + } + } + + mod transparent { + use super::*; + + #[test] + fn serialization_equals_inner_value_json_and_ron() { + #[nutype(derive(Debug, PartialEq, Serialize, Deserialize))] + #[serde(transparent)] + struct Meters(i32); + + let m = Meters::new(5); + assert_eq!( + serde_json::to_string(&m).unwrap(), + serde_json::to_string(&5).unwrap() + ); + // The principled framing assertion: transparent output is exactly + // the inner value's own serialization, in any format. + assert_eq!(ron::to_string(&m).unwrap(), ron::to_string(&5).unwrap()); + + // And it deserializes back from the inner value's serialization. + let from_ron: Meters = ron::from_str(&ron::to_string(&5).unwrap()).unwrap(); + assert_eq!(from_ron, m); + let from_json: Meters = serde_json::from_str("5").unwrap(); + assert_eq!(from_json, m); + } + + #[test] + fn validation_still_runs() { + #[nutype(validate(greater_or_equal = 0), derive(Debug, Serialize, Deserialize))] + #[serde(transparent)] + struct Positive(i32); + + let err = serde_json::from_str::("-1").unwrap_err(); + assert!( + err.to_string().contains("Expected valid Positive"), + "unexpected error: {err}" + ); + } + + #[test] + fn float_kind() { + #[nutype(validate(finite), derive(Debug, PartialEq, Serialize, Deserialize))] + #[serde(transparent)] + struct Weight(f64); + + let w = Weight::try_new(72.5).unwrap(); + let json = serde_json::to_string(&w).unwrap(); + assert_eq!(json, "72.5"); + let back: Weight = serde_json::from_str(&json).unwrap(); + assert_eq!(back, w); + } + + #[test] + fn string_kind_with_sanitization() { + #[nutype(sanitize(trim), derive(Debug, PartialEq, Serialize, Deserialize))] + #[serde(transparent)] + struct Login(String); + + let login: Login = serde_json::from_str("\" alice \"").unwrap(); + assert_eq!(login.into_inner(), "alice"); + } + + #[test] + fn generic_newtype() { + #[nutype(derive(Debug, PartialEq, Serialize, Deserialize))] + #[serde(transparent)] + struct AnyVal(T); + + let v = AnyVal::new(33); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "33"); + let back: AnyVal = serde_json::from_str(&json).unwrap(); + assert_eq!(back, v); + } + + #[test] + fn combined_with_custom_functions() { + #[nutype( + validate(predicate = |v| !v.is_empty()), + derive(Debug, PartialEq, Serialize, Deserialize), + )] + #[serde(transparent)] + struct Token(#[serde(with = "hex_bytes")] Vec); + + let token = Token::try_new(vec![0xbe, 0xef]).unwrap(); + let json = serde_json::to_string(&token).unwrap(); + assert_eq!(json, "\"beef\""); + let back: Token = serde_json::from_str(&json).unwrap(); + assert_eq!(back, token); + + // Validation still gates the custom deserialization. + let err = serde_json::from_str::("\"\"").unwrap_err(); + assert!( + err.to_string().contains("Expected valid Token"), + "unexpected error: {err}" + ); + } + } + + mod conditional_derives { + use super::*; + + // The serde derives come from a cfg_attr group; the field serde attrs + // must thread through the conditional generation path as well. + #[test] + fn cfg_attr_serde_derive_with_field_attrs() { + #[nutype( + derive(Debug, PartialEq), + cfg_attr(feature = "serde", derive(Serialize, Deserialize)) + )] + struct CondToken(#[serde(with = "hex_bytes")] Vec); + + let token = CondToken::new(vec![0x0a, 0xff]); + let json = serde_json::to_string(&token).unwrap(); + assert_eq!(json, "\"0aff\""); + let back: CondToken = serde_json::from_str(&json).unwrap(); + assert_eq!(back, token); + } + + #[test] + fn cfg_attr_serde_derive_with_transparent() { + #[nutype( + derive(Debug, PartialEq), + cfg_attr(feature = "serde", derive(Serialize, Deserialize)) + )] + #[serde(transparent)] + struct CondMeters(i32); + + let m = CondMeters::new(8); + assert_eq!(ron::to_string(&m).unwrap(), ron::to_string(&8).unwrap()); + } + } + + // The default (non-transparent) newtype framing must stay intact for + // types that use custom functions: RON roundtrip exercises the + // visit_newtype_struct path. + mod default_framing_preserved { + use super::*; + + #[test] + fn ron_roundtrip_with_custom_codec() { + #[nutype(derive(Debug, PartialEq, Serialize, Deserialize))] + struct Blob(#[serde(with = "hex_bytes")] Vec); + + let blob = Blob::new(vec![0x01, 0x02]); + let ron_str = ron::to_string(&blob).unwrap(); + let back: Blob = ron::from_str(&ron_str).unwrap(); + assert_eq!(back, blob); + } + } +} + +#[cfg(all(feature = "serde", feature = "rust_decimal"))] +mod serde_customization_decimal { + use super::*; + use rust_decimal::Decimal; + + #[test] + fn transparent_decimal() { + #[nutype( + validate(greater_or_equal = 0), + derive(Debug, PartialEq, Serialize, Deserialize) + )] + #[serde(transparent)] + struct Price(Decimal); + + let price = Price::try_new(Decimal::new(1234, 2)).unwrap(); // 12.34 + let json = serde_json::to_string(&price).unwrap(); + assert_eq!(json, serde_json::to_string(&Decimal::new(1234, 2)).unwrap()); + let back: Price = serde_json::from_str(&json).unwrap(); + assert_eq!(back, price); + } +} diff --git a/test_suite/tests/ui/common/attrs/field_cfg_rejected.rs b/test_suite/tests/ui/common/attrs/field_cfg_rejected.rs new file mode 100644 index 00000000..81d9a5a8 --- /dev/null +++ b/test_suite/tests/ui/common/attrs/field_cfg_rejected.rs @@ -0,0 +1,8 @@ +use nutype::nutype; + +// #[cfg] on the inner field could cfg the only field away, breaking every +// generated impl. It must be rejected explicitly. +#[nutype(derive(Debug))] +pub struct Name(#[cfg(test)] String); + +fn main() {} diff --git a/test_suite/tests/ui/common/attrs/field_cfg_rejected.stderr b/test_suite/tests/ui/common/attrs/field_cfg_rejected.stderr new file mode 100644 index 00000000..c6d608c3 --- /dev/null +++ b/test_suite/tests/ui/common/attrs/field_cfg_rejected.stderr @@ -0,0 +1,7 @@ +error: #[nutype] does not support `#[cfg(...)]` on the inner field: + it could remove the only field of the newtype and break the generated code. + Note: `#[cfg_attr(...)]` is supported and forwarded as is. + --> tests/ui/common/attrs/field_cfg_rejected.rs:6:17 + | +6 | pub struct Name(#[cfg(test)] String); + | ^ diff --git a/test_suite/tests/ui/common/attrs/field_serde_bad_path.rs b/test_suite/tests/ui/common/attrs/field_serde_bad_path.rs new file mode 100644 index 00000000..35e29ec1 --- /dev/null +++ b/test_suite/tests/ui/common/attrs/field_serde_bad_path.rs @@ -0,0 +1,7 @@ +use nutype::nutype; + +// The value must be a path, like in serde itself. +#[nutype(derive(Debug))] +pub struct Name(#[serde(serialize_with = "99 not a path")] String); + +fn main() {} diff --git a/test_suite/tests/ui/common/attrs/field_serde_bad_path.stderr b/test_suite/tests/ui/common/attrs/field_serde_bad_path.stderr new file mode 100644 index 00000000..77b90113 --- /dev/null +++ b/test_suite/tests/ui/common/attrs/field_serde_bad_path.stderr @@ -0,0 +1,5 @@ +error: Expected a path (e.g. `my_module::my_function`), like in serde: expected identifier + --> tests/ui/common/attrs/field_serde_bad_path.rs:5:42 + | +5 | pub struct Name(#[serde(serialize_with = "99 not a path")] String); + | ^^^^^^^^^^^^^^^ diff --git a/test_suite/tests/ui/common/attrs/field_serde_duplicate_key.rs b/test_suite/tests/ui/common/attrs/field_serde_duplicate_key.rs new file mode 100644 index 00000000..e10aabc5 --- /dev/null +++ b/test_suite/tests/ui/common/attrs/field_serde_duplicate_key.rs @@ -0,0 +1,7 @@ +use nutype::nutype; + +// Duplicated serde keys must be rejected. +#[nutype(derive(Debug))] +pub struct Name(#[serde(serialize_with = "serialize_fn_a", serialize_with = "serialize_fn_b")] String); + +fn main() {} diff --git a/test_suite/tests/ui/common/attrs/field_serde_duplicate_key.stderr b/test_suite/tests/ui/common/attrs/field_serde_duplicate_key.stderr new file mode 100644 index 00000000..e5de2cae --- /dev/null +++ b/test_suite/tests/ui/common/attrs/field_serde_duplicate_key.stderr @@ -0,0 +1,5 @@ +error: Duplicated serde attribute `serialize_with`. + --> tests/ui/common/attrs/field_serde_duplicate_key.rs:5:60 + | +5 | pub struct Name(#[serde(serialize_with = "serialize_fn_a", serialize_with = "serialize_fn_b")] String); + | ^^^^^^^^^^^^^^ diff --git a/test_suite/tests/ui/common/attrs/field_serde_requires_derive.rs b/test_suite/tests/ui/common/attrs/field_serde_requires_derive.rs new file mode 100644 index 00000000..93888236 --- /dev/null +++ b/test_suite/tests/ui/common/attrs/field_serde_requires_derive.rs @@ -0,0 +1,8 @@ +use nutype::nutype; + +// serialize_with requires Serialize to be derived through +// #[nutype(derive(...))]; the field attr alone does nothing. +#[nutype(derive(Debug))] +pub struct Name(#[serde(serialize_with = "serialize_fn")] String); + +fn main() {} diff --git a/test_suite/tests/ui/common/attrs/field_serde_requires_derive.stderr b/test_suite/tests/ui/common/attrs/field_serde_requires_derive.stderr new file mode 100644 index 00000000..d372e7ac --- /dev/null +++ b/test_suite/tests/ui/common/attrs/field_serde_requires_derive.stderr @@ -0,0 +1,6 @@ +error: #[serde(serialize_with = ...)] on the inner field requires `Serialize` to be derived. + Add it with #[nutype(derive(Serialize))]. + --> tests/ui/common/attrs/field_serde_requires_derive.rs:6:42 + | +6 | pub struct Name(#[serde(serialize_with = "serialize_fn")] String); + | ^^^^^^^^^^^^^^ diff --git a/test_suite/tests/ui/common/attrs/field_serde_unknown_key.rs b/test_suite/tests/ui/common/attrs/field_serde_unknown_key.rs new file mode 100644 index 00000000..67a5aa84 --- /dev/null +++ b/test_suite/tests/ui/common/attrs/field_serde_unknown_key.rs @@ -0,0 +1,8 @@ +use nutype::nutype; + +// Only `with`, `serialize_with` and `deserialize_with` are supported on the +// inner field. +#[nutype(derive(Debug))] +pub struct Name(#[serde(rename = "name")] String); + +fn main() {} diff --git a/test_suite/tests/ui/common/attrs/field_serde_unknown_key.stderr b/test_suite/tests/ui/common/attrs/field_serde_unknown_key.stderr new file mode 100644 index 00000000..cb74bac4 --- /dev/null +++ b/test_suite/tests/ui/common/attrs/field_serde_unknown_key.stderr @@ -0,0 +1,7 @@ +error: Unsupported serde attribute on the inner field. + #[nutype] supports only `with`, `serialize_with` and `deserialize_with` here. + If you need support for more, please open an issue: https://github.com/greyblake/nutype/issues + --> tests/ui/common/attrs/field_serde_unknown_key.rs:6:25 + | +6 | pub struct Name(#[serde(rename = "name")] String); + | ^^^^^^ diff --git a/test_suite/tests/ui/common/attrs/field_serde_with_conflict.rs b/test_suite/tests/ui/common/attrs/field_serde_with_conflict.rs new file mode 100644 index 00000000..798b196b --- /dev/null +++ b/test_suite/tests/ui/common/attrs/field_serde_with_conflict.rs @@ -0,0 +1,8 @@ +use nutype::nutype; + +// `with` already provides both directions; combining it with +// `serialize_with` is ambiguous and must be rejected (same as serde). +#[nutype(derive(Debug))] +pub struct Name(#[serde(with = "codec", serialize_with = "serialize_fn")] String); + +fn main() {} diff --git a/test_suite/tests/ui/common/attrs/field_serde_with_conflict.stderr b/test_suite/tests/ui/common/attrs/field_serde_with_conflict.stderr new file mode 100644 index 00000000..0f51d064 --- /dev/null +++ b/test_suite/tests/ui/common/attrs/field_serde_with_conflict.stderr @@ -0,0 +1,5 @@ +error: `serialize_with` cannot be combined with `with`. + --> tests/ui/common/attrs/field_serde_with_conflict.rs:6:41 + | +6 | pub struct Name(#[serde(with = "codec", serialize_with = "serialize_fn")] String); + | ^^^^^^^^^^^^^^ diff --git a/test_suite/tests/ui/common/attrs/native_derive_rejected.rs b/test_suite/tests/ui/common/attrs/native_derive_rejected.rs new file mode 100644 index 00000000..32c11655 --- /dev/null +++ b/test_suite/tests/ui/common/attrs/native_derive_rejected.rs @@ -0,0 +1,9 @@ +use nutype::nutype; + +// Native #[derive(...)] must keep being rejected: the derive set is curated +// by #[nutype(derive(...))] to protect the type's invariants. +#[nutype(validate(not_empty))] +#[derive(Clone)] +pub struct Name(String); + +fn main() {} diff --git a/test_suite/tests/ui/common/attrs/native_derive_rejected.stderr b/test_suite/tests/ui/common/attrs/native_derive_rejected.stderr new file mode 100644 index 00000000..af2c3f78 --- /dev/null +++ b/test_suite/tests/ui/common/attrs/native_derive_rejected.stderr @@ -0,0 +1,10 @@ +error: #[derive(..)] macro is not allowed to be used with #[nutype]. If you want to derive traits use `derive(..) attribute within #[nutype] macro: + + #[nutype( + derive(Debug, Clone, AsRef) + )] + + --> tests/ui/common/attrs/native_derive_rejected.rs:6:1 + | +6 | #[derive(Clone)] + | ^ diff --git a/test_suite/tests/ui/common/attrs/serde_transparent_requires_derive.rs b/test_suite/tests/ui/common/attrs/serde_transparent_requires_derive.rs new file mode 100644 index 00000000..ad4c6d98 --- /dev/null +++ b/test_suite/tests/ui/common/attrs/serde_transparent_requires_derive.rs @@ -0,0 +1,8 @@ +use nutype::nutype; + +// #[serde(transparent)] without serde derives has nothing to apply to. +#[nutype(derive(Debug))] +#[serde(transparent)] +pub struct Name(String); + +fn main() {} diff --git a/test_suite/tests/ui/common/attrs/serde_transparent_requires_derive.stderr b/test_suite/tests/ui/common/attrs/serde_transparent_requires_derive.stderr new file mode 100644 index 00000000..cfabb5bc --- /dev/null +++ b/test_suite/tests/ui/common/attrs/serde_transparent_requires_derive.stderr @@ -0,0 +1,6 @@ +error: #[serde(transparent)] requires `Serialize` or `Deserialize` to be derived. + Add it with #[nutype(derive(Serialize, Deserialize))]. + --> tests/ui/common/attrs/serde_transparent_requires_derive.rs:5:9 + | +5 | #[serde(transparent)] + | ^^^^^^^^^^^ diff --git a/test_suite/tests/ui/common/attrs/struct_attr_deprecated_forwarded.rs b/test_suite/tests/ui/common/attrs/struct_attr_deprecated_forwarded.rs new file mode 100644 index 00000000..45144bf0 --- /dev/null +++ b/test_suite/tests/ui/common/attrs/struct_attr_deprecated_forwarded.rs @@ -0,0 +1,13 @@ +#![deny(deprecated)] +use nutype::nutype; + +// Observable proof that struct attributes are forwarded: if #[deprecated] +// reaches the generated struct, using the type triggers the deny(deprecated) +// error below. If the attribute were dropped, this file would compile. +#[nutype(derive(Debug))] +#[deprecated(note = "use NewName instead")] +pub struct OldName(String); + +fn main() { + let _ = OldName::try_new("hello"); +} diff --git a/test_suite/tests/ui/common/attrs/struct_attr_deprecated_forwarded.stderr b/test_suite/tests/ui/common/attrs/struct_attr_deprecated_forwarded.stderr new file mode 100644 index 00000000..9483d817 --- /dev/null +++ b/test_suite/tests/ui/common/attrs/struct_attr_deprecated_forwarded.stderr @@ -0,0 +1,41 @@ +error: use of deprecated struct `__nutype_OldName__::OldName`: use NewName instead + --> tests/ui/common/attrs/struct_attr_deprecated_forwarded.rs:9:12 + | +9 | pub struct OldName(String); + | ^^^^^^^ + | +note: the lint level is defined here + --> tests/ui/common/attrs/struct_attr_deprecated_forwarded.rs:1:9 + | +1 | #![deny(deprecated)] + | ^^^^^^^^^^ + +error: use of deprecated struct `__nutype_OldName__::OldName`: use NewName instead + --> tests/ui/common/attrs/struct_attr_deprecated_forwarded.rs:12:13 + | +12 | let _ = OldName::try_new("hello"); + | ^^^^^^^ + +error: use of deprecated field `__nutype_OldName__::OldName::0`: use NewName instead + --> tests/ui/common/attrs/struct_attr_deprecated_forwarded.rs:7:1 + | +7 | #[nutype(derive(Debug))] + | ^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `nutype` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0599]: no associated function or constant named `try_new` found for struct `OldName` in the current scope + --> tests/ui/common/attrs/struct_attr_deprecated_forwarded.rs:12:22 + | + 7 | #[nutype(derive(Debug))] + | ------------------------ associated function or constant `try_new` not found for this struct +... +12 | let _ = OldName::try_new("hello"); + | ^^^^^^^ associated function or constant not found in `OldName` + | +note: if you're trying to build a new `OldName`, consider using `OldName::new` which returns `OldName` + --> tests/ui/common/attrs/struct_attr_deprecated_forwarded.rs:7:1 + | + 7 | #[nutype(derive(Debug))] + | ^^^^^^^^^^^^^^^^^^^^^^^^ + = note: this error originates in the attribute macro `nutype` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/test_suite/tests/ui/common/attrs/struct_schemars_rejected.rs b/test_suite/tests/ui/common/attrs/struct_schemars_rejected.rs new file mode 100644 index 00000000..f04c0369 --- /dev/null +++ b/test_suite/tests/ui/common/attrs/struct_schemars_rejected.rs @@ -0,0 +1,9 @@ +use nutype::nutype; + +// schemars attributes are not supported (yet): nutype generates its own +// JsonSchema impl, so the helper attr would not resolve. +#[nutype(derive(Debug))] +#[schemars(title = "Name")] +pub struct Name(String); + +fn main() {} diff --git a/test_suite/tests/ui/common/attrs/struct_schemars_rejected.stderr b/test_suite/tests/ui/common/attrs/struct_schemars_rejected.stderr new file mode 100644 index 00000000..efec560b --- /dev/null +++ b/test_suite/tests/ui/common/attrs/struct_schemars_rejected.stderr @@ -0,0 +1,6 @@ +error: #[nutype] does not support `#[schemars(...)]` attributes. + nutype generates its own implementation of `JsonSchema`, so there is no schemars derive that could read this attribute. + --> tests/ui/common/attrs/struct_schemars_rejected.rs:6:1 + | +6 | #[schemars(title = "Name")] + | ^ diff --git a/test_suite/tests/ui/common/attrs/struct_serde_unsupported.rs b/test_suite/tests/ui/common/attrs/struct_serde_unsupported.rs new file mode 100644 index 00000000..86a2d2d0 --- /dev/null +++ b/test_suite/tests/ui/common/attrs/struct_serde_unsupported.rs @@ -0,0 +1,9 @@ +use nutype::nutype; + +// Struct-level serde attributes other than `transparent` are not supported: +// nutype generates its own serde impls, so the helper attr would not resolve. +#[nutype(derive(Debug))] +#[serde(rename_all = "camelCase")] +pub struct Name(String); + +fn main() {} diff --git a/test_suite/tests/ui/common/attrs/struct_serde_unsupported.stderr b/test_suite/tests/ui/common/attrs/struct_serde_unsupported.stderr new file mode 100644 index 00000000..9b5a065b --- /dev/null +++ b/test_suite/tests/ui/common/attrs/struct_serde_unsupported.stderr @@ -0,0 +1,8 @@ +error: #[nutype] does not support this serde attribute on the type. + The only supported type-level serde attribute is `#[serde(transparent)]`. + To customize how the inner value is serialized, use the field-level attributes + `#[serde(with = "...")]`, `#[serde(serialize_with = "...")]` or `#[serde(deserialize_with = "...")]`. + --> tests/ui/common/attrs/struct_serde_unsupported.rs:6:9 + | +6 | #[serde(rename_all = "camelCase")] + | ^^^^^^^^^^ From af672a451d907a18315b4dfc92d7f3f94d13e32b Mon Sep 17 00:00:00 2001 From: Serhii Potapov Date: Fri, 12 Jun 2026 16:01:15 +0200 Subject: [PATCH 2/2] docs: document attribute passthrough and serde customization README and macro docs: new "Attribute passthrough" section with sqlx/garde recipes, the serde customization subsection, and the boundary note (nutype forwards attributes verbatim and cannot verify what they do; an attribute macro that rewrites the type can break the guarantees, exactly like derive_unchecked). --- README.md | 41 +++++++++++++++++++++++++++++++++++++++++ nutype/src/lib.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/README.md b/README.md index 58244647..9cd5c540 100644 --- a/README.md +++ b/README.md @@ -509,6 +509,47 @@ pub struct Tag(String); Note that a trait cannot appear in both unconditional `derive` and `cfg_attr` `derive` at the same time. +## Attribute passthrough + +Attributes on the struct and on the inner field are forwarded verbatim onto +the generated type, so the compiler and third-party derives (via +`derive_unchecked`) can read them, e.g. `#[repr(transparent)]`, +`#[sqlx(transparent)]` or field-level garde validation: + +```rust,ignore +#[nutype( + sanitize(trim), + derive(Debug, Clone), + derive_unchecked(garde::Validate), +)] +pub struct UserId(#[garde(length(min = 1))] String); +``` + +Exceptions: `#[derive(...)]` must go through `#[nutype(derive(...))]`, +`#[serde(...)]` is handled by nutype itself (see below), and `#[cfg(...)]` is +rejected on the inner field. + +nutype forwards attributes verbatim and cannot verify what they do: an +attribute macro that rewrites the type can break nutype's guarantees, exactly +like `derive_unchecked`. + +### Serde customization + +nutype generates its own `Serialize`/`Deserialize` impls and understands +field-level `#[serde(with = "...")]`, `#[serde(serialize_with = "...")]`, +`#[serde(deserialize_with = "...")]` and type-level `#[serde(transparent)]` +natively. Sanitization and validation always run on deserialization, even +with a custom `deserialize_with` function: + +```rust,ignore +#[nutype( + validate(predicate = |v| !v.is_empty()), + derive(Debug, Serialize, Deserialize), +)] +pub struct InvitationToken(#[serde(with = "base64_codec")] Vec); +``` + + ## Constants You can mark a type with the `const_fn` flag. In that case, its `new` and `try_new` functions will be declared as `const`: diff --git a/nutype/src/lib.rs b/nutype/src/lib.rs index f27d8cbf..d91fbc56 100644 --- a/nutype/src/lib.rs +++ b/nutype/src/lib.rs @@ -513,6 +513,46 @@ //! Note that a trait cannot appear in both unconditional `derive` and `cfg_attr` `derive` at the same time. //! //! +//! ## Attribute passthrough +//! +//! Attributes on the struct and on the inner field are forwarded verbatim +//! onto the generated type, so the compiler and third-party derives (via +//! `derive_unchecked`) can read them: +//! +//! ```ignore +//! #[nutype( +//! sanitize(trim), +//! derive(Debug, Clone), +//! derive_unchecked(garde::Validate), +//! )] +//! pub struct UserId(#[garde(length(min = 1))] String); +//! ``` +//! +//! Exceptions: `#[derive(...)]` must go through `#[nutype(derive(...))]`, +//! `#[serde(...)]` is handled by nutype itself (see below), and `#[cfg(...)]` +//! is rejected on the inner field. +//! +//! nutype cannot verify what a forwarded attribute does: an attribute macro +//! that rewrites the type can break nutype's guarantees, exactly like +//! `derive_unchecked`. +//! +//! ### Serde customization +//! +//! nutype generates its own `Serialize`/`Deserialize` impls and understands +//! field-level `#[serde(with = "...")]`, `#[serde(serialize_with = "...")]`, +//! `#[serde(deserialize_with = "...")]` and type-level `#[serde(transparent)]` +//! natively. Sanitization and validation always run on deserialization, even +//! with a custom `deserialize_with` function: +//! +//! ```ignore +//! #[nutype( +//! validate(predicate = |v| !v.is_empty()), +//! derive(Debug, Serialize, Deserialize), +//! )] +//! pub struct InvitationToken(#[serde(with = "base64_codec")] Vec); +//! ``` +//! +//! //! ## Constants //! //! You can mark a type with the `const_fn` flag. In that case, its `new` and `try_new` functions will be declared as `const`: