Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ jobs:
with:
tool: typos-cli,taplo-cli,hawkeye
- run: cargo x lint
- run: cargo x package --locked

msrv:
name: Resolve MSRV
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,35 @@ All notable changes to this project will be documented in this file.
* Make the `ShapeId` tuple field private. Use `ShapeId::index()` when the graph-local numeric index is needed.
* Add `description` to definition, field, and variant metadata. Manual struct literals must initialize the new field.
* Remove `OpaqueReason::{FromType, TryFromType, IntoType}` because Serde conversion attributes now use the conversion type's shape instead of an opaque boundary.
* Restrict the blanket `Box<T>` deserialization shape to sized `T`. Serde-supported owned DSTs have explicit implementations; shape-only custom DSTs now need a local newtype.

### New features

* Add `#[serde_shape(serialize_with = "path", deserialize_with = "path")]` hooks for custom Serde functions and foreign representations.
* Allow custom shape hooks on enum variants so known custom variant content does not have to remain opaque.
* Reflect Serde's byte-buffer representation for `CStr`, `CString`, and owned `Box<CStr>` input.
* Add `Rc`, `Arc`, and weak-pointer shapes, preserving Serde's owned input and optional weak-pointer representations.
* Reflect `Saturating<T>` with Serde's generic serialization and primitive-only deserialization support.
* Add named struct shapes for Serde's `Range`, `RangeFrom`, `RangeInclusive`, and `RangeTo` representations.
* Reflect `Bound<T>` as Serde's externally tagged `Unbounded`, `Included`, and `Excluded` enum.
* Add the `std`-only `SystemTime` struct shape with Serde's epoch field names.
* Add directional `#[serde_shape(bound(...))]` overrides for generic custom shape hooks.
* Add `SerializeShapeGraph::root_definition` and `DeserializeShapeGraph::root_definition` for directly inspecting named root types.
* Preserve Rust doc comments on derived containers, variants, and fields as user-facing descriptions.

### Bug fixes

* Distinguish borrowed byte input from owned boxed slices: `&[u8]` reflects bytes while `Box<[u8]>` reflects a sequence, matching Serde.
* Match Serde's serialization bounds for `BinaryHeap`, `RefCell`, `Mutex`, and `RwLock`, including unsized wrapper contents.
* Reflect the proxy type used by Serde `from`, `try_from`, and `into` container attributes.
* Follow Serde's directional bounds for `Cow`: serialization reflects the borrowed type and deserialization reflects the owned type.
* Make IP and socket address shapes available in `no_std` builds through `core::net`.
* Preserve qualified Serde default paths without token-rendering spaces.

### Improvements

* Verify both publishable crate archives from their normalized manifests in CI.
* Implement `Default` for container attributes, `Tagging`, and `DefaultShape` so manual shape implementations can initialize ordinary Serde metadata concisely.
* Allow unsized types such as `str` and slices to use the `SerializeShape::serialize_shape` and `DeserializeShape::deserialize_shape` convenience methods directly.
* Document runtime graph construction, format-dependent unions, graph-local identifiers, built-in coverage, and custom representation boundaries.
* Replace broad debug snapshots with focused behavior assertions and remove the snapshot-testing dependency.
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Repository tasks are defined by `cargo x`:
cargo x build --locked
cargo x test
cargo x lint
cargo x package --locked
```

Run `cargo x --help` or `cargo x <command> --help` for command-specific options. `cargo x lint --fix` applies supported formatting and lint fixes.
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ struct Config {

Each function receives the current graph context and returns a `ShapeRef`. It may delegate to another type's shape implementation or construct a custom shape directly. A custom shape function is an assertion about the Serde behavior; `serde-shape` cannot verify that the declared shape matches the serializer or deserializer implementation.

The same `serde_shape` hooks can be placed on enum variants whose content is controlled by a variant-level Serde custom function. A variant hook describes the content inside the enum's tagging representation; the enum's `repr` still describes the tag. Without an explicit hook, custom variant content remains opaque.

For a generic custom hook, container-level `#[serde_shape(bound(serialize = "...", deserialize = "..."))]` replaces the automatically inferred bounds in the corresponding direction, following Serde's bound-override convention.

