diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 641ca1b..0bd61c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,7 @@ jobs: with: tool: typos-cli,taplo-cli,hawkeye - run: cargo x lint + - run: cargo x package --locked msrv: name: Resolve MSRV diff --git a/CHANGELOG.md b/CHANGELOG.md index cecd5ad..8e0fa5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,16 +10,26 @@ All notable changes to this project will be documented in this file. * Make the `ShapeId` tuple field private. Use `ShapeId::index()` when the graph-local numeric index is needed. * Add `description` to definition, field, and variant metadata. Manual struct literals must initialize the new field. * Remove `OpaqueReason::{FromType, TryFromType, IntoType}` because Serde conversion attributes now use the conversion type's shape instead of an opaque boundary. +* Restrict the blanket `Box` deserialization shape to sized `T`. Serde-supported owned DSTs have explicit implementations; shape-only custom DSTs now need a local newtype. ### New features * Add `#[serde_shape(serialize_with = "path", deserialize_with = "path")]` hooks for custom Serde functions and foreign representations. +* Allow custom shape hooks on enum variants so known custom variant content does not have to remain opaque. +* Reflect Serde's byte-buffer representation for `CStr`, `CString`, and owned `Box` input. +* Add `Rc`, `Arc`, and weak-pointer shapes, preserving Serde's owned input and optional weak-pointer representations. +* Reflect `Saturating` with Serde's generic serialization and primitive-only deserialization support. +* Add named struct shapes for Serde's `Range`, `RangeFrom`, `RangeInclusive`, and `RangeTo` representations. +* Reflect `Bound` as Serde's externally tagged `Unbounded`, `Included`, and `Excluded` enum. +* Add the `std`-only `SystemTime` struct shape with Serde's epoch field names. * Add directional `#[serde_shape(bound(...))]` overrides for generic custom shape hooks. * Add `SerializeShapeGraph::root_definition` and `DeserializeShapeGraph::root_definition` for directly inspecting named root types. * Preserve Rust doc comments on derived containers, variants, and fields as user-facing descriptions. ### Bug fixes +* Distinguish borrowed byte input from owned boxed slices: `&[u8]` reflects bytes while `Box<[u8]>` reflects a sequence, matching Serde. +* Match Serde's serialization bounds for `BinaryHeap`, `RefCell`, `Mutex`, and `RwLock`, including unsized wrapper contents. * Reflect the proxy type used by Serde `from`, `try_from`, and `into` container attributes. * Follow Serde's directional bounds for `Cow`: serialization reflects the borrowed type and deserialization reflects the owned type. * Make IP and socket address shapes available in `no_std` builds through `core::net`. @@ -27,6 +37,8 @@ All notable changes to this project will be documented in this file. ### Improvements +* Verify both publishable crate archives from their normalized manifests in CI. +* Implement `Default` for container attributes, `Tagging`, and `DefaultShape` so manual shape implementations can initialize ordinary Serde metadata concisely. * Allow unsized types such as `str` and slices to use the `SerializeShape::serialize_shape` and `DeserializeShape::deserialize_shape` convenience methods directly. * Document runtime graph construction, format-dependent unions, graph-local identifiers, built-in coverage, and custom representation boundaries. * Replace broad debug snapshots with focused behavior assertions and remove the snapshot-testing dependency. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c4a2b2..adc11d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,7 @@ Repository tasks are defined by `cargo x`: cargo x build --locked cargo x test cargo x lint +cargo x package --locked ``` Run `cargo x --help` or `cargo x --help` for command-specific options. `cargo x lint --fix` applies supported formatting and lint fixes. diff --git a/README.md b/README.md index 1fc057d..53798aa 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,8 @@ struct Config { Each function receives the current graph context and returns a `ShapeRef`. It may delegate to another type's shape implementation or construct a custom shape directly. A custom shape function is an assertion about the Serde behavior; `serde-shape` cannot verify that the declared shape matches the serializer or deserializer implementation. +The same `serde_shape` hooks can be placed on enum variants whose content is controlled by a variant-level Serde custom function. A variant hook describes the content inside the enum's tagging representation; the enum's `repr` still describes the tag. Without an explicit hook, custom variant content remains opaque. + For a generic custom hook, container-level `#[serde_shape(bound(serialize = "...", deserialize = "..."))]` replaces the automatically inferred bounds in the corresponding direction, following Serde's bound-override convention. ## Model boundaries @@ -156,12 +158,16 @@ The built-in implementations follow Serde's own data-model calls in each directi | --- | --- | | Scalars | Rust primitives, `String`, `str`, non-zero integers, and atomics available on the target | | Containers | `Option`, `Result`, arrays, slices for serialization, tuples through arity 16, `Vec`, `VecDeque`, `LinkedList`, `BinaryHeap`, `BTreeSet`, and `BTreeMap` | -| Wrappers | References, `Box`, `Cow`, `Cell`, `RefCell`, `Wrapping`, `Reverse`, and `PhantomData` | -| Time | `core::time::Duration` | +| Wrappers | References, `Box`, `Rc`, `Arc`, their weak pointers, `Cow`, `Cell`, `RefCell`, `Wrapping`, `Saturating`, `Reverse`, and `PhantomData` | +| FFI | `CStr` and `CString` byte representations, including owned `Box` input | +| Ranges | `Range`, `RangeFrom`, `RangeInclusive`, `RangeTo`, and `Bound` | +| Time | `core::time::Duration` and, with `std`, `SystemTime` | | Network | `core::net` IP and socket address types | | `std` feature | `HashMap`, `HashSet`, `Path`, `PathBuf`, `Mutex`, and `RwLock` | -Network address shapes are unions of their human-readable string representation and their compact Serde representation. A serialized byte slice is a sequence, while borrowed byte deserialization uses `ShapeRef::Bytes`. +Network address shapes are unions of their human-readable string representation and their compact Serde representation. A serialized byte slice and an owned `Box<[u8]>` input are sequences, while borrowed byte deserialization uses `ShapeRef::Bytes`. + +Serde's `rc` feature is still required to serialize or deserialize `Rc`, `Arc`, and their weak pointers; the shape implementations do not enable Serde features. For an unsupported foreign type, use a local newtype and implement `SerializeShape` or `DeserializeShape` manually. Custom Serde functions remain opaque by default because their wire behavior cannot be inferred; use a `serde_shape` custom hook when the representation is known. diff --git a/serde-shape-derive/src/lib.rs b/serde-shape-derive/src/lib.rs index f66bb27..8ac243e 100644 --- a/serde-shape-derive/src/lib.rs +++ b/serde-shape-derive/src/lib.rs @@ -149,13 +149,7 @@ fn validate_shape_attrs(container: &ast::Container<'_>) -> syn::Result<()> { match &container.data { ast::Data::Enum(variants) => { for variant in variants { - let attrs = ShapeAttrs::parse(&variant.original.attrs)?; - if !attrs.is_empty() { - return Err(syn::Error::new_spanned( - variant.original, - "serde_shape attributes are not supported on variants", - )); - } + validate_variant_shape_attrs(variant)?; for field in &variant.fields { validate_field_shape_attrs(field)?; } @@ -170,6 +164,17 @@ fn validate_shape_attrs(container: &ast::Container<'_>) -> syn::Result<()> { Ok(()) } +fn validate_variant_shape_attrs(variant: &ast::Variant<'_>) -> syn::Result<()> { + let attrs = ShapeAttrs::parse(&variant.original.attrs)?; + if attrs.has_bound() { + return Err(syn::Error::new_spanned( + variant.original, + "serde_shape bounds are supported on containers, not variants", + )); + } + Ok(()) +} + fn validate_field_shape_attrs(field: &ast::Field<'_>) -> syn::Result<()> { let attrs = ShapeAttrs::parse(&field.original.attrs)?; if attrs.has_bound() { @@ -221,7 +226,11 @@ fn add_serialize_shape_bounds( } ast::Data::Enum(variants) => { for variant in variants { - if variant.attrs.skip_serializing() || variant.attrs.serialize_with().is_some() { + let variant_shape_attrs = ShapeAttrs::parse(&variant.original.attrs)?; + if variant.attrs.skip_serializing() + || variant.attrs.serialize_with().is_some() + || variant_shape_attrs.serialize_with().is_some() + { continue; } collect_serialize_field_bound_types( @@ -286,7 +295,10 @@ fn add_deserialize_shape_bounds( } ast::Data::Enum(variants) => { for variant in variants { - if variant.attrs.skip_deserializing() || variant.attrs.deserialize_with().is_some() + let variant_shape_attrs = ShapeAttrs::parse(&variant.original.attrs)?; + if variant.attrs.skip_deserializing() + || variant.attrs.deserialize_with().is_some() + || variant_shape_attrs.deserialize_with().is_some() { continue; } @@ -699,6 +711,7 @@ fn deserialize_container_attributes(attrs: &attr::Container) -> TokenStream2 { } fn serialize_variant_shape(variant: &ast::Variant<'_>) -> syn::Result { + let shape_attrs = ShapeAttrs::parse(&variant.original.attrs)?; let rust_name = lit(variant.ident.to_string()); let name = lit(variant.attrs.name().serialize_name()); let description = description(&variant.original.attrs); @@ -708,6 +721,8 @@ fn serialize_variant_shape(variant: &ast::Variant<'_>) -> syn::Result) -> syn::Result) -> syn::Result { + let shape_attrs = ShapeAttrs::parse(&variant.original.attrs)?; let rust_name = lit(variant.ident.to_string()); let name = lit(variant.attrs.name().deserialize_name()); let aliases = aliases(variant.attrs.aliases()); @@ -754,6 +770,8 @@ fn deserialize_variant_shape(variant: &ast::Variant<'_>) -> syn::Result bool { self.serialize_bound.is_some() || self.deserialize_bound.is_some() } - - pub fn is_empty(&self) -> bool { - self.serialize_with.is_none() && self.deserialize_with.is_none() && !self.has_bound() - } } pub fn description(attrs: &[Attribute]) -> Option { diff --git a/serde-shape/src/impls/bound.rs b/serde-shape/src/impls/bound.rs new file mode 100644 index 0000000..af56391 --- /dev/null +++ b/serde-shape/src/impls/bound.rs @@ -0,0 +1,157 @@ +// Copyright 2026 FastLabs Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use alloc::vec; +use core::any::type_name; +use core::ops::Bound; + +use crate::DefaultShape; +use crate::DeserializeContainerAttributes; +use crate::DeserializeDefinitionKind; +use crate::DeserializeEnumShape; +use crate::DeserializeFieldShape; +use crate::DeserializeShape; +use crate::DeserializeShapeContext; +use crate::DeserializeTypeName; +use crate::DeserializeVariantContent; +use crate::DeserializeVariantShape; +use crate::FieldMember; +use crate::FieldWireShape; +use crate::FieldsStyle; +use crate::SerializeContainerAttributes; +use crate::SerializeDefinitionKind; +use crate::SerializeEnumShape; +use crate::SerializeFieldShape; +use crate::SerializeShape; +use crate::SerializeShapeContext; +use crate::SerializeTypeName; +use crate::SerializeVariantContent; +use crate::SerializeVariantShape; +use crate::ShapeRef; +use crate::Tagging; + +impl SerializeShape for Bound +where + T: SerializeShape, +{ + fn serialize_shape_in(context: &mut SerializeShapeContext) -> ShapeRef { + context.define_named_type( + SerializeTypeName { + rust_name: type_name::(), + name: "Bound", + }, + |context| { + SerializeDefinitionKind::Enum(SerializeEnumShape { + repr: Tagging::External, + variants: vec![ + serialize_bound_variant("Unbounded", None), + serialize_bound_variant("Included", Some(T::serialize_shape_in(context))), + serialize_bound_variant("Excluded", Some(T::serialize_shape_in(context))), + ], + attributes: SerializeContainerAttributes::default(), + }) + }, + ) + } +} + +impl DeserializeShape for Bound +where + T: DeserializeShape, +{ + fn deserialize_shape_in(context: &mut DeserializeShapeContext) -> ShapeRef { + context.define_named_type( + DeserializeTypeName { + rust_name: type_name::(), + name: "Bound", + }, + |context| { + DeserializeDefinitionKind::Enum(DeserializeEnumShape { + repr: Tagging::External, + variants: vec![ + deserialize_bound_variant("Unbounded", None), + deserialize_bound_variant( + "Included", + Some(T::deserialize_shape_in(context)), + ), + deserialize_bound_variant( + "Excluded", + Some(T::deserialize_shape_in(context)), + ), + ], + attributes: DeserializeContainerAttributes::default(), + }) + }, + ) + } +} + +fn serialize_bound_variant( + name: &'static str, + value_shape: Option, +) -> SerializeVariantShape { + let (style, fields) = match value_shape { + Some(value_shape) => ( + FieldsStyle::Newtype, + vec![SerializeFieldShape { + member: FieldMember::Unnamed(0), + name: "0", + description: None, + wire_shape: FieldWireShape::Value(value_shape), + skip_if: None, + }], + ), + None => (FieldsStyle::Unit, vec![]), + }; + + SerializeVariantShape { + rust_name: name, + name, + description: None, + style, + content: SerializeVariantContent::Fields(fields), + untagged: false, + } +} + +fn deserialize_bound_variant( + name: &'static str, + value_shape: Option, +) -> DeserializeVariantShape { + let (style, fields) = match value_shape { + Some(value_shape) => ( + FieldsStyle::Newtype, + vec![DeserializeFieldShape { + member: FieldMember::Unnamed(0), + name: "0", + aliases: vec!["0"], + description: None, + wire_shape: FieldWireShape::Value(value_shape), + default: DefaultShape::None, + }], + ), + None => (FieldsStyle::Unit, vec![]), + }; + + DeserializeVariantShape { + rust_name: name, + name, + aliases: vec![name], + description: None, + style, + content: DeserializeVariantContent::Fields(fields), + other: false, + untagged: false, + } +} diff --git a/serde-shape/src/impls/container.rs b/serde-shape/src/impls/container.rs index f3e4921..f8869f9 100644 --- a/serde-shape/src/impls/container.rs +++ b/serde-shape/src/impls/container.rs @@ -97,7 +97,7 @@ seq_shape! { (T) BinaryHeap where - serialize { T: Ord + SerializeShape } + serialize { T: SerializeShape } deserialize { T: Ord + DeserializeShape } => T; diff --git a/serde-shape/src/impls/ffi.rs b/serde-shape/src/impls/ffi.rs new file mode 100644 index 0000000..5eef066 --- /dev/null +++ b/serde-shape/src/impls/ffi.rs @@ -0,0 +1,47 @@ +// Copyright 2026 FastLabs Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use alloc::boxed::Box; +use alloc::ffi::CString; +use core::ffi::CStr; + +use crate::DeserializeShape; +use crate::DeserializeShapeContext; +use crate::SerializeShape; +use crate::SerializeShapeContext; +use crate::ShapeRef; + +impl SerializeShape for CStr { + fn serialize_shape_in(_context: &mut SerializeShapeContext) -> ShapeRef { + ShapeRef::Bytes + } +} + +impl SerializeShape for CString { + fn serialize_shape_in(_context: &mut SerializeShapeContext) -> ShapeRef { + ShapeRef::Bytes + } +} + +impl DeserializeShape for CString { + fn deserialize_shape_in(_context: &mut DeserializeShapeContext) -> ShapeRef { + ShapeRef::Bytes + } +} + +impl DeserializeShape for Box { + fn deserialize_shape_in(_context: &mut DeserializeShapeContext) -> ShapeRef { + ShapeRef::Bytes + } +} diff --git a/serde-shape/src/impls/mod.rs b/serde-shape/src/impls/mod.rs index 3ef13e9..45ac420 100644 --- a/serde-shape/src/impls/mod.rs +++ b/serde-shape/src/impls/mod.rs @@ -12,9 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +mod bound; mod container; +mod ffi; mod net; mod primitive; +mod range; mod result; mod time; mod tuple; diff --git a/serde-shape/src/impls/net.rs b/serde-shape/src/impls/net.rs index d2cb7f9..4c4415e 100644 --- a/serde-shape/src/impls/net.rs +++ b/serde-shape/src/impls/net.rs @@ -216,22 +216,9 @@ fn deserialize_newtype_variant(name: &'static str, shape: ShapeRef) -> Deseriali } fn serialize_enum_attributes() -> SerializeContainerAttributes { - SerializeContainerAttributes { - tagging: Tagging::External, - has_flatten: false, - transparent: false, - non_exhaustive: false, - } + SerializeContainerAttributes::default() } fn deserialize_enum_attributes() -> DeserializeContainerAttributes { - DeserializeContainerAttributes { - tagging: Tagging::External, - deny_unknown_fields: false, - default: DefaultShape::None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - } + DeserializeContainerAttributes::default() } diff --git a/serde-shape/src/impls/range.rs b/serde-shape/src/impls/range.rs new file mode 100644 index 0000000..93ed9f2 --- /dev/null +++ b/serde-shape/src/impls/range.rs @@ -0,0 +1,119 @@ +// Copyright 2026 FastLabs Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use alloc::vec; +use core::any::type_name; +use core::ops::Range; +use core::ops::RangeFrom; +use core::ops::RangeInclusive; +use core::ops::RangeTo; + +use crate::DefaultShape; +use crate::DeserializeContainerAttributes; +use crate::DeserializeDefinitionKind; +use crate::DeserializeFieldShape; +use crate::DeserializeShape; +use crate::DeserializeShapeContext; +use crate::DeserializeStructShape; +use crate::DeserializeTypeName; +use crate::FieldMember; +use crate::FieldWireShape; +use crate::FieldsStyle; +use crate::SerializeContainerAttributes; +use crate::SerializeDefinitionKind; +use crate::SerializeFieldShape; +use crate::SerializeShape; +use crate::SerializeShapeContext; +use crate::SerializeStructShape; +use crate::SerializeTypeName; +use crate::ShapeRef; + +macro_rules! range_shape { + ($($range:ident { $($field:ident),+ $(,)? })+) => { + $( + impl SerializeShape for $range + where + T: SerializeShape, + { + fn serialize_shape_in(context: &mut SerializeShapeContext) -> ShapeRef { + context.define_named_type( + SerializeTypeName { + rust_name: type_name::(), + name: stringify!($range), + }, + |context| { + SerializeDefinitionKind::Struct(SerializeStructShape { + style: FieldsStyle::Struct, + fields: vec![ + $(SerializeFieldShape { + member: FieldMember::Named(stringify!($field)), + name: stringify!($field), + description: None, + wire_shape: FieldWireShape::Value( + T::serialize_shape_in(context), + ), + skip_if: None, + }),+ + ], + attributes: SerializeContainerAttributes::default(), + }) + }, + ) + } + } + + impl DeserializeShape for $range + where + T: DeserializeShape, + { + fn deserialize_shape_in(context: &mut DeserializeShapeContext) -> ShapeRef { + context.define_named_type( + DeserializeTypeName { + rust_name: type_name::(), + name: stringify!($range), + }, + |context| { + DeserializeDefinitionKind::Struct(DeserializeStructShape { + style: FieldsStyle::Struct, + fields: vec![ + $(DeserializeFieldShape { + member: FieldMember::Named(stringify!($field)), + name: stringify!($field), + aliases: vec![stringify!($field)], + description: None, + wire_shape: FieldWireShape::Value( + T::deserialize_shape_in(context), + ), + default: DefaultShape::None, + }),+ + ], + attributes: DeserializeContainerAttributes { + deny_unknown_fields: true, + ..DeserializeContainerAttributes::default() + }, + }) + }, + ) + } + } + )+ + }; +} + +range_shape! { + Range { start, end } + RangeFrom { start } + RangeInclusive { start, end } + RangeTo { end } +} diff --git a/serde-shape/src/impls/result.rs b/serde-shape/src/impls/result.rs index 8499934..b0b9768 100644 --- a/serde-shape/src/impls/result.rs +++ b/serde-shape/src/impls/result.rs @@ -58,12 +58,7 @@ where serialize_result_variant("Ok", T::serialize_shape_in(context)), serialize_result_variant("Err", E::serialize_shape_in(context)), ], - attributes: SerializeContainerAttributes { - tagging: Tagging::External, - has_flatten: false, - transparent: false, - non_exhaustive: false, - }, + attributes: SerializeContainerAttributes::default(), }) }, ) @@ -88,15 +83,7 @@ where deserialize_result_variant("Ok", T::deserialize_shape_in(context)), deserialize_result_variant("Err", E::deserialize_shape_in(context)), ], - attributes: DeserializeContainerAttributes { - tagging: Tagging::External, - deny_unknown_fields: false, - default: DefaultShape::None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, + attributes: DeserializeContainerAttributes::default(), }) }, ) diff --git a/serde-shape/src/impls/time.rs b/serde-shape/src/impls/time.rs index aafc639..c05cf44 100644 --- a/serde-shape/src/impls/time.rs +++ b/serde-shape/src/impls/time.rs @@ -13,8 +13,11 @@ // limitations under the License. use alloc::vec; +use alloc::vec::Vec; use core::any::type_name; use core::time::Duration; +#[cfg(feature = "std")] +use std::time::SystemTime; use crate::DefaultShape; use crate::DeserializeContainerAttributes; @@ -35,85 +38,93 @@ use crate::SerializeShapeContext; use crate::SerializeStructShape; use crate::SerializeTypeName; use crate::ShapeRef; -use crate::Tagging; -impl SerializeShape for Duration { - fn serialize_shape_in(context: &mut SerializeShapeContext) -> ShapeRef { - context.define_named_type( - SerializeTypeName { - rust_name: type_name::(), - name: "Duration", - }, - |_| { - SerializeDefinitionKind::Struct(SerializeStructShape { - style: FieldsStyle::Struct, - fields: vec![ - SerializeFieldShape { - member: FieldMember::Named("secs"), - name: "secs", - description: None, - wire_shape: FieldWireShape::Value(ShapeRef::U64), - skip_if: None, - }, - SerializeFieldShape { - member: FieldMember::Named("nanos"), - name: "nanos", - description: None, - wire_shape: FieldWireShape::Value(ShapeRef::U32), - skip_if: None, - }, - ], - attributes: SerializeContainerAttributes { - tagging: Tagging::External, - has_flatten: false, - transparent: false, - non_exhaustive: false, - }, - }) - }, - ) - } +macro_rules! time_shape { + ($ty:ty, $name:literal, $($field:literal => $shape:expr),+ $(,)?) => { + impl SerializeShape for $ty { + fn serialize_shape_in(context: &mut SerializeShapeContext) -> ShapeRef { + serialize_time_shape( + context, + type_name::(), + $name, + [$(($field, $shape)),+], + ) + } + } + + impl DeserializeShape for $ty { + fn deserialize_shape_in(context: &mut DeserializeShapeContext) -> ShapeRef { + deserialize_time_shape( + context, + type_name::(), + $name, + [$(($field, $shape)),+], + ) + } + } + }; } -impl DeserializeShape for Duration { - fn deserialize_shape_in(context: &mut DeserializeShapeContext) -> ShapeRef { - context.define_named_type( - DeserializeTypeName { - rust_name: type_name::(), - name: "Duration", - }, - |_| { - DeserializeDefinitionKind::Struct(DeserializeStructShape { - style: FieldsStyle::Struct, - fields: vec![ - DeserializeFieldShape { - member: FieldMember::Named("secs"), - name: "secs", - aliases: vec!["secs"], - description: None, - wire_shape: FieldWireShape::Value(ShapeRef::U64), - default: DefaultShape::None, - }, - DeserializeFieldShape { - member: FieldMember::Named("nanos"), - name: "nanos", - aliases: vec!["nanos"], - description: None, - wire_shape: FieldWireShape::Value(ShapeRef::U32), - default: DefaultShape::None, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: Tagging::External, - deny_unknown_fields: true, - default: DefaultShape::None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }) +time_shape!(Duration, "Duration", "secs" => ShapeRef::U64, "nanos" => ShapeRef::U32); + +#[cfg(feature = "std")] +time_shape!( + SystemTime, + "SystemTime", + "secs_since_epoch" => ShapeRef::U64, + "nanos_since_epoch" => ShapeRef::U32, +); + +fn serialize_time_shape( + context: &mut SerializeShapeContext, + rust_name: &'static str, + name: &'static str, + fields: [(&'static str, ShapeRef); N], +) -> ShapeRef { + let fields: Vec<_> = fields + .into_iter() + .map(|(name, shape)| SerializeFieldShape { + member: FieldMember::Named(name), + name, + description: None, + wire_shape: FieldWireShape::Value(shape), + skip_if: None, + }) + .collect(); + context.define_named_type(SerializeTypeName { rust_name, name }, move |_| { + SerializeDefinitionKind::Struct(SerializeStructShape { + style: FieldsStyle::Struct, + fields, + attributes: SerializeContainerAttributes::default(), + }) + }) +} + +fn deserialize_time_shape( + context: &mut DeserializeShapeContext, + rust_name: &'static str, + name: &'static str, + fields: [(&'static str, ShapeRef); N], +) -> ShapeRef { + let fields: Vec<_> = fields + .into_iter() + .map(|(name, shape)| DeserializeFieldShape { + member: FieldMember::Named(name), + name, + aliases: vec![name], + description: None, + wire_shape: FieldWireShape::Value(shape), + default: DefaultShape::None, + }) + .collect(); + context.define_named_type(DeserializeTypeName { rust_name, name }, move |_| { + DeserializeDefinitionKind::Struct(DeserializeStructShape { + style: FieldsStyle::Struct, + fields, + attributes: DeserializeContainerAttributes { + deny_unknown_fields: true, + ..DeserializeContainerAttributes::default() }, - ) - } + }) + }) } diff --git a/serde-shape/src/impls/wrapper.rs b/serde-shape/src/impls/wrapper.rs index 74a24c5..d56672e 100644 --- a/serde-shape/src/impls/wrapper.rs +++ b/serde-shape/src/impls/wrapper.rs @@ -15,10 +15,17 @@ use alloc::borrow::Cow; use alloc::borrow::ToOwned; use alloc::boxed::Box; +use alloc::rc::Rc; +use alloc::rc::Weak as RcWeak; +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Weak as ArcWeak; use core::cell::Cell; use core::cell::RefCell; use core::cmp::Reverse; use core::marker::PhantomData; +use core::num::Saturating; use core::num::Wrapping; use crate::DeserializeShape; @@ -75,7 +82,7 @@ transparent_shape! { (T) Box where serialize { T: SerializeShape + ?Sized } - deserialize { T: DeserializeShape + ?Sized } + deserialize { T: DeserializeShape } => T; (T) Cell @@ -86,7 +93,7 @@ transparent_shape! { (T) RefCell where - serialize { T: SerializeShape } + serialize { T: SerializeShape + ?Sized } deserialize { T: DeserializeShape } => T; @@ -103,17 +110,110 @@ transparent_shape! { => T; } +impl SerializeShape for Saturating +where + T: SerializeShape, +{ + fn serialize_shape_in(context: &mut SerializeShapeContext) -> ShapeRef { + T::serialize_shape_in(context) + } +} + +macro_rules! saturating_deserialize_shape { + ($($ty:ty),+ $(,)?) => { + $( + impl DeserializeShape for Saturating<$ty> { + fn deserialize_shape_in(context: &mut DeserializeShapeContext) -> ShapeRef { + <$ty as DeserializeShape>::deserialize_shape_in(context) + } + } + )+ + }; +} + +saturating_deserialize_shape!( + i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, +); + +impl DeserializeShape for Box<[T]> +where + T: DeserializeShape, +{ + fn deserialize_shape_in(context: &mut DeserializeShapeContext) -> ShapeRef { + ShapeRef::Seq(Box::new(T::deserialize_shape_in(context))) + } +} + +impl DeserializeShape for Box { + fn deserialize_shape_in(_context: &mut DeserializeShapeContext) -> ShapeRef { + ShapeRef::String + } +} + +macro_rules! shared_pointer_shape { + ($strong:ident, $weak:ident) => { + impl SerializeShape for $strong + where + T: SerializeShape + ?Sized, + { + fn serialize_shape_in(context: &mut SerializeShapeContext) -> ShapeRef { + T::serialize_shape_in(context) + } + } + + impl DeserializeShape for $strong + where + T: ?Sized, + Box: DeserializeShape, + { + fn deserialize_shape_in(context: &mut DeserializeShapeContext) -> ShapeRef { + as DeserializeShape>::deserialize_shape_in(context) + } + } + + impl SerializeShape for $weak + where + T: SerializeShape + ?Sized, + { + fn serialize_shape_in(context: &mut SerializeShapeContext) -> ShapeRef { + ShapeRef::Option(Box::new(T::serialize_shape_in(context))) + } + } + + impl DeserializeShape for $weak + where + T: DeserializeShape, + { + fn deserialize_shape_in(context: &mut DeserializeShapeContext) -> ShapeRef { + ShapeRef::Option(Box::new(T::deserialize_shape_in(context))) + } + } + }; +} + +shared_pointer_shape!(Rc, RcWeak); + +#[cfg(target_has_atomic = "ptr")] +shared_pointer_shape!(Arc, ArcWeak); + +#[cfg(feature = "std")] +impl DeserializeShape for Box { + fn deserialize_shape_in(_context: &mut DeserializeShapeContext) -> ShapeRef { + ShapeRef::String + } +} + #[cfg(feature = "std")] transparent_shape! { (T) std::sync::Mutex where - serialize { T: SerializeShape } + serialize { T: SerializeShape + ?Sized } deserialize { T: DeserializeShape } => T; (T) std::sync::RwLock where - serialize { T: SerializeShape } + serialize { T: SerializeShape + ?Sized } deserialize { T: DeserializeShape } => T; } diff --git a/serde-shape/src/lib.rs b/serde-shape/src/lib.rs index ab9f5ce..11caa55 100644 --- a/serde-shape/src/lib.rs +++ b/serde-shape/src/lib.rs @@ -152,9 +152,9 @@ //! [`ShapeRef::Opaque`] and remain composable with those field positions. //! //! Use `#[serde_shape(serialize_with = "path")]` or -//! `#[serde_shape(deserialize_with = "path")]` to declare the representation of a container or -//! field that cannot be inferred. Each function receives the current graph context and returns a -//! [`ShapeRef`], so it can delegate to another type or build a custom shape directly. +//! `#[serde_shape(deserialize_with = "path")]` to declare the representation of a container, +//! variant, or field that cannot be inferred. Each function receives the current graph context and +//! returns a [`ShapeRef`], so it can delegate to another type or build a custom shape directly. //! //! Rust doc comments on derived containers, variants, and fields are preserved in their //! `description` fields for documentation and diagnostic consumers. @@ -218,10 +218,10 @@ pub mod __private { /// metadata that Serde uses for deserialization. The generated implementation records the /// deserialization-side names, shape graph, and Serde field/container metadata. /// -/// Use `#[serde_shape(deserialize_with = "path")]` on a container or field to override an -/// opaque or foreign representation. The function must accept `&mut DeserializeShapeContext` -/// and return a [`ShapeRef`]. Generic hooks can replace inferred bounds with -/// `#[serde_shape(bound(deserialize = "T: DeserializeShape"))]` on the container. +/// Use `#[serde_shape(deserialize_with = "path")]` on a container, variant, or field to +/// override an opaque or foreign representation. The function must accept `&mut +/// DeserializeShapeContext` and return a [`ShapeRef`]. Generic hooks can replace inferred +/// bounds with `#[serde_shape(bound(deserialize = "T: DeserializeShape"))]` on the container. /// /// # Example /// @@ -258,9 +258,9 @@ pub use serde_shape_derive::DeserializeShape; /// metadata that Serde uses for serialization. The generated implementation records the /// serialization-side names, shape graph, and Serde field/container metadata. /// -/// Use `#[serde_shape(serialize_with = "path")]` on a container or field to override an opaque -/// or foreign representation. The function must accept `&mut SerializeShapeContext` and return -/// a [`ShapeRef`]. Generic hooks can replace inferred bounds with +/// Use `#[serde_shape(serialize_with = "path")]` on a container, variant, or field to override +/// an opaque or foreign representation. The function must accept `&mut SerializeShapeContext` +/// and return a [`ShapeRef`]. Generic hooks can replace inferred bounds with /// `#[serde_shape(bound(serialize = "T: SerializeShape"))]` on the container. /// /// # Example @@ -598,7 +598,7 @@ pub enum ShapeRef { F64, /// UTF-8 string shape. String, - /// Byte buffer shape. + /// Serde byte-buffer data-model shape. Bytes, /// Optional value shape. Option(Box), @@ -790,7 +790,10 @@ pub enum DeserializeDefinitionKind { } /// Serde attributes that apply to a whole serialized container. -#[derive(Clone, Debug, Eq, PartialEq)] +/// +/// [`Default`] represents an ordinary externally tagged container with every optional behavior +/// disabled. +#[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct SerializeContainerAttributes { /// The container tagging representation. pub tagging: Tagging, @@ -803,7 +806,10 @@ pub struct SerializeContainerAttributes { } /// Serde attributes that apply to a whole deserialized container. -#[derive(Clone, Debug, Eq, PartialEq)] +/// +/// [`Default`] represents an ordinary externally tagged container with no default, expectation, or +/// optional behavior configured. +#[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct DeserializeContainerAttributes { /// The container tagging representation. pub tagging: Tagging, @@ -822,9 +828,12 @@ pub struct DeserializeContainerAttributes { } /// Serde container or enum tagging representation. -#[derive(Clone, Debug, Eq, PartialEq)] +/// +/// [`Default`] is [`Tagging::External`]. +#[derive(Clone, Debug, Default, Eq, PartialEq)] pub enum Tagging { /// The default externally tagged representation. + #[default] External, /// `#[serde(tag = "...")]`. Internal { @@ -979,6 +988,8 @@ pub enum SerializeVariantContent { Omitted, /// Serde derives the variant content from these fields. Fields(Vec), + /// A `serde_shape` hook supplies the content shape inside the enum's tagging representation. + Shape(ShapeRef), /// A custom serializer controls the variant content. Custom(OpaqueShape), } @@ -1012,14 +1023,19 @@ pub enum DeserializeVariantContent { Omitted, /// Serde derives the variant content from these fields. Fields(Vec), + /// A `serde_shape` hook supplies the content shape inside the enum's tagging representation. + Shape(ShapeRef), /// A custom deserializer controls the variant content. Custom(OpaqueShape), } /// A Serde default marker. -#[derive(Clone, Debug, Eq, PartialEq)] +/// +/// [`Default`] is [`DefaultShape::None`]. +#[derive(Clone, Debug, Default, Eq, PartialEq)] pub enum DefaultShape { /// No default is configured. + #[default] None, /// `Default::default()` is used. Default, diff --git a/serde-shape/src/tests.rs b/serde-shape/src/tests.rs index 6a070fb..39aaef2 100644 --- a/serde-shape/src/tests.rs +++ b/serde-shape/src/tests.rs @@ -19,13 +19,26 @@ use alloc::collections::BTreeMap; use alloc::collections::BinaryHeap; use alloc::collections::LinkedList; use alloc::collections::VecDeque; +use alloc::ffi::CString; +use alloc::rc::Rc; +use alloc::rc::Weak as RcWeak; use alloc::string::String; +#[cfg(target_has_atomic = "ptr")] +use alloc::sync::Arc; use alloc::vec; use alloc::vec::Vec; use core::borrow::Borrow; use core::cell::Cell; +use core::cell::RefCell; use core::cmp::Reverse; +use core::ffi::CStr; +use core::num::Saturating; use core::num::Wrapping; +use core::ops::Bound; +use core::ops::Range; +use core::ops::RangeFrom; +use core::ops::RangeInclusive; +use core::ops::RangeTo; use crate::DeserializeDefinitionKind; use crate::DeserializeShape; @@ -226,6 +239,10 @@ fn distinguishes_byte_sequences_from_borrowed_byte_input() { <[u8] as DeserializeShape>::deserialize_shape().root(), &ShapeRef::Bytes ); + assert_eq!( + as DeserializeShape>::deserialize_shape().root(), + &ShapeRef::Seq(Box::new(ShapeRef::U8)) + ); } #[test] @@ -288,6 +305,74 @@ fn maps_duration_as_serde_struct_fields() { ); } +#[test] +fn maps_range_struct_shapes() { + assert_range_shapes::>("Range", &["start", "end"]); + assert_range_shapes::>("RangeFrom", &["start"]); + assert_range_shapes::>("RangeInclusive", &["start", "end"]); + assert_range_shapes::>("RangeTo", &["end"]); +} + +#[test] +fn maps_bound_as_an_externally_tagged_enum() { + let serialize = SerializeShapeGraph::for_type::>(); + let SerializeDefinitionKind::Enum(shape) = &serialize.root_definition().unwrap().kind else { + panic!("bound serialization shape should be an enum"); + }; + assert_eq!(shape.repr, Tagging::External); + assert_eq!(shape.variants[0].name, "Unbounded"); + assert_eq!(shape.variants[0].style, FieldsStyle::Unit); + assert_eq!(shape.variants[1].name, "Included"); + assert_eq!(shape.variants[1].style, FieldsStyle::Newtype); + + let deserialize = DeserializeShapeGraph::for_type::>(); + let DeserializeDefinitionKind::Enum(shape) = &deserialize.root_definition().unwrap().kind + else { + panic!("bound deserialization shape should be an enum"); + }; + assert_eq!(shape.variants[2].name, "Excluded"); + let crate::DeserializeVariantContent::Fields(fields) = &shape.variants[2].content else { + panic!("Excluded should contain one reflected field"); + }; + assert_eq!(fields[0].wire_shape, FieldWireShape::Value(ShapeRef::U8)); +} + +fn assert_range_shapes(type_name: &str, field_names: &[&str]) +where + T: SerializeShape + DeserializeShape, +{ + let serialize = T::serialize_shape(); + let serialize_definition = serialize.root_definition().unwrap(); + assert_eq!(serialize_definition.type_name.name, type_name); + let SerializeDefinitionKind::Struct(shape) = &serialize_definition.kind else { + panic!("range serialization shape should be a struct"); + }; + assert_eq!( + shape + .fields + .iter() + .map(|field| field.name) + .collect::>(), + field_names + ); + + let deserialize = T::deserialize_shape(); + let deserialize_definition = deserialize.root_definition().unwrap(); + assert_eq!(deserialize_definition.type_name.name, type_name); + let DeserializeDefinitionKind::Struct(shape) = &deserialize_definition.kind else { + panic!("range deserialization shape should be a struct"); + }; + assert!(shape.attributes.deny_unknown_fields); + assert_eq!( + shape + .fields + .iter() + .map(|field| field.name) + .collect::>(), + field_names + ); +} + #[test] fn supports_serde_tuple_arity() { type Tuple16 = ( @@ -330,6 +415,14 @@ fn maps_common_core_and_alloc_shapes() { DeserializeShapeGraph::for_type::>().root(), &ShapeRef::I16 ); + assert_eq!( + SerializeShapeGraph::for_type::>().root(), + &ShapeRef::String + ); + assert_eq!( + DeserializeShapeGraph::for_type::>().root(), + &ShapeRef::U16 + ); assert_eq!( SerializeShapeGraph::for_type::>().root(), &ShapeRef::U32 @@ -346,6 +439,51 @@ fn maps_common_core_and_alloc_shapes() { DeserializeShapeGraph::for_type::>().root(), &ShapeRef::Seq(Box::new(ShapeRef::U16)) ); + assert_eq!( + SerializeShapeGraph::for_type::().root(), + &ShapeRef::Bytes + ); + assert_eq!( + DeserializeShapeGraph::for_type::().root(), + &ShapeRef::Bytes + ); + assert_eq!( + as DeserializeShape>::deserialize_shape().root(), + &ShapeRef::Bytes + ); +} + +#[test] +fn follows_shared_pointer_serde_shapes() { + assert_eq!( + as SerializeShape>::serialize_shape().root(), + &ShapeRef::String + ); + assert_eq!( + as DeserializeShape>::deserialize_shape().root(), + &ShapeRef::Seq(Box::new(ShapeRef::U8)) + ); + assert_eq!( + SerializeShapeGraph::for_type::>().root(), + &ShapeRef::Option(Box::new(ShapeRef::U16)) + ); + #[cfg(target_has_atomic = "ptr")] + assert_eq!( + as DeserializeShape>::deserialize_shape().root(), + &ShapeRef::Seq(Box::new(ShapeRef::U8)) + ); +} + +#[test] +fn accepts_serde_serializable_collection_and_wrapper_bounds() { + assert_eq!( + SerializeShapeGraph::for_type::>().root(), + &ShapeRef::Seq(Box::new(ShapeRef::U8)) + ); + assert_eq!( + as SerializeShape>::serialize_shape().root(), + &ShapeRef::String + ); } #[test] @@ -375,6 +513,27 @@ fn maps_common_std_shapes() { SerializeShapeGraph::for_type::().root(), &ShapeRef::String ); + assert_eq!( + as DeserializeShape>::deserialize_shape().root(), + &ShapeRef::String + ); + assert_eq!( + as SerializeShape>::serialize_shape().root(), + &ShapeRef::String + ); + assert_eq!( + as SerializeShape>::serialize_shape().root(), + &ShapeRef::String + ); + + let system_time = DeserializeShapeGraph::for_type::(); + let DeserializeDefinitionKind::Struct(shape) = &system_time.root_definition().unwrap().kind + else { + panic!("system time shape should be a struct"); + }; + assert_eq!(shape.fields[0].name, "secs_since_epoch"); + assert_eq!(shape.fields[1].name, "nanos_since_epoch"); + assert!(shape.attributes.deny_unknown_fields); } #[test] diff --git a/tests/derive/tests/serde_compat.rs b/tests/derive/tests/serde_compat.rs index ec7d79a..0ed26ff 100644 --- a/tests/derive/tests/serde_compat.rs +++ b/tests/derive/tests/serde_compat.rs @@ -60,6 +60,31 @@ enum CustomVariant { Value(u64), } +#[derive(Debug, PartialEq, Serialize, Deserialize, SerializeShape, DeserializeShape)] +enum DeclaredCustomVariant { + #[serde(with = "flat_value")] + #[serde_shape( + serialize_with = "serialize_flat_value_shape", + deserialize_with = "deserialize_flat_value_shape" + )] + Value(FlatValue), +} + +fn serialize_flat_value_shape(_context: &mut renamed_shape::SerializeShapeContext) -> ShapeRef { + flat_value_shape() +} + +fn deserialize_flat_value_shape(_context: &mut renamed_shape::DeserializeShapeContext) -> ShapeRef { + flat_value_shape() +} + +fn flat_value_shape() -> ShapeRef { + ShapeRef::Map { + key: Box::new(ShapeRef::String), + value: Box::new(ShapeRef::U64), + } +} + #[test] fn composes_flatten_with_custom_field_boundaries() { let value = FlattenedCustom { @@ -157,6 +182,29 @@ fn retains_custom_variant_boundary_details() { assert_eq!(opaque.detail, Some("stringified::deserialize")); } +#[test] +fn declares_known_custom_variant_content() { + let value = DeclaredCustomVariant::Value(FlatValue(19)); + let json = serde_json::to_value(&value).expect("value should serialize"); + assert_eq!(json, serde_json::json!({ "Value": { "custom": 19 } })); + assert_eq!( + serde_json::from_value::(json).expect("value should deserialize"), + value + ); + + let expected = flat_value_shape(); + let serialize_variant = first_serialize_variant::(); + assert_eq!( + serialize_variant.content, + SerializeVariantContent::Shape(expected.clone()) + ); + let deserialize_variant = first_deserialize_variant::(); + assert_eq!( + deserialize_variant.content, + DeserializeVariantContent::Shape(expected) + ); +} + fn first_serialize_field() -> SerializeFieldShape where T: SerializeShape, diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6c93e7c..275e731 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -34,6 +34,7 @@ impl Command { match self.sub { SubCommand::Build(cmd) => cmd.run(), SubCommand::Lint(cmd) => cmd.run(), + SubCommand::Package(cmd) => cmd.run(), SubCommand::Test(cmd) => cmd.run(), } } @@ -45,6 +46,8 @@ enum SubCommand { Build(CommandBuild), #[clap(about = "Run workspace quality checks.")] Lint(CommandLint), + #[clap(about = "Build the publishable crate archives.")] + Package(CommandPackage), #[clap(about = "Run workspace unit tests.")] Test(CommandTest), } @@ -61,6 +64,18 @@ impl CommandBuild { } } +#[derive(Parser)] +struct CommandPackage { + #[arg(long, help = "Assert that `Cargo.lock` will remain unchanged.")] + locked: bool, +} + +impl CommandPackage { + fn run(self) { + run_command(make_package_cmd(self.locked)); + } +} + #[derive(Parser)] struct CommandTest { #[arg(long, help = "Run tests serially and do not capture output.")] @@ -157,6 +172,22 @@ fn make_build_cmd(locked: bool) -> StdCommand { cmd } +fn make_package_cmd(locked: bool) -> StdCommand { + let mut cmd = find_command("cargo"); + cmd.args([ + "package", + "--package", + "serde-shape-derive", + "--package", + "serde-shape", + "--all-features", + ]); + if locked { + cmd.arg("--locked"); + } + cmd +} + fn make_test_cmd(no_capture: bool, package: &str, features: &[&str]) -> StdCommand { let mut cmd = find_command("cargo"); cmd.args(["test", "-p", package, "--no-default-features"]);