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
6 changes: 6 additions & 0 deletions .changes/unreleased/breaking-changes-20260914-000000.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: Breaking changes
body: |-
**`ProtoString` uses `copy_from_str(&str)` instead of requiring `for<'a> From<&'a str>`** (#442, closes #441). This lets string libraries retain borrowing `From<&str>` conversions while copying temporary JSON inputs for non-optional singular fields, and borrowed view text in view-to-owned conversions of custom-representation string fields, into owned storage. The new method defaults to `From<String>`; override it to avoid the intermediate allocation for inline/shared strings. The remote derive and `buffa-smolstr` example preserve their direct borrowed-text construction path.
A custom string type that relied on an efficient `From<&str>` (for example inline storage for short strings) and does not override `copy_from_str` now pays an intermediate `String` allocation when a view converts to an owned message (singular, optional, repeated and oneof string fields; map keys and values already went through `From<String>`) and when JSON deserialization of a non-optional singular field receives borrowed text, because the default can no longer call `From<&str>`; such a type still compiles and produces the same values. Override `copy_from_str` to restore the fast path. Types that use `#[derive(buffa_remote_derive::ProtoString)]` need no change: the derive forwards `copy_from_str` to the remote type's `From<&str>`.
Generic callers that relied on `S: ProtoString` to imply `From<&str>` should use `S::copy_from_str(value)` or add an explicit conversion bound. `json_helpers::proto_string::deserialize` now requires `ProtoString`, not just the two `From` conversions; hand-written users of that helper must implement the trait or use their own deserializer. Regenerate custom-string view code before using a representation without the old bound. Binary decoding, wire format, and default `String` code generation are unchanged.
time: 2026-09-14T00:00:00+00:00
4 changes: 2 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,9 +531,9 @@ Vtable mode is what makes reflection cheap enough to put on a hot path: reflecti

By default a `string` field generates as `String` and a `bytes` field as `Vec<u8>`, but both are pluggable: `buffa_build`'s `string_type` / `bytes_type` knobs select a different in-memory representation per field-path prefix, with no change to the wire format. The built-in alternative for `bytes` is `bytes::Bytes` (decoded zero-copy from a `Bytes`-backed buffer); beyond that, either knob accepts any type named by its Rust path — for example `smol_str::SmolStr` or `ecow::EcoString` for read-mostly string fields, or a small-buffer-optimized byte container. Views are unaffected: a `MyMessageView<'a>` always borrows `&'a str` / `&'a [u8]` regardless of the owned representation, and `map<_, string>` keys and values always stay `String`.

**The contract is a `from_wire` constructor, not a blanket impl.** `ProtoString` and `ProtoBytes` (in `buffa`) each require `fn from_wire(WirePayload<'_>) -> Result<Self, DecodeError>`, atop the supertraits `Clone + PartialEq + Default + Debug + Send + Sync`, plus `Deref<Target = str>` + `AsRef<str>` + `From<String>` + `From<&str>` for strings, and `Deref<Target = [u8]>` + `AsRef<[u8]>` + `From<Vec<u8>>` for bytes. The `Deref` bound is load-bearing rather than cosmetic: generated code borrows a field as `&str` / `&[u8]` by plain reference coercion (`&self.field`), so a representation must `Deref` to the slice type, not merely offer `AsRef`. (`ProtoBytes` deliberately omits `From<&[u8]>`, because `bytes::Bytes` implements that only for `&'static [u8]`; requiring it would exclude `Bytes` itself.) There is deliberately **no blanket impl**: a representation implements the trait (a foreign type via a local newtype), and buffa ships the built-in impls for `String`, `Vec<u8>`, and `bytes::Bytes`. The earlier design *was* a blanket impl over `From<String>` / `From<Vec<u8>>`; it was tightened to `from_wire` because the blanket path always pays an allocate-and-copy and a transient heap allocation — even for a short string an inline type could keep on the stack — so it disadvantaged every custom type.
**The contract is a `from_wire` constructor, not a blanket impl.** `ProtoString` and `ProtoBytes` (in `buffa`) each require `fn from_wire(WirePayload<'_>) -> Result<Self, DecodeError>`, atop the supertraits `Clone + PartialEq + Default + Debug + Send + Sync`, plus `Deref<Target = str>` + `AsRef<str>` + `From<String>` for strings, and `Deref<Target = [u8]>` + `AsRef<[u8]>` + `From<Vec<u8>>` for bytes. The `Deref` bound is load-bearing rather than cosmetic: generated code borrows a field as `&str` / `&[u8]` by plain reference coercion (`&self.field`), so a representation must `Deref` to the slice type, not merely offer `AsRef`. (`ProtoBytes` deliberately omits `From<&[u8]>`, because `bytes::Bytes` implements that only for `&'static [u8]`; requiring it would exclude `Bytes` itself.) There is deliberately **no blanket impl**: a representation implements the trait (a foreign type via a local newtype), and buffa ships the built-in impls for `String`, `Vec<u8>`, and `bytes::Bytes`. The earlier design *was* a blanket impl over `From<String>` / `From<Vec<u8>>`; it was tightened to `from_wire` because the blanket path always pays an allocate-and-copy and a transient heap allocation — even for a short string an inline type could keep on the stack — so it disadvantaged every custom type.

**`from_wire` owns validation and borrow-vs-own; the defaults keep their specialized paths.** `WirePayload` is the decoder's gift to the representation: an opaque struct holding the field's bytes either borrowed from the input chunk (the common case for slice- and `Bytes`-backed sources) or owned as `Bytes` otherwise, exposing `as_slice()` (zero-copy borrow), `to_str()` (UTF-8-validated borrow, slack-aware on the borrowed path), `into_bytes()` (zero-copy when owned), and `is_owned()` to branch on which is free. So an inline string type validates via `to_str()` and stores it without a heap allocation, and a refcounted bytes type takes ownership with `into_bytes()`. `decode_string_to::<S>()` / `decode_bytes_to::<B>()` read one length-delimited field, build the payload, and call `S::from_wire` / `B::from_wire`. The default representations keep their hand-tuned fast paths: `String` reuses its buffer in place via `merge_string`, `Vec<u8>` via `merge_bytes` (`bytes::Bytes` and every other type go through `from_wire`). Clearing a field resets it to `Default::default()` rather than `.clear()`, because a substituted type may be immutable. The `From<String>` / `From<&str>` supertraits remain, serving the JSON, text-format, and view→owned paths. The `arbitrary` support is type-agnostic: codegen attaches a generic builder (`arbitrary_proto_string::<S>` / `arbitrary_proto_bytes::<B>`) that materializes the canonical `String` / `Vec<u8>` and converts via `From`, so a substituted type needs no native `Arbitrary` impl.
**`from_wire` owns validation and borrow-vs-own; the defaults keep their specialized paths.** `WirePayload` is the decoder's gift to the representation: an opaque struct holding the field's bytes either borrowed from the input chunk (the common case for slice- and `Bytes`-backed sources) or owned as `Bytes` otherwise, exposing `as_slice()` (zero-copy borrow), `to_str()` (UTF-8-validated borrow, slack-aware on the borrowed path), `into_bytes()` (zero-copy when owned), and `is_owned()` to branch on which is free. So an inline string type validates via `to_str()` and stores it without a heap allocation, and a refcounted bytes type takes ownership with `into_bytes()`. `decode_string_to::<S>()` / `decode_bytes_to::<B>()` read one length-delimited field, build the payload, and call `S::from_wire` / `B::from_wire`. The default representations keep their hand-tuned fast paths: `String` reuses its buffer in place via `merge_string`, `Vec<u8>` via `merge_bytes` (`bytes::Bytes` and every other type go through `from_wire`). Clearing a field resets it to `Default::default()` rather than `.clear()`, because a substituted type may be immutable. Owned JSON strings and text-format parsing still use `From<String>`. Borrowed JSON strings in non-optional singular fields and view→owned conversions use `ProtoString::copy_from_str(&str)`, so a string library can retain a separate borrowing `From<&str>` conversion. The copying method defaults to allocating a `String` and converting it; inline/shared-string implementations should override it to avoid this intermediate allocation. The remote derive and the `buffa-smolstr` example provide that override. The `arbitrary` support is type-agnostic: codegen attaches a generic builder (`arbitrary_proto_string::<S>` / `arbitrary_proto_bytes::<B>`) that materializes the canonical `String` / `Vec<u8>` and converts via `From`, so a substituted type needs no native `Arbitrary` impl.

A note on what is *not* yet optimal: a borrowed payload (single-chunk source — the common case) copies into an owned `Bytes` only when a representation calls `into_bytes()`, and an owned payload is currently produced only for multi-chunk sources. So a refcounted bytes representation does not yet zero-copy *share* a single `Bytes` input buffer — it is no worse than `Vec<u8>` today, and a per-type `PREFERS_OWNED_BYTES` preference is a future, additive (non-breaking) enhancement. Inline string representations already get their full win (zero heap for short strings) regardless.

Expand Down
42 changes: 13 additions & 29 deletions buffa-codegen/src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2059,21 +2059,14 @@ fn build_to_owned_fields(
///
/// The default `String` keeps the original `binding.to_string()` (which
/// auto-derefs through a `&&str` binding), so default output is byte-identical
/// to before this knob existed. Other representations build via `From<&str>`
/// with the owned field type driving `Into` inference; `Into::into` takes its
/// argument by value and does not auto-deref, so `double_ref` sites (iterator
/// and match-ergonomics bindings that are `&&str`) get one explicit `*`.
fn str_view_to_owned(
repr: crate::StringRepr,
binding: TokenStream,
double_ref: bool,
) -> TokenStream {
/// to before this knob existed. Other representations use
/// `ProtoString::copy_from_str`, with the field type driving inference.
/// Both paths auto-deref iterator and match-ergonomics `&&str` bindings.
fn str_view_to_owned(repr: crate::StringRepr, binding: TokenStream) -> TokenStream {
if repr.is_default() {
quote! { #binding.to_string() }
} else if double_ref {
quote! { ::core::convert::Into::into(*#binding) }
} else {
quote! { ::core::convert::Into::into(#binding) }
quote! { ::buffa::ProtoString::copy_from_str(#binding) }
}
}

Expand All @@ -2094,13 +2087,13 @@ pub(crate) fn singular_to_owned(
return Ok(match ty {
Type::TYPE_STRING => {
// Option<&str>::map. The default keeps `|s| s.to_string()`; for
// other reprs pass the `Into::into` fn directly rather than
// other reprs pass `ProtoString::copy_from_str` directly rather than
// wrapping it in a closure (avoids clippy::redundant_closure in
// the consumer crate). Inference picks the target from the field.
if field_string_repr(ctx, proto_fqn, field_name).is_default() {
quote! { self.#ident.map(|s| s.to_string()) }
} else {
quote! { self.#ident.map(::core::convert::Into::into) }
quote! { self.#ident.map(::buffa::ProtoString::copy_from_str) }
}
}
Type::TYPE_BYTES => {
Expand All @@ -2114,7 +2107,6 @@ pub(crate) fn singular_to_owned(
Type::TYPE_STRING => str_view_to_owned(
field_string_repr(ctx, proto_fqn, field_name),
quote! { self.#ident },
false,
),
Type::TYPE_BYTES => bytes_to_owned(ctx, proto_fqn, field_name, quote! { self.#ident }),
Type::TYPE_MESSAGE | Type::TYPE_GROUP => {
Expand Down Expand Up @@ -2150,11 +2142,8 @@ pub(crate) fn repeated_to_owned(
match ty {
Type::TYPE_STRING => {
// RepeatedView<&str>::iter() yields `&&str` (double ref).
let conv = str_view_to_owned(
field_string_repr(ctx, proto_fqn, field_name),
quote! { s },
true,
);
let conv =
str_view_to_owned(field_string_repr(ctx, proto_fqn, field_name), quote! { s });
quote! { self.#ident.iter().map(|s| #conv).collect() }
}
Type::TYPE_BYTES => {
Expand Down Expand Up @@ -2205,13 +2194,12 @@ pub(crate) fn map_to_owned_expr(

// A custom `string_type` on the outer map field promotes a `string` key /
// value to the configured owned type, matching the owned-side map type. The
// view always yields `&str`, so the custom type constructs from a fresh
// `String` (via its required `From<String>`), mirroring the bytes path.
// view always yields `&str`, so custom types use their copying constructor.
let key_string_repr = map_string_repr(ctx, key_ty, proto_fqn, field_name);
let key_conv = match key_ty {
Type::TYPE_STRING => match key_string_repr {
crate::StringRepr::String => quote! { k.to_string() },
crate::StringRepr::Custom(_) => quote! { ::core::convert::Into::into(k.to_string()) },
crate::StringRepr::Custom(_) => quote! { ::buffa::ProtoString::copy_from_str(k) },
},
// utf8_validation = NONE on a string map key: &[u8] → Vec<u8>.
Type::TYPE_BYTES => quote! { k.to_vec() },
Expand All @@ -2228,7 +2216,7 @@ pub(crate) fn map_to_owned_expr(
let val_conv = match val_ty {
Type::TYPE_STRING => match val_string_repr {
crate::StringRepr::String => quote! { v.to_string() },
crate::StringRepr::Custom(_) => quote! { ::core::convert::Into::into(v.to_string()) },
crate::StringRepr::Custom(_) => quote! { ::buffa::ProtoString::copy_from_str(v) },
},
Type::TYPE_BYTES => match value_bytes_repr {
crate::BytesRepr::Vec => quote! { v.to_vec() },
Expand Down Expand Up @@ -2272,11 +2260,7 @@ pub(crate) fn oneof_variant_to_owned(
match ty {
Type::TYPE_STRING => {
// Match ergonomics on `&ViewEnum` binds `v` as `&&str` (double ref).
str_view_to_owned(
field_string_repr(ctx, proto_fqn, field_name),
quote! { v },
true,
)
str_view_to_owned(field_string_repr(ctx, proto_fqn, field_name), quote! { v })
}
// match-ergonomics on &ViewEnum → v: &&[u8]. bytes_to_owned handles it.
Type::TYPE_BYTES => bytes_to_owned(ctx, proto_fqn, field_name, quote! { v }),
Expand Down
9 changes: 6 additions & 3 deletions buffa-remote-derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,12 @@
//! `From<&str>`, and `buffa::ProtoString` impls that would otherwise be
//! hand-written (compare to the worked example in `buffa-smolstr` or
//! `examples/custom-types`). The remote type must already satisfy
//! `ProtoString`'s non-buffa-owned supertraits (`Clone`, `PartialEq`,
//! the forwarding bounds (`Clone`, `PartialEq`,
//! `Default`, `Debug`, `Send`, `Sync`, `AsRef<str>`, `From<String>`,
//! `From<&str>`) — true of essentially every inline/shared-string crate, since
//! that's the API surface they're built to offer as a `String` substitute.
//! `for<'a> From<&'a str>`). The derive uses the borrowed conversion for
//! `copy_from_str` as well, avoiding an intermediate `String`. This last bound
//! belongs to the derive, not to `ProtoString`: if the remote type's `From<&str>`
//! borrows, implement `ProtoString` by hand and provide an owning copy operation.
//! On the newtype itself, derive the derivable subset (`Clone`,
//! `PartialEq`, `Default`, `Debug`) yourself — `Send`/`Sync` are automatic
//! for a single-field wrapper, and for the generic list/map derives
Expand Down Expand Up @@ -208,6 +210,7 @@ mod string;
/// `Deref<Target = str>`, `AsRef<str>`, `From<String>`, `From<&str>`, and
/// `buffa::ProtoString` for a single-field newtype wrapping the type named by
/// `#[buffa(remote = ...)]`.
/// The generated `copy_from_str` forwards to the inner type's `From<&str>`.
#[proc_macro_derive(ProtoString, attributes(buffa))]
pub fn derive_proto_string(input: TokenStream) -> TokenStream {
expand(input, string::derive)
Expand Down
5 changes: 5 additions & 0 deletions buffa-remote-derive/src/string.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ pub fn derive(input: DeriveInput) -> syn::Result<TokenStream> {
}

impl #impl_generics ::buffa::ProtoString for #ident #ty_generics #where_clause {
#[inline]
fn copy_from_str(s: &str) -> Self {
#ctor_from_str
}

#[inline]
fn from_wire(
payload: ::buffa::WirePayload<'_>,
Expand Down
2 changes: 2 additions & 0 deletions buffa-remote-derive/tests/proto_string.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ fn from_string_and_from_str_round_trip() {
let from_owned = MyEcoString::from(String::from("owned"));
let from_borrowed = MyEcoString::from("owned");
assert_eq!(from_owned, from_borrowed);
assert_eq!(MyEcoString::copy_from_str("owned"), from_borrowed);
}

// Named-field struct shape (not just tuple structs) is also supported.
Expand All @@ -41,6 +42,7 @@ struct NamedEcoString {
fn named_field_struct_works() {
let s = NamedEcoString::from("named");
assert_eq!(s.as_ref(), "named");
assert_eq!(NamedEcoString::copy_from_str("named"), s);
}

// A remote type implementing both `AsRef<str>` and `AsRef<[u8]>` would make
Expand Down
25 changes: 25 additions & 0 deletions buffa-test/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,31 @@ fn main() {
.compile()
.expect("buffa_build failed for basic.proto");

// `OwnedStr` is `BorrowingStr<'static>`, whose `From<&'a str>` borrows and
// so cannot serve view-to-owned or JSON text of arbitrary lifetime. Compile
// every field shape against it: those paths must go through
// `ProtoString::copy_from_str`.
buffa_build::Config::new()
.files(&["protos/string_copy.proto"])
.includes(&["protos/"])
.string_type_custom("crate::string_copy::OwnedStr")
.generate_json(true)
.generate_text(true)
.compile()
.expect("buffa_build failed for string_copy.proto");

// `CountedStr` counts its `copy_from_str` calls, and its `From<&str>` and
// `From<String>` do not: a test can tell whether generated code used
// `copy_from_str` for each field shape. It needs its own package because the
// generated module is named after the proto package.
buffa_build::Config::new()
.files(&["protos/string_copy_counted.proto"])
.includes(&["protos/"])
.string_type_custom("crate::string_copy_counted::CountedStr")
.generate_json(true)
.compile()
.expect("buffa_build failed for string_copy_counted.proto");

// box_type: a crate-LOCAL `CustomBox<T>` pointer (a `ProtoBox<T>` impl) for
// singular message fields, via the `*`-templated knob. The crate compiling
// is most of the test — the field type, decode (`get_or_insert_default`),
Expand Down
13 changes: 13 additions & 0 deletions buffa-test/protos/string_copy.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
syntax = "proto3";
package string_copy;

message Strings {
string name = 1;
optional string maybe = 2;
repeated string items = 3;
map<string, string> by_name = 4;
oneof choice {
string label = 5;
int32 number = 6;
}
}
12 changes: 12 additions & 0 deletions buffa-test/protos/string_copy_counted.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
syntax = "proto3";
package string_copy_counted;

message Strings {
string name = 1;
optional string maybe = 2;
repeated string items = 3;
map<string, string> by_name = 4;
oneof choice {
string label = 5;
}
}
Loading
Loading