## Model boundaries
Expand All @@ -156,12 +158,16 @@ The built-in implementations follow Serde's own data-model calls in each directi
| --- | --- |
| Scalars | Rust primitives, `String`, `str`, non-zero integers, and atomics available on the target |
| Containers | `Option`, `Result`, arrays, slices for serialization, tuples through arity 16, `Vec`, `VecDeque`, `LinkedList`, `BinaryHeap`, `BTreeSet`, and `BTreeMap` |
| Wrappers | References, `Box`, `Cow`, `Cell`, `RefCell`, `Wrapping`, `Reverse`, and `PhantomData` |
| Time | `core::time::Duration` |
| Wrappers | References, `Box`, `Rc`, `Arc`, their weak pointers, `Cow`, `Cell`, `RefCell`, `Wrapping`, `Saturating`, `Reverse`, and `PhantomData` |
| FFI | `CStr` and `CString` byte representations, including owned `Box<CStr>` input |
| Ranges | `Range`, `RangeFrom`, `RangeInclusive`, `RangeTo`, and `Bound` |
| Time | `core::time::Duration` and, with `std`, `SystemTime` |
| Network | `core::net` IP and socket address types |
| `std` feature | `HashMap`, `HashSet`, `Path`, `PathBuf`, `Mutex`, and `RwLock` |

Network address shapes are unions of their human-readable string representation and their compact Serde representation. A serialized byte slice is a sequence, while borrowed byte deserialization uses `ShapeRef::Bytes`.
Network address shapes are unions of their human-readable string representation and their compact Serde representation. A serialized byte slice and an owned `Box<[u8]>` input are sequences, while borrowed byte deserialization uses `ShapeRef::Bytes`.

Serde's `rc` feature is still required to serialize or deserialize `Rc`, `Arc`, and their weak pointers; the shape implementations do not enable Serde features.

For an unsupported foreign type, use a local newtype and implement `SerializeShape` or `DeserializeShape` manually. Custom Serde functions remain opaque by default because their wire behavior cannot be inferred; use a `serde_shape` custom hook when the representation is known.

