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
76 changes: 76 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ serde = { version = "1", default-features = false }
serde_json = { version = "1", default-features = false, features = ["alloc"] }
arbitrary = { version = "1", default-features = false, features = ["derive"] }
thiserror = { version = "2", default-features = false }
# Optional `string` field representations that will be selectable via
# `buffa_build`'s forthcoming `string_type` knob. Declared
# `default-features = false` so they stay `no_std`; the relevant
# `serde`/`arbitrary`/`std` sub-features are turned on by the matching `buffa`
# feature (see buffa/Cargo.toml).
smol_str = { version = "0.3", default-features = false }
ecow = { version = "0.2", default-features = false }
compact_str = { version = "0.9", default-features = false }
prettyplease = "0.2"
proc-macro2 = "1"
quote = "1"
Expand Down
37 changes: 34 additions & 3 deletions buffa/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,42 @@ rustdoc-args = ["--cfg", "docsrs"]

[features]
default = ["std"]
std = ["bytes/std", "thiserror/std", "serde?/std", "serde_json?/std"]
std = [
"bytes/std",
"thiserror/std",
"serde?/std",
"serde_json?/std",
"smol_str?/std",
"ecow?/std",
"compact_str?/std",
]
# `json` and `text` are independently enableable. Both share the
# `type_registry` module (gated `any(json, text)`) but carry per-format
# entry types (`JsonAnyEntry` vs `TextAnyEntry`) — no struct-literal `#[cfg]`
# needed, no `Option<fn>` placeholder fields.
json = ["dep:serde", "dep:base64", "dep:serde_json", "serde/alloc", "serde/derive", "hashbrown/serde"]
arbitrary = ["dep:arbitrary"]
json = [
"dep:serde",
"dep:base64",
"dep:serde_json",
"serde/alloc",
"serde/derive",
"hashbrown/serde",
# Pull in serde support for whichever configurable string types are enabled.
"smol_str?/serde",
"ecow?/serde",
"compact_str?/serde",
]
arbitrary = ["dep:arbitrary", "smol_str?/arbitrary", "compact_str?/arbitrary"]
text = []
# Configurable `string` field representations. Enabling one of these will let
# `buffa_build`'s forthcoming `string_type` knob map proto `string` to that
# type; buffa re-exports the crate so generated code references
# `::buffa::<crate>::<Type>` and downstream crates do not declare the dependency
# themselves. `ecow` has no native `arbitrary` impl, so buffa supplies a shim
# under the `arbitrary` feature.
smol_str = ["dep:smol_str"]
ecow = ["dep:ecow"]
compact_str = ["dep:compact_str"]

