From 30f4116f3609f357fd4b1ddb16b0f7ffd62497e7 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 22:43:34 +0800 Subject: [PATCH 1/9] fix: support unsized shape entry points Why: str and slice types already implement the shape traits, and the graph constructors already accept ?Sized types. The convenience methods nevertheless imposed a Sized bound, forcing users of those built-ins onto the lower-level graph API for no semantic reason. Signed-off-by: tison --- CHANGELOG.md | 1 + serde-shape/src/lib.rs | 10 ++-------- serde-shape/src/tests.rs | 4 ++-- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4361419..1e211b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,5 +23,6 @@ All notable changes to this project will be documented in this file. ### Improvements +* 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/serde-shape/src/lib.rs b/serde-shape/src/lib.rs index ec5ae50..4fdcec5 100644 --- a/serde-shape/src/lib.rs +++ b/serde-shape/src/lib.rs @@ -318,10 +318,7 @@ pub trait SerializeShape { fn serialize_shape_in(context: &mut SerializeShapeContext) -> ShapeRef; /// Build a complete serialization shape graph rooted at this type. - fn serialize_shape() -> SerializeShapeGraph - where - Self: Sized, - { + fn serialize_shape() -> SerializeShapeGraph { SerializeShapeGraph::for_type::() } } @@ -332,10 +329,7 @@ pub trait DeserializeShape { fn deserialize_shape_in(context: &mut DeserializeShapeContext) -> ShapeRef; /// Build a complete deserialization shape graph rooted at this type. - fn deserialize_shape() -> DeserializeShapeGraph - where - Self: Sized, - { + fn deserialize_shape() -> DeserializeShapeGraph { DeserializeShapeGraph::for_type::() } } diff --git a/serde-shape/src/tests.rs b/serde-shape/src/tests.rs index 3941839..50b6f3c 100644 --- a/serde-shape/src/tests.rs +++ b/serde-shape/src/tests.rs @@ -213,7 +213,7 @@ fn builds_map_shape() { #[test] fn distinguishes_byte_sequences_from_borrowed_byte_input() { assert_eq!( - SerializeShapeGraph::for_type::<[u8]>().root(), + <[u8] as SerializeShape>::serialize_shape().root(), &ShapeRef::Seq(Box::new(ShapeRef::U8)) ); assert_eq!( @@ -221,7 +221,7 @@ fn distinguishes_byte_sequences_from_borrowed_byte_input() { &ShapeRef::Seq(Box::new(ShapeRef::U8)) ); assert_eq!( - DeserializeShapeGraph::for_type::<[u8]>().root(), + <[u8] as DeserializeShape>::deserialize_shape().root(), &ShapeRef::Bytes ); } From ac03a0e50f6ed2fc271942f6d066e3e1b703b7f8 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 22:45:00 +0800 Subject: [PATCH 2/9] feat: expose root definitions directly Why: inspecting a derived struct or enum required callers to match the root ShapeRef, copy its ShapeId, and perform a separate lookup. That ceremony dominated every introductory example and made the common case feel lower-level than it is. Signed-off-by: tison --- CHANGELOG.md | 1 + README.md | 9 ++---- serde-shape/src/lib.rs | 47 ++++++++++++++------------------ serde-shape/src/tests.rs | 2 ++ tests/derive/tests/common/mod.rs | 11 ++------ 5 files changed, 28 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e211b0..19cb924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file. ### New features * Add `#[serde_shape(serialize_with = "path", deserialize_with = "path")]` hooks for custom Serde functions and foreign representations. +* 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 diff --git a/README.md b/README.md index ff94a8d..b2fecfb 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,7 @@ You may use [`schemars`](https://docs.rs/schemars) for JSON Schema generation an The following example shows how to inspect a nested config type. ```rust -use serde_shape::{ - DeserializeDefinitionKind, DeserializeShape, FieldsStyle, ShapeRef, -}; +use serde_shape::{DeserializeDefinitionKind, DeserializeShape, FieldsStyle}; #[derive(DeserializeShape)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] @@ -81,10 +79,7 @@ struct TlsConfig { } let graph = Config::deserialize_shape(); -let ShapeRef::Definition(config_id) = graph.root() else { - panic!("Config should produce a named definition"); -}; -let definition = graph.definition(*config_id).unwrap(); +let definition = graph.root_definition().unwrap(); let DeserializeDefinitionKind::Struct(shape) = &definition.kind else { panic!("Config should produce a struct shape"); diff --git a/serde-shape/src/lib.rs b/serde-shape/src/lib.rs index 4fdcec5..82719f3 100644 --- a/serde-shape/src/lib.rs +++ b/serde-shape/src/lib.rs @@ -53,7 +53,6 @@ //! use serde_shape::DeserializeDefinitionKind; //! use serde_shape::DeserializeShape; //! use serde_shape::FieldsStyle; -//! use serde_shape::ShapeRef; //! //! #[derive(DeserializeShape)] //! #[serde(rename_all = "kebab-case", deny_unknown_fields)] @@ -71,10 +70,7 @@ //! } //! //! let graph = Config::deserialize_shape(); -//! let ShapeRef::Definition(config_id) = graph.root() else { -//! panic!("Config should produce a named definition"); -//! }; -//! let config = graph.definition(*config_id).unwrap(); +//! let config = graph.root_definition().unwrap(); //! //! let DeserializeDefinitionKind::Struct(shape) = &config.kind else { //! panic!("Config should produce a struct shape"); @@ -99,7 +95,6 @@ //! use serde_shape::DeserializeShape; //! use serde_shape::SerializeDefinitionKind; //! use serde_shape::SerializeShape; -//! use serde_shape::ShapeRef; //! //! #[derive(SerializeShape, DeserializeShape)] //! #[serde(rename(serialize = "wire-output", deserialize = "wire-input"))] @@ -110,16 +105,8 @@ //! //! let serialize_graph = Message::serialize_shape(); //! let deserialize_graph = Message::deserialize_shape(); -//! -//! let ShapeRef::Definition(serialize_id) = serialize_graph.root() else { -//! panic!("Message should produce a named serialization definition"); -//! }; -//! let ShapeRef::Definition(deserialize_id) = deserialize_graph.root() else { -//! panic!("Message should produce a named deserialization definition"); -//! }; -//! -//! let serialize_definition = serialize_graph.definition(*serialize_id).unwrap(); -//! let deserialize_definition = deserialize_graph.definition(*deserialize_id).unwrap(); +//! let serialize_definition = serialize_graph.root_definition().unwrap(); +//! let deserialize_definition = deserialize_graph.root_definition().unwrap(); //! //! assert_eq!(serialize_definition.type_name.name, "wire-output"); //! assert_eq!(deserialize_definition.type_name.name, "wire-input"); @@ -239,7 +226,6 @@ pub mod __private { /// use serde_shape::DefaultShape; /// use serde_shape::DeserializeDefinitionKind; /// use serde_shape::DeserializeShape; -/// use serde_shape::ShapeRef; /// /// #[derive(DeserializeShape)] /// #[serde(rename_all = "kebab-case")] @@ -250,10 +236,7 @@ pub mod __private { /// } /// /// let graph = Config::deserialize_shape(); -/// let ShapeRef::Definition(id) = graph.root() else { -/// panic!("Config should produce a named definition"); -/// }; -/// let definition = graph.definition(*id).unwrap(); +/// let definition = graph.root_definition().unwrap(); /// /// let DeserializeDefinitionKind::Struct(shape) = &definition.kind else { /// panic!("Config should produce a struct shape"); @@ -281,7 +264,6 @@ pub use serde_shape_derive::DeserializeShape; /// ```rust /// use serde_shape::SerializeDefinitionKind; /// use serde_shape::SerializeShape; -/// use serde_shape::ShapeRef; /// /// #[derive(SerializeShape)] /// #[serde(rename = "api-response", rename_all = "camelCase")] @@ -292,10 +274,7 @@ pub use serde_shape_derive::DeserializeShape; /// } /// /// let graph = Response::serialize_shape(); -/// let ShapeRef::Definition(id) = graph.root() else { -/// panic!("Response should produce a named definition"); -/// }; -/// let definition = graph.definition(*id).unwrap(); +/// let definition = graph.root_definition().unwrap(); /// /// let SerializeDefinitionKind::Struct(shape) = &definition.kind else { /// panic!("Response should produce a struct shape"); @@ -362,6 +341,14 @@ impl SerializeShapeGraph { &self.root } + /// Return the root definition when the graph root is a named type. + pub fn root_definition(&self) -> Option<&SerializeDefinitionShape> { + let ShapeRef::Definition(id) = self.root() else { + return None; + }; + self.definition(*id) + } + /// Return the named definitions reachable from the root. pub fn definitions(&self) -> &[SerializeDefinitionShape] { &self.definitions @@ -401,6 +388,14 @@ impl DeserializeShapeGraph { &self.root } + /// Return the root definition when the graph root is a named type. + pub fn root_definition(&self) -> Option<&DeserializeDefinitionShape> { + let ShapeRef::Definition(id) = self.root() else { + return None; + }; + self.definition(*id) + } + /// Return the named definitions reachable from the root. pub fn definitions(&self) -> &[DeserializeDefinitionShape] { &self.definitions diff --git a/serde-shape/src/tests.rs b/serde-shape/src/tests.rs index 50b6f3c..15e6eeb 100644 --- a/serde-shape/src/tests.rs +++ b/serde-shape/src/tests.rs @@ -205,8 +205,10 @@ fn builds_map_shape() { }; assert_eq!(serialize_shape.root(), &expected); + assert!(serialize_shape.root_definition().is_none()); assert!(serialize_shape.definitions().is_empty()); assert_eq!(deserialize_shape.root(), &expected); + assert!(deserialize_shape.root_definition().is_none()); assert!(deserialize_shape.definitions().is_empty()); } diff --git a/tests/derive/tests/common/mod.rs b/tests/derive/tests/common/mod.rs index 3c83f50..d8f18ec 100644 --- a/tests/derive/tests/common/mod.rs +++ b/tests/derive/tests/common/mod.rs @@ -16,18 +16,14 @@ use renamed_shape::DeserializeDefinitionShape; use renamed_shape::DeserializeShape; use renamed_shape::SerializeDefinitionShape; use renamed_shape::SerializeShape; -use renamed_shape::ShapeRef; pub(super) fn deserialize_root_definition() -> DeserializeDefinitionShape where T: DeserializeShape, { let graph = T::deserialize_shape(); - let ShapeRef::Definition(id) = graph.root() else { - panic!("deserialization root shape should be a definition"); - }; graph - .definition(*id) + .root_definition() .expect("deserialization root definition should exist") .clone() } @@ -37,11 +33,8 @@ where T: SerializeShape, { let graph = T::serialize_shape(); - let ShapeRef::Definition(id) = graph.root() else { - panic!("serialization root shape should be a definition"); - }; graph - .definition(*id) + .root_definition() .expect("serialization root definition should exist") .clone() } From 2da93d122e88ff006eae5db3955c07fd987a5531 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 22:46:07 +0800 Subject: [PATCH 3/9] fix: expose network shapes in no_std Why: IP and socket address value types live in core::net on the supported Rust versions, but their shape implementations were needlessly hidden behind the std feature. That prevented no_std configuration models from reflecting common listen and peer addresses. Signed-off-by: tison --- CHANGELOG.md | 1 + README.md | 3 ++- serde-shape/src/impls/mod.rs | 1 - serde-shape/src/impls/net.rs | 12 ++++++------ serde-shape/src/tests.rs | 8 ++++++-- tests/no_std/src/lib.rs | 2 ++ tests/no_std/tests/shapes.rs | 16 ++++++++++++---- 7 files changed, 29 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19cb924..842f161 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ All notable changes to this project will be documented in this file. * 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`. ### Improvements diff --git a/README.md b/README.md index b2fecfb..2776aea 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,8 @@ The built-in implementations follow Serde's own data-model calls in each directi | 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` | -| `std` feature | `HashMap`, `HashSet`, `Path`, `PathBuf`, IP and socket address types, `Mutex`, and `RwLock` | +| 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`. diff --git a/serde-shape/src/impls/mod.rs b/serde-shape/src/impls/mod.rs index 38b544c..3ef13e9 100644 --- a/serde-shape/src/impls/mod.rs +++ b/serde-shape/src/impls/mod.rs @@ -13,7 +13,6 @@ // limitations under the License. mod container; -#[cfg(feature = "std")] mod net; mod primitive; mod result; diff --git a/serde-shape/src/impls/net.rs b/serde-shape/src/impls/net.rs index 9168d32..d2cb7f9 100644 --- a/serde-shape/src/impls/net.rs +++ b/serde-shape/src/impls/net.rs @@ -15,12 +15,12 @@ use alloc::boxed::Box; use alloc::vec; use core::any::type_name; -use std::net::IpAddr; -use std::net::Ipv4Addr; -use std::net::Ipv6Addr; -use std::net::SocketAddr; -use std::net::SocketAddrV4; -use std::net::SocketAddrV6; +use core::net::IpAddr; +use core::net::Ipv4Addr; +use core::net::Ipv6Addr; +use core::net::SocketAddr; +use core::net::SocketAddrV4; +use core::net::SocketAddrV6; use crate::DefaultShape; use crate::DeserializeContainerAttributes; diff --git a/serde-shape/src/tests.rs b/serde-shape/src/tests.rs index 15e6eeb..6a070fb 100644 --- a/serde-shape/src/tests.rs +++ b/serde-shape/src/tests.rs @@ -375,16 +375,20 @@ fn maps_common_std_shapes() { SerializeShapeGraph::for_type::().root(), &ShapeRef::String ); +} + +#[test] +fn maps_network_shapes_without_std() { let ipv4_binary = ShapeRef::Array { item: Box::new(ShapeRef::U8), len: 4, }; assert_eq!( - SerializeShapeGraph::for_type::().root(), + SerializeShapeGraph::for_type::().root(), &ShapeRef::union([ShapeRef::String, ipv4_binary.clone()]) ); - let socket = DeserializeShapeGraph::for_type::(); + let socket = DeserializeShapeGraph::for_type::(); let ShapeRef::Union(root) = socket.root() else { panic!("socket address should reflect human-readable and binary shapes"); }; diff --git a/tests/no_std/src/lib.rs b/tests/no_std/src/lib.rs index 0e43dc5..22cd850 100644 --- a/tests/no_std/src/lib.rs +++ b/tests/no_std/src/lib.rs @@ -20,6 +20,7 @@ extern crate alloc; use alloc::boxed::Box; use alloc::string::String; use alloc::vec::Vec; +use core::net::SocketAddr; use serde_shape::DeserializeShape; use serde_shape::SerializeShape; @@ -29,4 +30,5 @@ pub struct NoStdConfig { name: String, values: Vec>, child: Option>, + listen: SocketAddr, } diff --git a/tests/no_std/tests/shapes.rs b/tests/no_std/tests/shapes.rs index 46182e5..74eb408 100644 --- a/tests/no_std/tests/shapes.rs +++ b/tests/no_std/tests/shapes.rs @@ -28,12 +28,12 @@ fn reflects_no_std_deserialization() { }; let root_id = *root_id; assert_eq!(root_id.index(), 0); - assert_eq!(graph.definitions().len(), 1); + assert_eq!(graph.definitions().len(), 2); let DeserializeDefinitionKind::Struct(shape) = &graph.definition(root_id).unwrap().kind else { panic!("root definition should be a struct"); }; - assert_eq!(shape.fields.len(), 3); + assert_eq!(shape.fields.len(), 4); assert_eq!( shape.fields[0].wire_shape, FieldWireShape::Value(ShapeRef::String) @@ -48,6 +48,10 @@ fn reflects_no_std_deserialization() { shape.fields[2].wire_shape, FieldWireShape::Value(ShapeRef::Option(Box::new(ShapeRef::Definition(root_id)))) ); + assert!(matches!( + shape.fields[3].wire_shape, + FieldWireShape::Value(ShapeRef::Union(_)) + )); } #[test] @@ -57,12 +61,12 @@ fn reflects_no_std_serialization() { panic!("root shape should be a definition"); }; let root_id = *root_id; - assert_eq!(graph.definitions().len(), 1); + assert_eq!(graph.definitions().len(), 2); let SerializeDefinitionKind::Struct(shape) = &graph.definition(root_id).unwrap().kind else { panic!("root definition should be a struct"); }; - assert_eq!(shape.fields.len(), 3); + assert_eq!(shape.fields.len(), 4); assert_eq!( shape.fields[0].wire_shape, FieldWireShape::Value(ShapeRef::String) @@ -77,4 +81,8 @@ fn reflects_no_std_serialization() { shape.fields[2].wire_shape, FieldWireShape::Value(ShapeRef::Option(Box::new(ShapeRef::Definition(root_id)))) ); + assert!(matches!( + shape.fields[3].wire_shape, + FieldWireShape::Value(ShapeRef::Union(_)) + )); } From 83b1fbd87f87879e3bc58b6fe400c462e3eb8d8e Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 22:48:32 +0800 Subject: [PATCH 4/9] feat: allow explicit derive bounds Why: a generic custom shape hook can require T: SerializeShape or T: DeserializeShape, but the derive cannot infer arbitrary function signatures. Without a directional bound override, users had to leak reflection-only constraints into the business type declaration or could not compile the hook at all. Signed-off-by: tison --- CHANGELOG.md | 1 + README.md | 2 ++ serde-shape-derive/src/lib.rs | 31 +++++++++++++++-- serde-shape-derive/src/shape_attr.rs | 52 ++++++++++++++++++++++++++-- serde-shape/src/lib.rs | 6 ++-- tests/derive/tests/derive.rs | 41 ++++++++++++++++++++++ 6 files changed, 126 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 842f161..8c2a186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file. ### New features * Add `#[serde_shape(serialize_with = "path", deserialize_with = "path")]` hooks for custom Serde functions and foreign representations. +* 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. diff --git a/README.md b/README.md index 2776aea..7f39398 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. +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 Shape graphs are an inspection API, not a stable interchange format. `ShapeId` values are local to one graph, and definition ordering and `Debug` output are not persistence contracts. diff --git a/serde-shape-derive/src/lib.rs b/serde-shape-derive/src/lib.rs index e3078ba..0c9ff9b 100644 --- a/serde-shape-derive/src/lib.rs +++ b/serde-shape-derive/src/lib.rs @@ -153,28 +153,46 @@ fn validate_shape_attrs(container: &ast::Container<'_>) -> syn::Result<()> { if !attrs.is_empty() { return Err(syn::Error::new_spanned( variant.original, - "serde_shape custom functions are supported on containers and fields, not variants", + "serde_shape attributes are not supported on variants", )); } for field in &variant.fields { - ShapeAttrs::parse(&field.original.attrs)?; + validate_field_shape_attrs(field)?; } } } ast::Data::Struct(_, fields) => { for field in fields { - ShapeAttrs::parse(&field.original.attrs)?; + validate_field_shape_attrs(field)?; } } } Ok(()) } +fn validate_field_shape_attrs(field: &ast::Field<'_>) -> syn::Result<()> { + let attrs = ShapeAttrs::parse(&field.original.attrs)?; + if attrs.has_bound() { + return Err(syn::Error::new_spanned( + field.original, + "serde_shape bounds are supported on containers, not fields", + )); + } + Ok(()) +} + fn add_serialize_shape_bounds( generics: &mut syn::Generics, container: &ast::Container<'_>, shape_attrs: &ShapeAttrs, ) -> syn::Result<()> { + if let Some(predicates) = shape_attrs.serialize_bound() { + generics + .make_where_clause() + .predicates + .extend(predicates.iter().cloned()); + return Ok(()); + } let type_params: BTreeSet<_> = generics .type_params() .map(|param| param.ident.to_string()) @@ -229,6 +247,13 @@ fn add_deserialize_shape_bounds( container: &ast::Container<'_>, shape_attrs: &ShapeAttrs, ) -> syn::Result<()> { + if let Some(predicates) = shape_attrs.deserialize_bound() { + generics + .make_where_clause() + .predicates + .extend(predicates.iter().cloned()); + return Ok(()); + } let type_params: BTreeSet<_> = generics .type_params() .map(|param| param.ident.to_string()) diff --git a/serde-shape-derive/src/shape_attr.rs b/serde-shape-derive/src/shape_attr.rs index 629a333..f4aab55 100644 --- a/serde-shape-derive/src/shape_attr.rs +++ b/serde-shape-derive/src/shape_attr.rs @@ -18,13 +18,18 @@ use syn::Expr; use syn::ExprPath; use syn::Lit; use syn::LitStr; +use syn::Token; +use syn::WherePredicate; use syn::meta::ParseNestedMeta; +use syn::punctuated::Punctuated; use syn::spanned::Spanned; #[derive(Default)] pub struct ShapeAttrs { serialize_with: Option<(ExprPath, Span)>, deserialize_with: Option<(ExprPath, Span)>, + serialize_bound: Option<(Vec, Span)>, + deserialize_bound: Option<(Vec, Span)>, } impl ShapeAttrs { @@ -49,9 +54,29 @@ impl ShapeAttrs { parse_path(&meta)?, meta.path.span(), ) + } else if meta.path.is_ident("bound") { + meta.parse_nested_meta(|meta| { + if meta.path.is_ident("serialize") { + set_once( + &mut parsed.serialize_bound, + parse_bound(&meta)?, + meta.path.span(), + ) + } else if meta.path.is_ident("deserialize") { + set_once( + &mut parsed.deserialize_bound, + parse_bound(&meta)?, + meta.path.span(), + ) + } else { + Err(meta.error( + "unknown serde_shape bound; expected `serialize` or `deserialize`", + )) + } + }) } else { Err(meta.error( - "unknown serde_shape attribute; expected `serialize_with` or `deserialize_with`", + "unknown serde_shape attribute; expected `serialize_with`, `deserialize_with`, or `bound`", )) } })?; @@ -68,8 +93,24 @@ impl ShapeAttrs { self.deserialize_with.as_ref().map(|(path, _)| path) } + pub fn serialize_bound(&self) -> Option<&[WherePredicate]> { + self.serialize_bound + .as_ref() + .map(|(predicates, _)| predicates.as_slice()) + } + + pub fn deserialize_bound(&self) -> Option<&[WherePredicate]> { + self.deserialize_bound + .as_ref() + .map(|(predicates, _)| predicates.as_slice()) + } + + pub fn has_bound(&self) -> 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.serialize_with.is_none() && self.deserialize_with.is_none() && !self.has_bound() } } @@ -113,6 +154,13 @@ fn parse_path(meta: &ParseNestedMeta<'_>) -> syn::Result { value.parse() } +fn parse_bound(meta: &ParseNestedMeta<'_>) -> syn::Result> { + let value = meta.value()?; + let value: LitStr = value.parse()?; + let predicates = value.parse_with(Punctuated::::parse_terminated)?; + Ok(predicates.into_iter().collect()) +} + fn set_once(slot: &mut Option<(T, Span)>, value: T, span: Span) -> syn::Result<()> { if slot.is_some() { return Err(syn::Error::new(span, "duplicate serde_shape attribute")); diff --git a/serde-shape/src/lib.rs b/serde-shape/src/lib.rs index 82719f3..8791327 100644 --- a/serde-shape/src/lib.rs +++ b/serde-shape/src/lib.rs @@ -218,7 +218,8 @@ pub mod __private { /// /// 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`]. +/// and return a [`ShapeRef`]. Generic hooks can replace inferred bounds with +/// `#[serde_shape(bound(deserialize = "T: DeserializeShape"))]` on the container. /// /// # Example /// @@ -257,7 +258,8 @@ pub use serde_shape_derive::DeserializeShape; /// /// 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`]. +/// a [`ShapeRef`]. Generic hooks can replace inferred bounds with +/// `#[serde_shape(bound(serialize = "T: SerializeShape"))]` on the container. /// /// # Example /// diff --git a/tests/derive/tests/derive.rs b/tests/derive/tests/derive.rs index 1ee7aa0..ebb1c1b 100644 --- a/tests/derive/tests/derive.rs +++ b/tests/derive/tests/derive.rs @@ -115,6 +115,18 @@ struct FieldShapeOverrides { directional: NotShape, } +#[derive(SerializeShape, DeserializeShape)] +#[serde_shape(bound(serialize = "T: SerializeShape", deserialize = "T: DeserializeShape"))] +struct GenericFieldShape { + #[serde_shape( + serialize_with = "serialize_type_shape::", + deserialize_with = "deserialize_type_shape::" + )] + custom: NotShape, + #[serde(skip)] + marker: core::marker::PhantomData, +} + /// Selects the retry policy. /// /// This text is available to configuration tooling. @@ -234,6 +246,14 @@ fn deserialize_string_shape(context: &mut DeserializeShapeContext) -> ShapeRef { String::deserialize_shape_in(context) } +fn serialize_type_shape(context: &mut SerializeShapeContext) -> ShapeRef { + T::serialize_shape_in(context) +} + +fn deserialize_type_shape(context: &mut DeserializeShapeContext) -> ShapeRef { + T::deserialize_shape_in(context) +} + fn serialize_u8_shape(_context: &mut SerializeShapeContext) -> ShapeRef { ShapeRef::U8 } @@ -343,6 +363,27 @@ fn applies_container_and_field_custom_shape_functions() { ); } +#[test] +fn applies_explicit_bounds_to_generic_shape_hooks() { + let definition = serialize_root_definition::>(); + let SerializeDefinitionKind::Struct(shape) = &definition.kind else { + panic!("serialize definition should be a struct"); + }; + assert_eq!( + shape.fields[0].wire_shape, + FieldWireShape::Value(ShapeRef::U32) + ); + + let definition = deserialize_root_definition::>(); + let DeserializeDefinitionKind::Struct(shape) = &definition.kind else { + panic!("deserialize definition should be a struct"); + }; + assert_eq!( + shape.fields[0].wire_shape, + FieldWireShape::Value(ShapeRef::U32) + ); +} + #[test] fn preserves_rust_documentation() { let definition = serialize_root_definition::(); From b5bea60a83568836c525bf6dfd1531965b056571 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 22:49:17 +0800 Subject: [PATCH 5/9] docs: state the graph model limits plainly Why: the README compared serde-shape to another project with claims it could not substantiate, implied custom Serde code was always opaque despite the hook API, and did not warn consumers that recursive definition edges require cycle detection. Signed-off-by: tison --- README.md | 6 ++++-- serde-shape/src/lib.rs | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7f39398..f7d9c67 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Typical use cases: Field shapes expose `wire_shape` as the source of truth for regular values, flattened fields, inline transparent fields, and omitted fields. Custom serializer/deserializer boundaries are represented by `ShapeRef::Opaque`, including when they are flattened or inline. -You may use [`schemars`](https://docs.rs/schemars) for JSON Schema generation and validation. But `schemars` is not a general-purpose Serde shape reflection library, and it does not support all Serde attributes. `serde-shape` is designed to be a more complete and general-purpose reflection of Serde shapes. +If the consumer needs JSON Schema, [`schemars`](https://docs.rs/schemars) directly targets that format. `serde-shape` instead keeps serialization and deserialization shapes separate and leaves format-specific export and validation to downstream tools. ## Example @@ -137,6 +137,8 @@ For a generic custom hook, container-level `#[serde_shape(bound(serialize = "... Shape graphs are an inspection API, not a stable interchange format. `ShapeId` values are local to one graph, and definition ordering and `Debug` output are not persistence contracts. +Definitions may be recursive. A `ShapeRef::Definition` is a graph edge, so walkers must detect repeated `ShapeId` values instead of expanding definitions indefinitely. + Types that branch on `Serializer::is_human_readable()` or `Deserializer::is_human_readable()` may expose a union of their known representations. The graph describes the possible Serde calls across formats; it is not specialized for one serializer format. ## Feature flags @@ -161,7 +163,7 @@ The built-in implementations follow Serde's own data-model calls in each directi 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`. -For an unsupported foreign type, use a local newtype and implement `SerializeShape` or `DeserializeShape` manually. Custom Serde functions remain visible as opaque boundaries because their wire behavior cannot be inferred. +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. ## `no_std` support diff --git a/serde-shape/src/lib.rs b/serde-shape/src/lib.rs index 8791327..ab9f5ce 100644 --- a/serde-shape/src/lib.rs +++ b/serde-shape/src/lib.rs @@ -132,6 +132,8 @@ //! Definition IDs are local to one graph. Use [`SerializeShapeGraph::definition`] or //! [`DeserializeShapeGraph::definition`] to resolve them. Definition ordering and debug output //! are not stable persistence formats. +//! Definitions may be recursive, so graph walkers must detect repeated [`ShapeId`] values before +//! following definition references. //! //! Types that branch on Serde's human-readable mode may expose a union of their known //! representations. Shape graphs describe possible Serde data-model calls across formats rather From 8dae9110d41c91f3d40500cf6ad2209b0ed18474 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 22:49:24 +0800 Subject: [PATCH 6/9] docs: use the canonical license URL Why: the license text is maintained by the Apache Software Foundation, so the project documentation should not depend on a repository branch URL merely to identify the Apache 2.0 license. Signed-off-by: tison --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f7d9c67..1fc057d 100644 --- a/README.md +++ b/README.md @@ -183,4 +183,4 @@ See the [contributor guide](CONTRIBUTING.md) for the development workflow and te ## License -This project is licensed under the [Apache License, Version 2.0](https://github.com/fast/serde-shape/blob/main/LICENSE). +This project is licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). From 9ab2746beff3b68abac05a56d349f60694dccce2 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 22:52:34 +0800 Subject: [PATCH 7/9] test: focus the config env integration fixture Why: the two end-to-end tests only need to prove that reflected paths can edit internally tagged TOML, but the fixture had grown a second 400-line schema interpreter for value kinds, optionality, and conditions. Most of those policies were untested and are downstream concerns, making the test harder to trust and review. Signed-off-by: tison --- tests/integration/tests/configenv.rs | 426 ++++++--------------------- 1 file changed, 91 insertions(+), 335 deletions(-) diff --git a/tests/integration/tests/configenv.rs b/tests/integration/tests/configenv.rs index cd3d338..626fee9 100644 --- a/tests/integration/tests/configenv.rs +++ b/tests/integration/tests/configenv.rs @@ -20,6 +20,7 @@ use serde::Deserialize; use serde::de::IntoDeserializer; use serde_shape::DeserializeDefinitionKind; use serde_shape::DeserializeEnumShape; +use serde_shape::DeserializeFieldShape; use serde_shape::DeserializeShape; use serde_shape::DeserializeShapeGraph; use serde_shape::DeserializeStructShape; @@ -29,18 +30,8 @@ use serde_shape::FieldsStyle; use serde_shape::ShapeId; use serde_shape::ShapeRef; use serde_shape::Tagging; -use serde_shape::UnionShape; use toml_edit::DocumentMut; -#[derive(Clone, Debug, Eq, PartialEq)] -struct EnvOption { - env_name: String, - path: Vec, - value_kind: String, - optional: bool, - condition: Option, -} - #[derive(Debug, Deserialize, DeserializeShape, PartialEq)] #[serde(deny_unknown_fields)] struct ClientConfig { @@ -77,7 +68,8 @@ enum ExecutionMode { #[test] fn edits_an_internally_tagged_newtype_variant_through_generated_paths() { - let options = env_options::("APP_CONFIG"); + let paths = env_paths::("APP_CONFIG"); + assert_eq!(paths.len(), 4); let mut document = r#" [transport] kind = "tcp" @@ -112,12 +104,11 @@ fn edits_an_internally_tagged_newtype_variant_through_generated_paths() { ]; for (env_name, value, expected_path) in overrides { - let option = options - .iter() - .find(|option| option.env_name == env_name) + let path = paths + .get(env_name) .expect("generated environment option should exist"); - assert_eq!(option.path, expected_path); - set_toml_path(&mut document, &option.path, value); + assert_eq!(path, &expected_path); + set_toml_path(&mut document, path, value); } let config = ClientConfig::deserialize(document.into_deserializer()) @@ -136,13 +127,12 @@ fn edits_an_internally_tagged_newtype_variant_through_generated_paths() { #[test] fn edits_an_internally_tagged_unit_enum_through_its_tag_path() { - let options = env_options::("APP_CONFIG"); - let option = options - .iter() - .find(|option| option.env_name == "APP_CONFIG_MODE_KIND") + let paths = env_paths::("APP_CONFIG"); + let path = paths + .get("APP_CONFIG_MODE_KIND") .expect("generated tag option should exist"); - assert_eq!(option.path, ["mode", "kind"]); - assert_eq!(option.value_kind, "enum[fast|safe]"); + assert_eq!(paths.len(), 1); + assert_eq!(path, &["mode", "kind"]); let mut document = r#" [mode] @@ -150,7 +140,7 @@ fn edits_an_internally_tagged_unit_enum_through_its_tag_path() { "# .parse::() .expect("config should be valid TOML"); - set_toml_path(&mut document, &option.path, toml_edit::value("safe")); + set_toml_path(&mut document, path, toml_edit::value("safe")); let config = ModeConfig::deserialize(document.into_deserializer()) .expect("edited TOML should deserialize"); @@ -162,62 +152,34 @@ fn edits_an_internally_tagged_unit_enum_through_its_tag_path() { ); } -fn env_options(env_prefix: &str) -> Vec { - let shape = DeserializeShapeGraph::for_type::(); - let mut collector = EnvCollector { - shape: &shape, - env_prefix, - options: BTreeMap::new(), +fn env_paths(prefix: &str) -> BTreeMap> { + let graph = T::deserialize_shape(); + let mut collector = EnvPathCollector { + graph: &graph, + prefix, + paths: BTreeMap::new(), }; - collector.visit_shape_ref(shape.root(), &mut Vec::new(), false, None); - collector.options.into_values().collect() + collector.visit(graph.root(), &mut Vec::new(), &mut Vec::new()); + collector.paths } -struct EnvCollector<'a> { - shape: &'a DeserializeShapeGraph, - env_prefix: &'a str, - options: BTreeMap, EnvOption>, +struct EnvPathCollector<'a> { + graph: &'a DeserializeShapeGraph, + prefix: &'a str, + paths: BTreeMap>, } -impl EnvCollector<'_> { - fn visit_shape_ref( +impl EnvPathCollector<'_> { + fn visit( &mut self, shape_ref: &ShapeRef, path: &mut Vec, - optional: bool, - condition: Option, + definition_stack: &mut Vec, ) { match shape_ref { - ShapeRef::Option(inner) => { - self.visit_shape_ref(inner, path, true, condition); - } - ShapeRef::Union(union) => { - let value_kind = self.union_kind(union); - self.push_leaf(path, &value_kind, optional, condition); - } - ShapeRef::Definition(id) => { - self.visit_definition(*id, path, optional, condition); - } - ShapeRef::Seq(_) | ShapeRef::Array { .. } => { - self.push_leaf(path, "array", optional, condition); - } - ShapeRef::Map { .. } => { - self.push_leaf(path, "object", optional, condition); - } - ShapeRef::Tuple(_) => { - self.push_leaf(path, "array", optional, condition); - } - ShapeRef::Opaque(opaque) => { - self.push_leaf( - path, - &format!("opaque({:?})", opaque.reason), - optional, - condition, - ); - } - shape_ref => { - self.push_leaf(path, primitive_kind(shape_ref), optional, condition); - } + ShapeRef::Option(inner) => self.visit(inner, path, definition_stack), + ShapeRef::Definition(id) => self.visit_definition(*id, path, definition_stack), + _ => self.record(path), } } @@ -225,59 +187,43 @@ impl EnvCollector<'_> { &mut self, id: ShapeId, path: &mut Vec, - optional: bool, - condition: Option, + definition_stack: &mut Vec, ) { - let definition = self.shape.definition(id).expect("shape definition exists"); + if definition_stack.contains(&id) { + return; + } + definition_stack.push(id); + + let definition = self.graph.definition(id).expect("shape definition exists"); match &definition.kind { DeserializeDefinitionKind::Struct(shape) => { - self.visit_struct(shape, path, optional, condition); + self.visit_struct(shape, path, definition_stack); } DeserializeDefinitionKind::Enum(shape) => { - self.visit_enum(shape, path, optional, condition); - } - DeserializeDefinitionKind::Opaque(opaque) => { - self.push_leaf( - path, - &format!("opaque({:?})", opaque.reason), - optional, - condition, - ); + self.visit_enum(shape, path, definition_stack); } + DeserializeDefinitionKind::Opaque(_) => self.record(path), } + + definition_stack.pop(); } fn visit_struct( &mut self, shape: &DeserializeStructShape, path: &mut Vec, - optional: bool, - condition: Option, + definition_stack: &mut Vec, ) { match shape.style { FieldsStyle::Struct => { for field in &shape.fields { - let field_optional = optional || !field.default.is_none(); - self.visit_field_wire_shape( - field.name, - &field.wire_shape, - path, - field_optional, - condition.clone(), - ); + self.visit_field(field, path, definition_stack); } } FieldsStyle::Newtype if shape.fields.len() == 1 => { - self.visit_newtype_wire_shape( - &shape.fields[0].wire_shape, - path, - optional, - condition, - ); - } - FieldsStyle::Tuple | FieldsStyle::Newtype | FieldsStyle::Unit => { - self.push_leaf(path, "object", optional, condition); + self.visit_newtype(&shape.fields[0].wire_shape, path, definition_stack); } + FieldsStyle::Tuple | FieldsStyle::Newtype | FieldsStyle::Unit => self.record(path), } } @@ -285,246 +231,87 @@ impl EnvCollector<'_> { &mut self, shape: &DeserializeEnumShape, path: &mut Vec, - optional: bool, - condition: Option, + definition_stack: &mut Vec, ) { - let variants = shape - .variants - .iter() - .filter(|variant| !matches!(&variant.content, DeserializeVariantContent::Omitted)) - .map(|variant| variant.name) - .collect::>(); - - let all_variants_are_unit = shape - .variants - .iter() - .filter(|variant| !matches!(&variant.content, DeserializeVariantContent::Omitted)) - .all(|variant| variant.style == FieldsStyle::Unit) - && !variants.is_empty(); - - if matches!(&shape.repr, Tagging::External) && all_variants_are_unit { - self.push_leaf( - path, - &format!("enum[{}]", variants.join("|")), - optional, - condition, - ); + let Tagging::Internal { tag } = &shape.repr else { + self.record(path); return; - } - - if let Tagging::Internal { tag } = shape.repr { - let tag_path = appended_path(path, tag); - self.push_leaf( - &tag_path, - &format!("enum[{}]", variants.join("|")), - optional, - condition.clone(), - ); - - for variant in &shape.variants { - let variant_condition = format!("{}={}", tag_path.join("."), variant.name); - let variant_condition = Some(merge_conditions( - condition.as_deref(), - variant_condition.as_str(), - )); - - match &variant.content { - DeserializeVariantContent::Omitted => {} - DeserializeVariantContent::Fields(fields) - if variant.style == FieldsStyle::Newtype && fields.len() == 1 => - { - self.visit_newtype_wire_shape( - &fields[0].wire_shape, - path, - optional, - variant_condition, - ); - } - DeserializeVariantContent::Fields(fields) => { - for field in fields { - self.visit_field_wire_shape( - field.name, - &field.wire_shape, - path, - optional, - variant_condition.clone(), - ); - } - } - DeserializeVariantContent::Custom(opaque) => { - self.push_leaf( - path, - &format!("opaque({:?})", opaque.reason), - optional, - variant_condition, - ); - } - _ => { - self.push_leaf(path, "unsupported", optional, variant_condition); + }; + + path.push((*tag).to_owned()); + self.record(path); + path.pop(); + + for variant in &shape.variants { + match &variant.content { + DeserializeVariantContent::Omitted => {} + DeserializeVariantContent::Fields(fields) + if variant.style == FieldsStyle::Newtype && fields.len() == 1 => + { + self.visit_newtype(&fields[0].wire_shape, path, definition_stack); + } + DeserializeVariantContent::Fields(fields) => { + for field in fields { + self.visit_field(field, path, definition_stack); } } + DeserializeVariantContent::Custom(_) => self.record(path), + _ => self.record(path), } - return; } - - self.push_leaf( - path, - &format!("enum[{}]", variants.join("|")), - optional, - condition, - ); } - fn visit_field_wire_shape( + fn visit_field( &mut self, - field_name: &str, - wire_shape: &FieldWireShape, + field: &DeserializeFieldShape, path: &mut Vec, - optional: bool, - condition: Option, + definition_stack: &mut Vec, ) { - match wire_shape { + match &field.wire_shape { FieldWireShape::Omitted => {} FieldWireShape::Value(shape_ref) => { - path.push(field_name.to_owned()); - self.visit_shape_ref(shape_ref, path, optional, condition); + path.push(field.name.to_owned()); + self.visit(shape_ref, path, definition_stack); path.pop(); } - FieldWireShape::Flatten(shape_ref) => { - self.visit_shape_ref(shape_ref, path, optional, condition); - } - FieldWireShape::Inline(shape_ref) => { - self.visit_shape_ref(shape_ref, path, optional, condition); + FieldWireShape::Flatten(shape_ref) | FieldWireShape::Inline(shape_ref) => { + self.visit(shape_ref, path, definition_stack); } _ => { - path.push(field_name.to_owned()); - self.push_leaf(path, "unsupported", optional, condition); + path.push(field.name.to_owned()); + self.record(path); path.pop(); } } } - fn visit_newtype_wire_shape( + fn visit_newtype( &mut self, wire_shape: &FieldWireShape, path: &mut Vec, - optional: bool, - condition: Option, + definition_stack: &mut Vec, ) { match wire_shape { FieldWireShape::Omitted => {} FieldWireShape::Value(shape_ref) | FieldWireShape::Flatten(shape_ref) | FieldWireShape::Inline(shape_ref) => { - self.visit_shape_ref(shape_ref, path, optional, condition); + self.visit(shape_ref, path, definition_stack); } - _ => { - self.push_leaf(path, "unsupported", optional, condition); - } - } - } - - fn union_kind(&self, union: &UnionShape) -> String { - let alternatives = union.alternatives(); - if alternatives.iter().all(ShapeRef::is_integer) { - return "integer".to_owned(); - } - if alternatives.iter().all(ShapeRef::is_float) { - return "float".to_owned(); + _ => self.record(path), } - if alternatives.iter().all(ShapeRef::is_number) { - return "number".to_owned(); - } - - alternatives - .iter() - .fold(Vec::::new(), |mut kinds, alternative| { - let kind = self.union_alternative_kind(alternative); - if !kinds.contains(&kind) { - kinds.push(kind); - } - kinds - }) - .join("|") } - fn union_alternative_kind(&self, shape_ref: &ShapeRef) -> String { - match shape_ref { - ShapeRef::Option(inner) => self.union_alternative_kind(inner), - ShapeRef::Seq(_) | ShapeRef::Array { .. } | ShapeRef::Tuple(_) => "array".to_owned(), - ShapeRef::Map { .. } => "object".to_owned(), - ShapeRef::Union(union) => self.union_kind(union), - ShapeRef::Definition(id) => { - let definition = self.shape.definition(*id).expect("shape definition exists"); - match &definition.kind { - DeserializeDefinitionKind::Struct(shape) if shape.attributes.transparent => { - shape - .fields - .iter() - .find_map(|field| match &field.wire_shape { - FieldWireShape::Inline(inner) => { - Some(self.union_alternative_kind(inner)) - } - FieldWireShape::Omitted - | FieldWireShape::Value(_) - | FieldWireShape::Flatten(_) => None, - _ => None, - }) - .unwrap_or_else(|| "unit".to_owned()) - } - DeserializeDefinitionKind::Struct(shape) - if shape.style == FieldsStyle::Newtype && shape.fields.len() == 1 => - { - match &shape.fields[0].wire_shape { - FieldWireShape::Omitted => "unit".to_owned(), - FieldWireShape::Value(inner) - | FieldWireShape::Flatten(inner) - | FieldWireShape::Inline(inner) => self.union_alternative_kind(inner), - _ => "unknown".to_owned(), - } - } - DeserializeDefinitionKind::Struct(_) => "object".to_owned(), - DeserializeDefinitionKind::Enum(_) => "enum".to_owned(), - DeserializeDefinitionKind::Opaque(opaque) => { - format!("opaque({:?})", opaque.reason) - } - } - } - ShapeRef::Opaque(opaque) => format!("opaque({:?})", opaque.reason), - shape_ref => primitive_kind(shape_ref).to_owned(), - } - } - - fn push_leaf( - &mut self, - path: &[String], - value_kind: &str, - optional: bool, - condition: Option, - ) { + fn record(&mut self, path: &[String]) { if path.is_empty() { return; } - - let path = path.to_vec(); - self.options - .entry(path.clone()) - .or_insert_with(|| EnvOption { - env_name: env_name(self.env_prefix, &path), - path, - value_kind: value_kind.to_owned(), - optional, - condition, - }); + self.paths + .entry(env_name(self.prefix, path)) + .or_insert_with(|| path.to_vec()); } } -fn appended_path(path: &[String], segment: &str) -> Vec { - let mut path = path.to_owned(); - path.push(segment.to_owned()); - path -} - fn set_toml_path(document: &mut DocumentMut, path: &[String], value: toml_edit::Item) { let (key, parents) = path.split_last().expect("config path should not be empty"); let mut current = document.as_item_mut(); @@ -534,41 +321,10 @@ fn set_toml_path(document: &mut DocumentMut, path: &[String], value: toml_edit:: current[key.as_str()] = value; } -fn merge_conditions(existing: Option<&str>, new: &str) -> String { - existing.map_or_else(|| new.to_owned(), |existing| format!("{existing}; {new}")) -} - -fn primitive_kind(shape_ref: &ShapeRef) -> &'static str { - if shape_ref.is_integer() { - "integer" - } else if shape_ref.is_float() { - "float" - } else if shape_ref.is_number() { - "number" - } else { - match shape_ref { - ShapeRef::Unit => "unit", - ShapeRef::Bool => "boolean", - ShapeRef::Char | ShapeRef::String | ShapeRef::Bytes => "string", - ShapeRef::Option(_) - | ShapeRef::Seq(_) - | ShapeRef::Array { .. } - | ShapeRef::Map { .. } - | ShapeRef::Tuple(_) - | ShapeRef::Union(_) - | ShapeRef::Definition(_) - | ShapeRef::Opaque(_) => { - unreachable!("compound shapes are handled before leaf mapping") - } - _ => unreachable!("numeric shapes are handled before leaf mapping"), - } - } -} - fn env_name(prefix: &str, path: &[String]) -> String { - let path = path - .iter() - .flat_map(|segment| segment.chars().chain(['_'])) + let suffix = path + .join("_") + .chars() .map(|ch| { if ch.is_ascii_alphanumeric() { ch.to_ascii_uppercase() @@ -577,5 +333,5 @@ fn env_name(prefix: &str, path: &[String]) -> String { } }) .collect::(); - format!("{prefix}_{}", path.trim_end_matches('_')) + format!("{prefix}_{suffix}") } From 1bb1481e1870094911dd1f0b57f8689c6c6380c5 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 22:54:01 +0800 Subject: [PATCH 8/9] fix: normalize reflected default paths Why: syn renders qualified paths such as crate::defaults with spaces around punctuation. Exposing that rendering through DefaultShape::Path made path metadata inconsistent with the custom-function metadata and awkward for tools that compare or display it. Signed-off-by: tison --- CHANGELOG.md | 1 + serde-shape-derive/src/lib.rs | 2 +- tests/derive/tests/derive.rs | 7 +++++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c2a186..cecd5ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to this project will be documented in this file. * 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`. +* Preserve qualified Serde default paths without token-rendering spaces. ### Improvements diff --git a/serde-shape-derive/src/lib.rs b/serde-shape-derive/src/lib.rs index 0c9ff9b..f66bb27 100644 --- a/serde-shape-derive/src/lib.rs +++ b/serde-shape-derive/src/lib.rs @@ -936,7 +936,7 @@ fn default_shape(default: &attr::Default) -> TokenStream2 { attr::Default::None => quote!(__serde_shape::DefaultShape::None), attr::Default::Default => quote!(__serde_shape::DefaultShape::Default), attr::Default::Path(path) => { - let path = lit(path.to_token_stream().to_string()); + let path = lit(path.to_token_stream().to_string().replace(' ', "")); quote!(__serde_shape::DefaultShape::Path(#path)) } } diff --git a/tests/derive/tests/derive.rs b/tests/derive/tests/derive.rs index ebb1c1b..0a7bd68 100644 --- a/tests/derive/tests/derive.rs +++ b/tests/derive/tests/derive.rs @@ -44,7 +44,7 @@ struct Config { http_port: u16, #[serde(alias = "endpoint")] api_url: Option, - #[serde(default = "default_retries")] + #[serde(default = "crate::default_retries")] retries: u8, #[serde(flatten)] storage: Storage, @@ -282,7 +282,10 @@ fn exposes_deserialize_container_attributes() { }; assert_eq!(http_port.name, "http-port"); assert_eq!(api_url.aliases, ["api-url", "endpoint"]); - assert_eq!(retries.default, DefaultShape::Path("default_retries")); + assert_eq!( + retries.default, + DefaultShape::Path("crate::default_retries") + ); assert!(matches!(storage.wire_shape, FieldWireShape::Flatten(_))); assert_eq!(skipped.wire_shape, FieldWireShape::Omitted); let FieldWireShape::Value(ShapeRef::Opaque(opaque)) = &secret.wire_shape else { From 7a4910bb715ebbaa82c3a4eaad29ceb24d1134a0 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 22:56:35 +0800 Subject: [PATCH 9/9] chore: exclude generated TOML from formatting Why: cargo package writes normalized manifests under target/package, and the existing exclude pattern matched only the target directory entry rather than its descendants. Running the documented lint workflow after a package check therefore failed on Cargo-generated files. Signed-off-by: tison --- taplo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/taplo.toml b/taplo.toml index 8bb3198..ebbb309 100644 --- a/taplo.toml +++ b/taplo.toml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -exclude = ["target"] +exclude = ["target/**"] include = ["Cargo.toml", "**/*.toml"] [formatting]