Expand Down
36 changes: 27 additions & 9 deletions serde-shape-derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,7 @@ fn validate_shape_attrs(container: &ast::Container<'_>) -> syn::Result<()> {
match &container.data {
ast::Data::Enum(variants) => {
for variant in variants {
let attrs = ShapeAttrs::parse(&variant.original.attrs)?;
if !attrs.is_empty() {
return Err(syn::Error::new_spanned(
variant.original,
"serde_shape attributes are not supported on variants",
));
}
validate_variant_shape_attrs(variant)?;
for field in &variant.fields {
validate_field_shape_attrs(field)?;
}
Expand All @@ -170,6 +164,17 @@ fn validate_shape_attrs(container: &ast::Container<'_>) -> syn::Result<()> {
Ok(())
}

fn validate_variant_shape_attrs(variant: &ast::Variant<'_>) -> syn::Result<()> {
let attrs = ShapeAttrs::parse(&variant.original.attrs)?;
if attrs.has_bound() {
return Err(syn::Error::new_spanned(
variant.original,
"serde_shape bounds are supported on containers, not variants",
));
}
Ok(())
}

fn validate_field_shape_attrs(field: &ast::Field<'_>) -> syn::Result<()> {
let attrs = ShapeAttrs::parse(&field.original.attrs)?;
if attrs.has_bound() {
Expand Down Expand Up @@ -221,7 +226,11 @@ fn add_serialize_shape_bounds(
}
ast::Data::Enum(variants) => {
for variant in variants {
if variant.attrs.skip_serializing() || variant.attrs.serialize_with().is_some() {
let variant_shape_attrs = ShapeAttrs::parse(&variant.original.attrs)?;
if variant.attrs.skip_serializing()
|| variant.attrs.serialize_with().is_some()
|| variant_shape_attrs.serialize_with().is_some()
{
continue;
}
collect_serialize_field_bound_types(
Expand Down Expand Up @@ -286,7 +295,10 @@ fn add_deserialize_shape_bounds(
}
ast::Data::Enum(variants) => {
for variant in variants {
if variant.attrs.skip_deserializing() || variant.attrs.deserialize_with().is_some()
let variant_shape_attrs = ShapeAttrs::parse(&variant.original.attrs)?;
if variant.attrs.skip_deserializing()
|| variant.attrs.deserialize_with().is_some()
|| variant_shape_attrs.deserialize_with().is_some()
{
continue;
}
Expand Down Expand Up @@ -699,6 +711,7 @@ fn deserialize_container_attributes(attrs: &attr::Container) -> TokenStream2 {
}

fn serialize_variant_shape(variant: &ast::Variant<'_>) -> syn::Result<TokenStream2> {
let shape_attrs = ShapeAttrs::parse(&variant.original.attrs)?;
let rust_name = lit(variant.ident.to_string());
let name = lit(variant.attrs.name().serialize_name());
let description = description(&variant.original.attrs);
Expand All @@ -708,6 +721,8 @@ fn serialize_variant_shape(variant: &ast::Variant<'_>) -> syn::Result<TokenStrea
let untagged = variant.attrs.untagged();
let content = if skip {
quote!(__serde_shape::SerializeVariantContent::Omitted)
} else if let Some(function) = shape_attrs.serialize_with() {
quote!(__serde_shape::SerializeVariantContent::Shape(#function(context)))
} else if let Some(custom_serializer) = variant.attrs.serialize_with() {
let detail = option_path(Some(custom_serializer));
quote! {
Expand Down Expand Up @@ -743,6 +758,7 @@ fn serialize_variant_shape(variant: &ast::Variant<'_>) -> syn::Result<TokenStrea
}

fn deserialize_variant_shape(variant: &ast::Variant<'_>) -> syn::Result<TokenStream2> {
let shape_attrs = ShapeAttrs::parse(&variant.original.attrs)?;
let rust_name = lit(variant.ident.to_string());
let name = lit(variant.attrs.name().deserialize_name());
let aliases = aliases(variant.attrs.aliases());
Expand All @@ -754,6 +770,8 @@ fn deserialize_variant_shape(variant: &ast::Variant<'_>) -> syn::Result<TokenStr
let untagged = variant.attrs.untagged();
let content = if skip {
quote!(__serde_shape::DeserializeVariantContent::Omitted)
} else if let Some(function) = shape_attrs.deserialize_with() {
quote!(__serde_shape::DeserializeVariantContent::Shape(#function(context)))
} else if let Some(custom_deserializer) = variant.attrs.deserialize_with() {
let detail = option_path(Some(custom_deserializer));
quote! {
Expand Down
4 changes: 0 additions & 4 deletions serde-shape-derive/src/shape_attr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,6 @@ impl ShapeAttrs {
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.has_bound()
}
}

pub fn description(attrs: &[Attribute]) -> Option<String> {
Expand Down
157 changes: 157 additions & 0 deletions serde-shape/src/impls/bound.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Copyright 2026 FastLabs Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use alloc::vec;
use core::any::type_name;
use core::ops::Bound;

use crate::DefaultShape;
use crate::DeserializeContainerAttributes;
use crate::DeserializeDefinitionKind;
use crate::DeserializeEnumShape;
use crate::DeserializeFieldShape;
use crate::DeserializeShape;
use crate::DeserializeShapeContext;
use crate::DeserializeTypeName;
use crate::DeserializeVariantContent;
use crate::DeserializeVariantShape;
use crate::FieldMember;
use crate::FieldWireShape;
use crate::FieldsStyle;
use crate::SerializeContainerAttributes;
use crate::SerializeDefinitionKind;
use crate::SerializeEnumShape;
use crate::SerializeFieldShape;
use crate::SerializeShape;
use crate::SerializeShapeContext;
use crate::SerializeTypeName;
use crate::SerializeVariantContent;
use crate::SerializeVariantShape;
use crate::ShapeRef;
use crate::Tagging;

impl<T> SerializeShape for Bound<T>
where
T: SerializeShape,
{
fn serialize_shape_in(context: &mut SerializeShapeContext) -> ShapeRef {
context.define_named_type(
SerializeTypeName {
rust_name: type_name::<Self>(),
name: "Bound",
},
|context| {
SerializeDefinitionKind::Enum(SerializeEnumShape {
repr: Tagging::External,
variants: vec![
serialize_bound_variant("Unbounded", None),
serialize_bound_variant("Included", Some(T::serialize_shape_in(context))),
serialize_bound_variant("Excluded", Some(T::serialize_shape_in(context))),
],
attributes: SerializeContainerAttributes::default(),
})
},
)
}
}

impl<T> DeserializeShape for Bound<T>
where
T: DeserializeShape,
{
fn deserialize_shape_in(context: &mut DeserializeShapeContext) -> ShapeRef {
context.define_named_type(
DeserializeTypeName {
rust_name: type_name::<Self>(),
name: "Bound",
},
|context| {
DeserializeDefinitionKind::Enum(DeserializeEnumShape {
repr: Tagging::External,
variants: vec![
deserialize_bound_variant("Unbounded", None),
deserialize_bound_variant(
"Included",
Some(T::deserialize_shape_in(context)),
),
deserialize_bound_variant(
"Excluded",
Some(T::deserialize_shape_in(context)),
),
],
attributes: DeserializeContainerAttributes::default(),
})
},
)
}
}

fn serialize_bound_variant(
name: &'static str,
value_shape: Option<ShapeRef>,
) -> SerializeVariantShape {
let (style, fields) = match value_shape {
Some(value_shape) => (
FieldsStyle::Newtype,
vec![SerializeFieldShape {
member: FieldMember::Unnamed(0),
name: "0",
description: None,
wire_shape: FieldWireShape::Value(value_shape),
skip_if: None,
}],
),
None => (FieldsStyle::Unit, vec![]),
};

SerializeVariantShape {
rust_name: name,
name,
description: None,
style,
content: SerializeVariantContent::Fields(fields),
untagged: false,
}
}

fn deserialize_bound_variant(
name: &'static str,
value_shape: Option<ShapeRef>,
) -> DeserializeVariantShape {
let (style, fields) = match value_shape {
Some(value_shape) => (
FieldsStyle::Newtype,
vec![DeserializeFieldShape {
member: FieldMember::Unnamed(0),
name: "0",
aliases: vec!["0"],
description: None,
wire_shape: FieldWireShape::Value(value_shape),
default: DefaultShape::None,
}],
),
None => (FieldsStyle::Unit, vec![]),
};

DeserializeVariantShape {
rust_name: name,
name,
aliases: vec![name],
description: None,
style,
content: DeserializeVariantContent::Fields(fields),
other: false,
untagged: false,
}
}
2 changes: 1 addition & 1 deletion serde-shape/src/impls/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ seq_shape! {

(T) BinaryHeap<T>
where
serialize { T: Ord + SerializeShape }
serialize { T: SerializeShape }
deserialize { T: Ord + DeserializeShape }
=> T;

Expand Down
47 changes: 47 additions & 0 deletions serde-shape/src/impls/ffi.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright 2026 FastLabs Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use alloc::boxed::Box;
use alloc::ffi::CString;
use core::ffi::CStr;

use crate::DeserializeShape;
use crate::DeserializeShapeContext;
use crate::SerializeShape;
use crate::SerializeShapeContext;
use crate::ShapeRef;

impl SerializeShape for CStr {
fn serialize_shape_in(_context: &mut SerializeShapeContext) -> ShapeRef {
ShapeRef::Bytes
}
}

impl SerializeShape for CString {
fn serialize_shape_in(_context: &mut SerializeShapeContext) -> ShapeRef {
ShapeRef::Bytes
}
}

impl DeserializeShape for CString {
fn deserialize_shape_in(_context: &mut DeserializeShapeContext) -> ShapeRef {
ShapeRef::Bytes
}
}

impl DeserializeShape for Box<CStr> {
fn deserialize_shape_in(_context: &mut DeserializeShapeContext) -> ShapeRef {
ShapeRef::Bytes
}
}
Loading