[dependencies]
arbitrary = { workspace = true, optional = true }
Expand All @@ -34,3 +62,6 @@ once_cell = { workspace = true, features = ["alloc"] }
serde = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
thiserror = { workspace = true }
smol_str = { workspace = true, optional = true }
ecow = { workspace = true, optional = true }
compact_str = { workspace = true, optional = true }
64 changes: 50 additions & 14 deletions buffa/src/json_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,31 +334,56 @@ pub mod proto_bool {
///
/// Use with `#[serde(with = "::buffa::json_helpers::proto_string")]`.
pub mod proto_string {
use alloc::string::ToString;
use serde::{Deserializer, Serializer};

pub fn serialize<S: Serializer>(value: &str, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(value)
/// Serialize a `string` field.
///
/// Generic over `T: AsRef<str>` so configurable string types
/// (`smol_str::SmolStr`, `ecow::EcoString`, ...) serialize without relying
/// on `Deref<Target = str>` coercion at the `#[serde(with = ...)]` call
/// site. `String` and `&str` both satisfy the bound.
pub fn serialize<T: AsRef<str> + ?Sized, S: Serializer>(
value: &T,
s: S,
) -> Result<S::Ok, S::Error> {
s.serialize_str(value.as_ref())
}

pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<alloc::string::String, D::Error> {
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = alloc::string::String;
/// Deserialize a `string` field (or JSON `null` → `""`).
///
/// Generic over the return type so that codegen's `string_type` knob (which
/// can map the field to `smol_str::SmolStr`, `ecow::EcoString`, etc.) works
/// without a per-type shim. The visitor constructs the target type directly:
/// `visit_str` goes through `From<&str>`, so a short string is inlined by an
/// SSO type without first allocating an intermediate `String`. `String`
/// itself satisfies both `From<&str>` and `From<String>`, keeping the
/// default path zero-extra-cost. Type inference picks `T` from the field
/// type at the serde call site.
pub fn deserialize<'de, T, D>(d: D) -> Result<T, D::Error>
where
T: for<'a> From<&'a str> + From<alloc::string::String>,
D: Deserializer<'de>,
{
struct V<T>(core::marker::PhantomData<T>);
impl<'de, T> serde::de::Visitor<'de> for V<T>
where
T: for<'a> From<&'a str> + From<alloc::string::String>,
{
type Value = T;
fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("a string or null")
}
fn visit_unit<E>(self) -> Result<alloc::string::String, E> {
Ok(alloc::string::String::new())
fn visit_unit<E>(self) -> Result<T, E> {
Ok(T::from(""))
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<alloc::string::String, E> {
Ok(v.to_string())
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<T, E> {
Ok(T::from(v))
}
fn visit_string<E>(self, v: alloc::string::String) -> Result<alloc::string::String, E> {
Ok(v)
fn visit_string<E>(self, v: alloc::string::String) -> Result<T, E> {
Ok(T::from(v))
}
}
d.deserialize_any(V)
d.deserialize_any(V::<T>(core::marker::PhantomData))
}
}

Expand Down Expand Up @@ -1132,6 +1157,17 @@ proto_elem_json_delegate!(bool, proto_bool);
proto_elem_json_delegate!(alloc::string::String, proto_string);
proto_elem_json_delegate!(alloc::vec::Vec<u8>, bytes);

// Configurable `string` field representations (codegen's `string_type()`), for
// `repeated string` / `map<_, string>`. The `proto_string` with-module is
// generic — `serialize` over `AsRef<str>`, `deserialize` over `From<String>` —
// so each delegate is a one-liner with no per-type shim.
#[cfg(feature = "smol_str")]
proto_elem_json_delegate!(::smol_str::SmolStr, proto_string);
#[cfg(feature = "ecow")]
proto_elem_json_delegate!(::ecow::EcoString, proto_string);
#[cfg(feature = "compact_str")]
proto_elem_json_delegate!(::compact_str::CompactString, proto_string);

// bytes::Bytes — for codegen's `use_bytes_type()` with `repeated bytes`.
// Serialize: `Bytes: Deref<Target=[u8]>` → `bytes::serialize(&[u8], s)`.
// Deserialize: `bytes::deserialize` is generic over `T: From<Vec<u8>>`;
Expand Down
94 changes: 94 additions & 0 deletions buffa/src/json_helpers/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1227,6 +1227,100 @@ fn proto_string_null_is_empty() {
assert_eq!(v.0, "");
}

/// A stand-in for a configurable string type (`SmolStr`/`EcoString`/...): it
/// implements just the `From<String>`/`From<&str>`/`AsRef<str>` surface the
/// generic `proto_string` path relies on, proving the with-module deserializes
/// into an arbitrary target type without a per-type shim.
#[derive(Clone, PartialEq, Debug, Default)]
struct MyStr(alloc::string::String);
impl From<alloc::string::String> for MyStr {
fn from(s: alloc::string::String) -> Self {
MyStr(s)
}
}
impl From<&str> for MyStr {
fn from(s: &str) -> Self {
MyStr(s.into())
}
}
impl AsRef<str> for MyStr {
fn as_ref(&self) -> &str {
&self.0
}
}

#[derive(serde::Serialize, serde::Deserialize)]
struct SerdeCustomStr(#[serde(with = "proto_string")] MyStr);

#[test]
fn proto_string_deserializes_into_custom_type() {
let recovered: SerdeCustomStr = serde_json::from_str(r#""hello""#).unwrap();
assert_eq!(recovered.0, MyStr("hello".into()));
// null still maps to the empty value via the `From<String>` conversion.
let empty: SerdeCustomStr = serde_json::from_str("null").unwrap();
assert_eq!(empty.0, MyStr::default());
// Round-trips back out, serialized via `AsRef<str>`.
let json = serde_json::to_string(&SerdeCustomStr(MyStr("hi".into()))).unwrap();
assert_eq!(json, r#""hi""#);
}

// Configurable string types: the singular `proto_string` with-module and the
// `ProtoElemJson` (repeated) path must both round-trip through proto3 JSON.
#[cfg(feature = "smol_str")]
#[test]
fn proto_string_smol_str_roundtrip() {
#[derive(serde::Serialize, serde::Deserialize)]
struct W(#[serde(with = "proto_string")] smol_str::SmolStr);
let json = serde_json::to_string(&W("hi".into())).unwrap();
assert_eq!(json, r#""hi""#);
assert_eq!(serde_json::from_str::<W>(&json).unwrap().0, "hi");
assert_eq!(serde_json::from_str::<W>("null").unwrap().0, "");

// repeated string via ProtoElemJson
#[derive(serde::Serialize, serde::Deserialize)]
struct R(#[serde(with = "proto_seq")] alloc::vec::Vec<smol_str::SmolStr>);
let v = R(vec!["a".into(), "b".into()]);
let json = serde_json::to_string(&v).unwrap();
assert_eq!(json, r#"["a","b"]"#);
assert_eq!(serde_json::from_str::<R>(&json).unwrap().0, v.0);
}

#[cfg(feature = "ecow")]
#[test]
fn proto_string_ecow_roundtrip() {
#[derive(serde::Serialize, serde::Deserialize)]
struct W(#[serde(with = "proto_string")] ecow::EcoString);
let json = serde_json::to_string(&W("hi".into())).unwrap();
assert_eq!(json, r#""hi""#);
assert_eq!(serde_json::from_str::<W>(&json).unwrap().0, "hi");
assert_eq!(serde_json::from_str::<W>("null").unwrap().0, "");

#[derive(serde::Serialize, serde::Deserialize)]
struct R(#[serde(with = "proto_seq")] alloc::vec::Vec<ecow::EcoString>);
let v = R(vec!["a".into(), "b".into()]);
let json = serde_json::to_string(&v).unwrap();
assert_eq!(json, r#"["a","b"]"#);
assert_eq!(serde_json::from_str::<R>(&json).unwrap().0, v.0);
}

#[cfg(feature = "compact_str")]
#[test]
fn proto_string_compact_str_roundtrip() {
#[derive(serde::Serialize, serde::Deserialize)]
struct W(#[serde(with = "proto_string")] compact_str::CompactString);
let json = serde_json::to_string(&W("hi".into())).unwrap();
assert_eq!(json, r#""hi""#);
assert_eq!(serde_json::from_str::<W>(&json).unwrap().0, "hi");
assert_eq!(serde_json::from_str::<W>("null").unwrap().0, "");

#[derive(serde::Serialize, serde::Deserialize)]
struct R(#[serde(with = "proto_seq")] alloc::vec::Vec<compact_str::CompactString>);
let v = R(vec!["a".into(), "b".into()]);
let json = serde_json::to_string(&v).unwrap();
assert_eq!(json, r#"["a","b"]"#);
assert_eq!(serde_json::from_str::<R>(&json).unwrap().0, v.0);
}

// ── closed_enum tests ─────────────────────────────────────────────────

#[derive(serde::Serialize, serde::Deserialize)]
Expand Down
Loading
Loading