diff --git a/Cargo.lock b/Cargo.lock index a6ef7bf4..7b225e9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2475,6 +2475,9 @@ dependencies = [ "jacquard-lexicon", "miette", "serde", + "serde_ipld_dagcbor", + "serde_json", + "smol_str", "thiserror 2.0.18", ] diff --git a/crates/jacquard-codegen-tests/Cargo.toml b/crates/jacquard-codegen-tests/Cargo.toml index cc873e54..a19b3f81 100644 --- a/crates/jacquard-codegen-tests/Cargo.toml +++ b/crates/jacquard-codegen-tests/Cargo.toml @@ -11,9 +11,14 @@ jacquard-common = { path = "../jacquard-common" } jacquard-derive = { path = "../jacquard-derive" } jacquard-lexicon = { path = "../jacquard-lexicon" } serde = { workspace = true } +smol_str = { workspace = true } thiserror = { workspace = true } miette = { workspace = true } +[dev-dependencies] +serde_json = { workspace = true, features = ["std"] } +serde_ipld_dagcbor = { workspace = true } + [features] default = ["app_bsky", "com_atproto", "test_collision", "test_ns1", "test_ns2", "test_ns3"] app_bsky = ["com_atproto"] @@ -25,3 +30,7 @@ test_ns3 = ["test_ns1"] [build-dependencies] jacquard-lexicon = { path = "../jacquard-lexicon", features = ["codegen"] } + + +[lints.rust] +dead_code = "allow" diff --git a/crates/jacquard-codegen-tests/src/lib.rs b/crates/jacquard-codegen-tests/src/lib.rs index 70462176..5077c1bc 100644 --- a/crates/jacquard-codegen-tests/src/lib.rs +++ b/crates/jacquard-codegen-tests/src/lib.rs @@ -26,6 +26,9 @@ pub mod pretty; #[path = "generated/macro_mode/lib.rs"] pub mod macro_mode; +// Serde spike: empirical validation for borrow-or-share type param + serde interaction. +mod serde_spike; + #[cfg(test)] mod tests { // -- Pretty mode type accessibility -- diff --git a/crates/jacquard-codegen-tests/src/serde_spike.rs b/crates/jacquard-codegen-tests/src/serde_spike.rs new file mode 100644 index 00000000..6e701a11 --- /dev/null +++ b/crates/jacquard-codegen-tests/src/serde_spike.rs @@ -0,0 +1,705 @@ +//! Serde spike: empirical validation of serde behaviour with type-parameterised structs. +//! +//! This module answers three questions from the borrow-or-share design plan: +//! +//! 1. Does `#[serde(borrow)]` on an `S`-typed field prevent `DeserializeOwned` when `S = SmolStr`? +//! **Answer: YES.** `#[serde(borrow)]` is sugar for `#[serde(bound(deserialize = "'de: 'a"))]` +//! and requires the field type to contain a lifetime. Type params like `S` have no lifetime, +//! so the macro rejects it outright. Even if it didn't, the injected bound would prevent +//! `DeserializeOwned`. Strategy A is dead. +//! +//! 2. Does `Deserialize<'de>` work for `S = &'de str` without `#[serde(borrow)]`? +//! **Tested below** in strategies B and C. +//! +//! 3. What serde attribute combinations should codegen emit? +//! **Tested below** — strategies B (no attrs) and C (explicit bounds) are the candidates. + +use alloc::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use smol_str::SmolStr; + +// --------------------------------------------------------------------------- +// Minimal Bos/BorrowOrShare trait copies (will live in jacquard-common later) +// --------------------------------------------------------------------------- + +mod bos { + mod internal { + pub trait Ref { + fn cast<'a>(self) -> &'a T + where + Self: 'a; + } + + impl Ref for &T { + #[inline] + fn cast<'a>(self) -> &'a T + where + Self: 'a, + { + self + } + } + } + + use alloc::borrow::ToOwned; + + use internal::Ref; + + /// Borrow or share — the base trait with a GAT for the reference type. + pub trait Bos { + type Ref<'this>: Ref + where + Self: 'this; + + fn borrow_or_share(this: &Self) -> Self::Ref<'_>; + } + + /// Convenience trait with split lifetimes for borrowed vs shared access. + pub trait BorrowOrShare<'i, 'o, T: ?Sized>: Bos { + fn borrow_or_share(&'i self) -> &'o T; + } + + impl<'i, 'o, T: ?Sized, B> BorrowOrShare<'i, 'o, T> for B + where + B: Bos + ?Sized + 'i, + B::Ref<'i>: 'o, + { + #[inline] + fn borrow_or_share(&'i self) -> &'o T { + (B::borrow_or_share(self) as B::Ref<'i>).cast() + } + } + + // --- Implementations --- + + impl<'a, T: ?Sized> Bos for &'a T { + type Ref<'this> + = &'a T + where + Self: 'this; + + #[inline] + fn borrow_or_share(this: &Self) -> Self::Ref<'_> { + this + } + } + + impl Bos for smol_str::SmolStr { + type Ref<'this> = &'this str; + + #[inline] + fn borrow_or_share(this: &Self) -> Self::Ref<'_> { + this.as_str() + } + } + + impl Bos for String { + type Ref<'this> = &'this str; + + #[inline] + fn borrow_or_share(this: &Self) -> Self::Ref<'_> { + this.as_str() + } + } + + impl<'a, B: ?Sized + ToOwned> Bos for alloc::borrow::Cow<'a, B> { + type Ref<'this> + = &'this B + where + Self: 'this; + + #[inline] + fn borrow_or_share(this: &Self) -> Self::Ref<'_> { + this.as_ref() + } + } + + impl<'a> Bos for jacquard_common::cowstr::CowStr<'a> { + type Ref<'this> + = &'this str + where + Self: 'this; + + #[inline] + fn borrow_or_share(this: &Self) -> Self::Ref<'_> { + this.as_str() + } + } +} + +use bos::Bos; + +// --------------------------------------------------------------------------- +// Strategy B: no serde attributes at all — let serde derive infer everything +// --------------------------------------------------------------------------- + +/// Flat struct with no serde annotations on fields. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FlatNoBorrow = SmolStr> { + pub name: S, + pub label: Option, + pub tags: Vec, +} + +/// Nested struct containing `FlatNoBorrow`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct NestedNoBorrow = SmolStr> { + pub inner: FlatNoBorrow, + pub count: u32, +} + +/// Struct with `BTreeMap` — mixed ownership (keys always SmolStr). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WithMapNoBorrow = SmolStr> { + pub title: S, + pub metadata: BTreeMap, +} + +// --------------------------------------------------------------------------- +// Strategy C: explicit #[serde(bound(...))] — override serde's inferred bounds +// --------------------------------------------------------------------------- + +/// Flat struct with explicit serde bounds. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(bound(serialize = "S: Serialize", deserialize = "S: Deserialize<'de>"))] +pub struct FlatExplicitBound = SmolStr> { + pub name: S, + pub label: Option, + pub tags: Vec, +} + +/// Nested struct with explicit serde bounds. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(bound(serialize = "S: Serialize", deserialize = "S: Deserialize<'de>"))] +pub struct NestedExplicitBound = SmolStr> { + pub inner: FlatExplicitBound, + pub count: u32, +} + +/// Map struct with explicit serde bounds. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(bound(serialize = "S: Serialize", deserialize = "S: Deserialize<'de>"))] +pub struct WithMapExplicitBound = SmolStr> { + pub title: S, + pub metadata: BTreeMap, +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use jacquard_common::cowstr::CowStr; + use serde::de::DeserializeOwned; + + const TEST_JSON: &str = r#"{ + "name": "alice", + "label": "admin", + "tags": ["rust", "atproto"] + }"#; + + const TEST_NESTED_JSON: &str = r#"{ + "inner": { + "name": "alice", + "label": "admin", + "tags": ["rust", "atproto"] + }, + "count": 42 + }"#; + + const TEST_MAP_JSON: &str = r#"{ + "title": "hello", + "metadata": { + "key1": "val1", + "key2": "val2" + } + }"#; + + // ----------------------------------------------------------------------- + // Compile-time assertions + // ----------------------------------------------------------------------- + + fn assert_deserialize_owned() {} + fn assert_deserialize<'de, T: Deserialize<'de>>() {} + + // ===== Strategy B: no attributes ===== + + #[test] + fn strategy_b_smolstr_deserialize_owned() { + assert_deserialize_owned::>(); + assert_deserialize_owned::>(); + assert_deserialize_owned::>(); + } + + #[test] + fn strategy_b_string_deserialize_owned() { + assert_deserialize_owned::>(); + assert_deserialize_owned::>(); + assert_deserialize_owned::>(); + } + + #[test] + fn strategy_b_borrowed_deserialize() { + // Does &str satisfy Deserialize<'de> via strategy B (no attrs)? + assert_deserialize::>(); + assert_deserialize::>(); + assert_deserialize::>(); + } + + // CowStr compile-time shape tests. + // + // We can't use assert_deserialize/assert_deserialize_owned for CowStr because: + // - CowStr<'static> does NOT satisfy DeserializeOwned (the Deserialize impl + // has 'de: 'a, and Rust can't specialise that away when 'a = 'static) + // - CowStr<'_> with an elided lifetime can't relate to the 'de on the helper + // + // Instead we prove the shape compiles by writing functions with the right + // lifetime relationship. The runtime tests below exercise actual behaviour. + + #[allow(dead_code)] + fn cowstr_deserialize_shape_b(input: &str) -> FlatNoBorrow> { + serde_json::from_str(input).unwrap() + } + + #[allow(dead_code)] + fn cowstr_nested_deserialize_shape_b(input: &str) -> NestedNoBorrow> { + serde_json::from_str(input).unwrap() + } + + // ===== Strategy C: explicit bounds ===== + + #[test] + fn strategy_c_smolstr_deserialize_owned() { + assert_deserialize_owned::>(); + assert_deserialize_owned::>(); + assert_deserialize_owned::>(); + } + + #[test] + fn strategy_c_string_deserialize_owned() { + assert_deserialize_owned::>(); + assert_deserialize_owned::>(); + assert_deserialize_owned::>(); + } + + #[test] + fn strategy_c_borrowed_deserialize() { + assert_deserialize::>(); + assert_deserialize::>(); + assert_deserialize::>(); + } + + // CowStr shape tests for strategy C (same limitation as B). + + #[allow(dead_code)] + fn cowstr_deserialize_shape_c(input: &str) -> FlatExplicitBound> { + serde_json::from_str(input).unwrap() + } + + #[allow(dead_code)] + fn cowstr_nested_deserialize_shape_c(input: &str) -> NestedExplicitBound> { + serde_json::from_str(input).unwrap() + } + + // ----------------------------------------------------------------------- + // Runtime: JSON roundtrips — Strategy B + // ----------------------------------------------------------------------- + + #[test] + fn strategy_b_json_roundtrip_flat_smolstr() { + let parsed: FlatNoBorrow = serde_json::from_str(TEST_JSON).unwrap(); + assert_eq!(parsed.name, SmolStr::new("alice")); + assert_eq!(parsed.label, Some(SmolStr::new("admin"))); + assert_eq!( + parsed.tags, + vec![SmolStr::new("rust"), SmolStr::new("atproto")] + ); + + let json = serde_json::to_string(&parsed).unwrap(); + let reparsed: FlatNoBorrow = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, reparsed); + } + + #[test] + fn strategy_b_json_roundtrip_nested_smolstr() { + let parsed: NestedNoBorrow = serde_json::from_str(TEST_NESTED_JSON).unwrap(); + assert_eq!(parsed.inner.name, SmolStr::new("alice")); + assert_eq!(parsed.count, 42); + + let json = serde_json::to_string(&parsed).unwrap(); + let reparsed: NestedNoBorrow = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, reparsed); + } + + #[test] + fn strategy_b_json_roundtrip_map_smolstr() { + let parsed: WithMapNoBorrow = serde_json::from_str(TEST_MAP_JSON).unwrap(); + assert_eq!(parsed.title, SmolStr::new("hello")); + assert_eq!( + parsed.metadata.get(&SmolStr::new("key1")), + Some(&SmolStr::new("val1")) + ); + + let json = serde_json::to_string(&parsed).unwrap(); + let reparsed: WithMapNoBorrow = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, reparsed); + } + + #[test] + fn strategy_b_json_roundtrip_flat_string() { + let parsed: FlatNoBorrow = serde_json::from_str(TEST_JSON).unwrap(); + assert_eq!(parsed.name, "alice"); + + let json = serde_json::to_string(&parsed).unwrap(); + let reparsed: FlatNoBorrow = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, reparsed); + } + + #[test] + fn strategy_b_json_borrowed_flat() { + let parsed: FlatNoBorrow<&str> = serde_json::from_str(TEST_JSON).unwrap(); + assert_eq!(parsed.name, "alice"); + assert_eq!(parsed.label, Some("admin")); + assert_eq!(parsed.tags, vec!["rust", "atproto"]); + } + + #[test] + fn strategy_b_json_borrowed_nested() { + let parsed: NestedNoBorrow<&str> = serde_json::from_str(TEST_NESTED_JSON).unwrap(); + assert_eq!(parsed.inner.name, "alice"); + assert_eq!(parsed.count, 42); + } + + #[test] + fn strategy_b_json_borrowed_map() { + let parsed: WithMapNoBorrow<&str> = serde_json::from_str(TEST_MAP_JSON).unwrap(); + assert_eq!(parsed.title, "hello"); + assert_eq!(parsed.metadata.get(&SmolStr::new("key1")), Some(&"val1")); + } + + #[test] + fn strategy_b_json_cowstr() { + let parsed: FlatNoBorrow = serde_json::from_str(TEST_JSON).unwrap(); + assert_eq!(parsed.name.as_str(), "alice"); + assert_eq!(parsed.label.as_ref().map(|c| c.as_str()), Some("admin")); + + let json = serde_json::to_string(&parsed).unwrap(); + let reparsed: FlatNoBorrow = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, reparsed); + } + + // ----------------------------------------------------------------------- + // Runtime: JSON roundtrips — Strategy C + // ----------------------------------------------------------------------- + + #[test] + fn strategy_c_json_roundtrip_flat_smolstr() { + let parsed: FlatExplicitBound = serde_json::from_str(TEST_JSON).unwrap(); + assert_eq!(parsed.name, SmolStr::new("alice")); + + let json = serde_json::to_string(&parsed).unwrap(); + let reparsed: FlatExplicitBound = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, reparsed); + } + + #[test] + fn strategy_c_json_roundtrip_nested_smolstr() { + let parsed: NestedExplicitBound = serde_json::from_str(TEST_NESTED_JSON).unwrap(); + assert_eq!(parsed.inner.name, SmolStr::new("alice")); + assert_eq!(parsed.count, 42); + + let json = serde_json::to_string(&parsed).unwrap(); + let reparsed: NestedExplicitBound = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, reparsed); + } + + #[test] + fn strategy_c_json_borrowed_flat() { + let parsed: FlatExplicitBound<&str> = serde_json::from_str(TEST_JSON).unwrap(); + assert_eq!(parsed.name, "alice"); + assert_eq!(parsed.label, Some("admin")); + assert_eq!(parsed.tags, vec!["rust", "atproto"]); + } + + #[test] + fn strategy_c_json_borrowed_nested() { + let parsed: NestedExplicitBound<&str> = serde_json::from_str(TEST_NESTED_JSON).unwrap(); + assert_eq!(parsed.inner.name, "alice"); + assert_eq!(parsed.count, 42); + } + + #[test] + fn strategy_c_json_cowstr() { + let parsed: FlatExplicitBound = serde_json::from_str(TEST_JSON).unwrap(); + assert_eq!(parsed.name.as_str(), "alice"); + + let json = serde_json::to_string(&parsed).unwrap(); + let reparsed: FlatExplicitBound = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, reparsed); + } + + // ----------------------------------------------------------------------- + // DAG-CBOR roundtrips — Strategy B (if JSON works, CBOR should too) + // ----------------------------------------------------------------------- + + #[test] + fn strategy_b_dagcbor_roundtrip_flat_smolstr() { + let original = FlatNoBorrow { + name: SmolStr::new("alice"), + label: Some(SmolStr::new("admin")), + tags: vec![SmolStr::new("rust"), SmolStr::new("atproto")], + }; + + let bytes = serde_ipld_dagcbor::to_vec(&original).unwrap(); + let parsed: FlatNoBorrow = serde_ipld_dagcbor::from_slice(&bytes).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn strategy_b_dagcbor_roundtrip_flat_string() { + let original = FlatNoBorrow { + name: String::from("alice"), + label: Some(String::from("admin")), + tags: vec![String::from("rust"), String::from("atproto")], + }; + + let bytes = serde_ipld_dagcbor::to_vec(&original).unwrap(); + let parsed: FlatNoBorrow = serde_ipld_dagcbor::from_slice(&bytes).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn strategy_b_dagcbor_roundtrip_nested_smolstr() { + let original = NestedNoBorrow { + inner: FlatNoBorrow { + name: SmolStr::new("bob"), + label: None, + tags: vec![], + }, + count: 99, + }; + + let bytes = serde_ipld_dagcbor::to_vec(&original).unwrap(); + let parsed: NestedNoBorrow = serde_ipld_dagcbor::from_slice(&bytes).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn strategy_b_dagcbor_borrowed_flat() { + // DAG-CBOR stores strings as CBOR text strings. Whether borrowed + // deserialization works depends on whether the deserializer calls + // visit_borrowed_str. This test documents the actual behaviour. + let original = FlatNoBorrow { + name: SmolStr::new("alice"), + label: Some(SmolStr::new("admin")), + tags: vec![SmolStr::new("rust")], + }; + + let bytes = serde_ipld_dagcbor::to_vec(&original).unwrap(); + let result: Result, _> = serde_ipld_dagcbor::from_slice(&bytes); + + if let Ok(parsed) = &result { + assert_eq!(parsed.name, "alice"); + } + + // Document the finding regardless of outcome. + eprintln!( + "dagcbor borrowed &str deserialization: {}", + if result.is_ok() { + "WORKS" + } else { + "FAILS (expected — CBOR deserializer may not support borrowing)" + } + ); + } + + // ----------------------------------------------------------------------- + // DAG-CBOR — Strategy C + // ----------------------------------------------------------------------- + + #[test] + fn strategy_c_dagcbor_roundtrip_flat_smolstr() { + let original = FlatExplicitBound { + name: SmolStr::new("alice"), + label: Some(SmolStr::new("admin")), + tags: vec![SmolStr::new("rust"), SmolStr::new("atproto")], + }; + + let bytes = serde_ipld_dagcbor::to_vec(&original).unwrap(); + let parsed: FlatExplicitBound = serde_ipld_dagcbor::from_slice(&bytes).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn strategy_c_dagcbor_roundtrip_nested_smolstr() { + let original = NestedExplicitBound { + inner: FlatExplicitBound { + name: SmolStr::new("bob"), + label: None, + tags: vec![], + }, + count: 99, + }; + + let bytes = serde_ipld_dagcbor::to_vec(&original).unwrap(); + let parsed: NestedExplicitBound = serde_ipld_dagcbor::from_slice(&bytes).unwrap(); + assert_eq!(original, parsed); + } + + // ----------------------------------------------------------------------- + // Zero-copy verification: prove borrowed &str points into the input buffer + // ----------------------------------------------------------------------- + + /// Returns true if `s` points into the memory range of `buf`. + fn points_into(s: &str, buf: &str) -> bool { + let buf_start = buf.as_ptr() as usize; + let buf_end = buf_start + buf.len(); + let s_start = s.as_ptr() as usize; + s_start >= buf_start && s_start + s.len() <= buf_end + } + + /// Same as above but for byte slices. + fn points_into_bytes(s: &str, buf: &[u8]) -> bool { + let buf_start = buf.as_ptr() as usize; + let buf_end = buf_start + buf.len(); + let s_start = s.as_ptr() as usize; + s_start >= buf_start && s_start + s.len() <= buf_end + } + + #[test] + fn json_borrowed_str_is_zero_copy() { + let input = r#"{"name":"alice","label":"admin","tags":["rust","atproto"]}"#; + let parsed: FlatNoBorrow<&str> = serde_json::from_str(input).unwrap(); + + assert!( + points_into(parsed.name, input), + "name should point into input buffer" + ); + assert!( + points_into(parsed.label.unwrap(), input), + "label should point into input buffer" + ); + for tag in &parsed.tags { + assert!( + points_into(tag, input), + "tag {:?} should point into input buffer", + tag + ); + } + } + + #[test] + fn dagcbor_borrowed_str_is_zero_copy() { + let original = FlatNoBorrow { + name: SmolStr::new("alice"), + label: Some(SmolStr::new("admin")), + tags: vec![SmolStr::new("rust")], + }; + + let bytes = serde_ipld_dagcbor::to_vec(&original).unwrap(); + let parsed: FlatNoBorrow<&str> = serde_ipld_dagcbor::from_slice(&bytes).unwrap(); + + assert!( + points_into_bytes(parsed.name, &bytes), + "name should point into CBOR buffer" + ); + assert!( + points_into_bytes(parsed.label.unwrap(), &bytes), + "label should point into CBOR buffer" + ); + for tag in &parsed.tags { + assert!( + points_into_bytes(tag, &bytes), + "tag {:?} should point into CBOR buffer", + tag + ); + } + } + + #[test] + fn json_cowstr_borrows_from_input() { + // CowStr's Deserialize impl calls visit_borrowed_str -> CowStr::Borrowed, + // so when deserializing from &str the result should be zero-copy. + let input = r#"{"name":"alice","label":"admin","tags":["rust","atproto"]}"#; + let parsed: FlatNoBorrow = serde_json::from_str(input).unwrap(); + + assert!( + matches!(parsed.name, CowStr::Borrowed(_)), + "name should be CowStr::Borrowed, got Owned" + ); + assert!( + points_into(parsed.name.as_str(), input), + "name should point into input buffer" + ); + + let label = parsed.label.unwrap(); + assert!( + matches!(label, CowStr::Borrowed(_)), + "label should be CowStr::Borrowed, got Owned" + ); + assert!( + points_into(label.as_str(), input), + "label should point into input buffer" + ); + + for tag in &parsed.tags { + assert!( + matches!(tag, CowStr::Borrowed(_)), + "tag {:?} should be CowStr::Borrowed, got Owned", + tag.as_str() + ); + assert!( + points_into(tag.as_str(), input), + "tag {:?} should point into input buffer", + tag.as_str() + ); + } + } + + #[test] + fn dagcbor_cowstr_borrows_from_buffer() { + let original = FlatNoBorrow { + name: SmolStr::new("alice"), + label: Some(SmolStr::new("admin")), + tags: vec![SmolStr::new("rust")], + }; + + let bytes = serde_ipld_dagcbor::to_vec(&original).unwrap(); + let parsed: FlatNoBorrow = serde_ipld_dagcbor::from_slice(&bytes).unwrap(); + + assert!( + matches!(parsed.name, CowStr::Borrowed(_)), + "name should be CowStr::Borrowed, got Owned" + ); + assert!( + points_into_bytes(parsed.name.as_str(), &bytes), + "name should point into CBOR buffer" + ); + + let label = parsed.label.unwrap(); + assert!( + matches!(label, CowStr::Borrowed(_)), + "label should be CowStr::Borrowed, got Owned" + ); + assert!( + points_into_bytes(label.as_str(), &bytes), + "label should point into CBOR buffer" + ); + + for tag in &parsed.tags { + assert!( + matches!(tag, CowStr::Borrowed(_)), + "tag {:?} should be CowStr::Borrowed, got Owned", + tag.as_str() + ); + assert!( + points_into_bytes(tag.as_str(), &bytes), + "tag {:?} should point into CBOR buffer", + tag.as_str() + ); + } + } +} diff --git a/crates/jacquard-common/src/bos.rs b/crates/jacquard-common/src/bos.rs new file mode 100644 index 00000000..e23263ce --- /dev/null +++ b/crates/jacquard-common/src/bos.rs @@ -0,0 +1,264 @@ +//! Borrow-or-share traits for abstracting over owned and borrowed string representations. +//! +//! This module is a vendored copy of the [`borrow-or-share`](https://docs.rs/borrow-or-share/0.2.4/) +//! crate by yescallop, with additional implementations for [`SmolStr`](smol_str::SmolStr) +//! and [`CowStr`](crate::CowStr). We vendor rather than depend on the crate to avoid +//! orphan rule issues. We need to implement `Bos` for `SmolStr` and `CowStr`, which +//! are foreign types relative to the upstream crate. +//! +//! # Overview +//! +//! [`Bos`] is the base trait providing a GAT for the reference type. Use it as a bound +//! when you need a method that borrows from `*self` regardless of whether the backing type +//! is owned or borrowed: +//! +//! ```ignore +//! impl> AsRef for MyType { +//! fn as_ref(&self) -> &str { +//! self.as_str() +//! } +//! } +//! ``` +//! +//! [`BorrowOrShare<'i, 'o, T>`] is the convenience trait with split lifetimes. Use it when +//! you want a method on `&'i self` that returns `&'o T`, where `'o` may outlive `'i` when +//! the backing type is a reference: +//! +//! ```ignore +//! impl<'i, 'o, T: BorrowOrShare<'i, 'o, str>> MyType { +//! fn as_str(&'i self) -> &'o str { +//! self.0.borrow_or_share() +//! } +//! } +//! ``` + +use alloc::{ + borrow::{Cow, ToOwned}, + boxed::Box, + string::String, + vec::Vec, +}; + +use smol_str::SmolStr; + +use crate::CowStr; + +mod internal { + pub trait Ref { + fn cast<'a>(self) -> &'a T + where + Self: 'a; + } + + impl Ref for &T { + #[inline] + fn cast<'a>(self) -> &'a T + where + Self: 'a, + { + self + } + } +} + +use internal::Ref; + +/// A trait for either borrowing or sharing data. +/// +/// See the [module-level documentation](self) for more details. +pub trait Bos { + /// The resulting reference type. May only be `&T`. + type Ref<'this>: Ref + where + Self: 'this; + + /// Borrows from `*this` or from behind a reference it holds, + /// returning a reference of type [`Self::Ref`]. + /// + /// In the latter case, the returned reference is said to be *shared* with `*this`. + fn borrow_or_share(this: &Self) -> Self::Ref<'_>; +} + +/// A helper trait for writing "data borrowing or sharing" functions. +/// +/// See the [module-level documentation](self) for more details. +pub trait BorrowOrShare<'i, 'o, T: ?Sized>: Bos { + /// Borrows from `*self` or from behind a reference it holds. + /// + /// In the latter case, the returned reference is said to be *shared* with `*self`. + fn borrow_or_share(&'i self) -> &'o T; +} + +impl<'i, 'o, T: ?Sized, B> BorrowOrShare<'i, 'o, T> for B +where + B: Bos + ?Sized + 'i, + B::Ref<'i>: 'o, +{ + #[inline] + fn borrow_or_share(&'i self) -> &'o T { + (B::borrow_or_share(self) as B::Ref<'i>).cast() + } +} + +// --- Reference impl (sharing) --- + +impl<'a, T: ?Sized> Bos for &'a T { + type Ref<'this> + = &'a T + where + Self: 'this; + + #[inline] + fn borrow_or_share(this: &Self) -> Self::Ref<'_> { + this + } +} + +// --- Macro for borrowing impls --- + +/// Implement [`Bos`] for types that always borrow from `*self`. +/// +/// Each entry maps a concrete type to the target slice/str type it derefs to. +/// The generated impl uses `Ref<'this> = &'this $target` — pure borrowing, no sharing. +#[macro_export] +macro_rules! impl_bos { + ($($(#[$attr:meta])? $({$($params:tt)*})? $ty:ty => $target:ty)*) => { + $( + $(#[$attr])? + impl $(<$($params)*>)? $crate::bos::Bos<$target> for $ty { + type Ref<'this> = &'this $target where Self: 'this; + + #[inline] + fn borrow_or_share(this: &Self) -> Self::Ref<'_> { + this + } + } + )* + }; +} + +// --- Standard library impls --- + +impl_bos! { + {T: ?Sized} &mut T => T + + {T, const N: usize} [T; N] => [T] + + {T} Vec => [T] + + String => str + + {T: ?Sized} Box => T + {B: ?Sized + ToOwned} Cow<'_, B> => B + + {T: ?Sized} alloc::sync::Arc => T + {T: ?Sized} alloc::rc::Rc => T +} + +#[cfg(feature = "std")] +impl_bos! { + std::ffi::OsString => std::ffi::OsStr + std::path::PathBuf => std::path::Path + alloc::ffi::CString => core::ffi::CStr +} + +// --- SmolStr impl --- + +impl_bos! { + SmolStr => str +} + +// --- CowStr impl --- + +impl<'a> Bos for CowStr<'a> { + type Ref<'this> + = &'this str + where + Self: 'this; + + #[inline] + fn borrow_or_share(this: &Self) -> Self::Ref<'_> { + this.as_str() + } +} + +/// The default string backing type for jacquard's type-parameterised types. +/// +/// `SmolStr` is used as the default because it satisfies `DeserializeOwned` (no lifetime +/// annotation required) and provides small-string inline storage without heap allocation +/// for strings of 22 bytes or fewer. +pub type DefaultStr = SmolStr; + +#[cfg(test)] +mod tests { + use super::*; + + // Verify BorrowOrShare works for all backing types. + + fn as_str_via_bos<'i, 'o, S: BorrowOrShare<'i, 'o, str>>(s: &'i S) -> &'o str { + s.borrow_or_share() + } + + #[test] + fn bos_smolstr() { + let s = SmolStr::new("hello"); + assert_eq!(as_str_via_bos(&s), "hello"); + } + + #[test] + fn bos_string() { + let s = String::from("hello"); + assert_eq!(as_str_via_bos(&s), "hello"); + } + + #[test] + fn bos_ref_str() { + let s: &str = "hello"; + assert_eq!(as_str_via_bos(&s), "hello"); + } + + #[test] + fn bos_cowstr_borrowed() { + let s = CowStr::Borrowed("hello"); + assert_eq!(as_str_via_bos(&s), "hello"); + } + + #[test] + fn bos_cowstr_owned() { + let s = CowStr::Owned(SmolStr::new("hello")); + assert_eq!(as_str_via_bos(&s), "hello"); + } + + // Verify Bos (non-sharing) works via AsRef-style usage. + + fn as_ref_via_bos>(s: &S) -> &str { + let r = S::borrow_or_share(s); + r.cast() + } + + #[test] + fn bos_as_ref_smolstr() { + let s = SmolStr::new("world"); + assert_eq!(as_ref_via_bos(&s), "world"); + } + + #[test] + fn bos_as_ref_ref_str() { + let s: &str = "world"; + assert_eq!(as_ref_via_bos(&s), "world"); + } + + // Verify sharing semantics: &str reference outlives the wrapper. + + #[test] + fn ref_str_sharing_outlives_wrapper() { + let original: &str = "shared"; + let result: &str; + { + let wrapper: &&str = &original; + result = as_str_via_bos(wrapper); + } + // result outlives wrapper because &str shares, not borrows. + assert_eq!(result, "shared"); + } +} diff --git a/crates/jacquard-common/src/cowstr.rs b/crates/jacquard-common/src/cowstr.rs index ed78b0fc..33218216 100644 --- a/crates/jacquard-common/src/cowstr.rs +++ b/crates/jacquard-common/src/cowstr.rs @@ -263,6 +263,7 @@ impl fmt::Display for CowStr<'_> { } } +// TODO(bos-migration): Change Output to SmolStr once types are parameterised by S: Bos. impl IntoStatic for CowStr<'_> { type Output = CowStr<'static>; diff --git a/crates/jacquard-common/src/into_static.rs b/crates/jacquard-common/src/into_static.rs index 99b6f414..46b23589 100644 --- a/crates/jacquard-common/src/into_static.rs +++ b/crates/jacquard-common/src/into_static.rs @@ -101,6 +101,15 @@ impl_into_static_passthru!( crate::deps::smol_str::SmolStr ); +impl IntoStatic for &str { + type Output = crate::deps::smol_str::SmolStr; + + #[inline] + fn into_static(self) -> Self::Output { + crate::deps::smol_str::SmolStr::new(self) + } +} + impl IntoStatic for Box { type Output = Box; diff --git a/crates/jacquard-common/src/lib.rs b/crates/jacquard-common/src/lib.rs index e8fb5a39..3d050eea 100644 --- a/crates/jacquard-common/src/lib.rs +++ b/crates/jacquard-common/src/lib.rs @@ -214,6 +214,7 @@ pub use spin::Lazy; pub use cowstr::CowStr; pub use into_static::IntoStatic; +pub use bos::{Bos, BorrowOrShare, DefaultStr}; /// A copy-on-write immutable string type that uses [`smol_str::SmolStr`] for /// the "owned" variant. @@ -222,6 +223,9 @@ pub mod cowstr; #[macro_use] /// Trait for taking ownership of most borrowed types in jacquard. pub mod into_static; +/// Borrow-or-share traits for abstracting over owned and borrowed string representations. +#[macro_use] +pub mod bos; /// Re-exports of external crate dependencies for consistent access across jacquard. pub mod deps; pub mod error;