diff --git a/CHANGELOG.md b/CHANGELOG.md index 4361419..cecd5ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,14 +14,19 @@ 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. ### Bug fixes * 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 +* 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/README.md b/README.md index ff94a8d..1fc057d 100644 --- a/README.md +++ b/README.md @@ -52,16 +52,14 @@ 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 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"); @@ -136,10 +131,14 @@ 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. +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 @@ -159,11 +158,12 @@ 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`. -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 @@ -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). diff --git a/serde-shape-derive/src/lib.rs b/serde-shape-derive/src/lib.rs index e3078ba..f66bb27 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()) @@ -911,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/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/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/lib.rs b/serde-shape/src/lib.rs index ec5ae50..ab9f5ce 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"); @@ -145,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 @@ -231,7 +220,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 /// @@ -239,7 +229,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 +239,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"); @@ -274,14 +260,14 @@ 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 /// /// ```rust /// use serde_shape::SerializeDefinitionKind; /// use serde_shape::SerializeShape; -/// use serde_shape::ShapeRef; /// /// #[derive(SerializeShape)] /// #[serde(rename = "api-response", rename_all = "camelCase")] @@ -292,10 +278,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"); @@ -318,10 +301,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 +312,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::() } } @@ -368,6 +345,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 @@ -407,6 +392,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 3941839..6a070fb 100644 --- a/serde-shape/src/tests.rs +++ b/serde-shape/src/tests.rs @@ -205,15 +205,17 @@ 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()); } #[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 +223,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 ); } @@ -373,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/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] 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() } diff --git a/tests/derive/tests/derive.rs b/tests/derive/tests/derive.rs index 1ee7aa0..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, @@ -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 } @@ -262,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 { @@ -343,6 +366,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::(); 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}") } 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(_)) + )); }