Add buffa-remote-derive: ProtoString/ProtoBytes/ProtoList/ProtoBox/MapStorage for remote types#251
Conversation
…types Generates the newtype + Deref/AsRef/From/buffa-trait boilerplate that the orphan rule otherwise forces consumers to hand-write when wrapping a foreign type (e.g. ecow::EcoString) to satisfy a pluggable owned-type trait. ProtoBox and MapStorage are out of scope here: their reference newtypes call inherent methods (smallbox::SmallBox::into_inner, indexmap::IndexMap::insert) rather than trait methods, so covering them needs a different, attribute-driven design and will ship as a follow-up.
|
All contributors have signed the CLA ✍️ ✅ |
These two call inherent methods on the remote type (smallbox::SmallBox:: into_inner, indexmap::IndexMap::insert) rather than trait methods, so unlike ProtoString/ProtoBytes/ProtoList they can't be synthesized purely from supertrait bounds. Default to the conventional method names (new/into_inner for pointers, len/insert/clear/iter for maps) with an attribute override for remote types that name them differently. Also renames RemoteField::remote_ty to field_ty, since it always holds the wrapped field's actual type rather than the (documentation-only) attribute value -- the old name was misleading about what's actually used for codegen.
iainmcgin
left a comment
There was a problem hiding this comment.
[claude code] Thanks for taking on #212 — this is a faithful implementation of the design sketched there: the supertrait-driven derives for ProtoString/ProtoBytes/ProtoList, the inherent-method override attributes for ProtoBox/MapStorage (a clean solution to a real asymmetry, and well explained in the crate docs), idiomatic proc-macro error handling, and no_std-clean ::buffa::alloc paths throughout. Review verified the generated impls against the real trait surfaces and found them correct. Three things before it can land — the first two blocking:
-
Feature-gated forwarders are missing. #212 scopes the derive output to include the (feature-gated) serde /
Arbitrary/ reflection forwarders, and the trait docs require them: a JSON build needsSerialize/Deserializeon the storage type, a reflection/vtable build needsReflectList/ReflectMap, and fuzzing needsArbitrary. Without them, a derived newtype used as a message field in a json- or reflect-enabled build fails to compile deep inside generated message code (MyType: Serialize is not satisfied), far from the derive that should have provided it. Either generate them behind feature flags, or — at minimum — document prominently that the derives currently cover the binary codec only and the user must hand-write the rest, with follow-up issues filed for the gaps. -
No end-to-end proof. Every test calls the trait methods directly (
from_wire,push,storage_insert, …); none wires a derived type into abuffa_build-generated message and round-trips it through encode/decode, which is the entire point of #212. The natural proof is migrating at least one newtype inexamples/custom-typesto the derive — which would also cross-link the example to the new crate (right now a reader of the example would never learn the derive exists). -
Unqualified
.as_ref()in the generatedDeref/AsRefbodies (string.rs,bytes.rs,list.rs) breaks for remote types with more than oneAsRefimpl — method resolution doesn't use the return type to disambiguate, so e.g. a type with bothAsRef<str>andAsRef<[u8]>produces a "multiple applicable items" error in generated code. Emit the UFCS form (::core::convert::AsRef::<str>::as_ref(&self.0)and the[u8]/[T]equivalents).
Smaller, non-blocking notes: nothing exercises the generated output under no_std (the dev-dependencies pull in std features — worth a compile check given the workspace's no_std guarantee); ProtoBytes::from_wire always copies via to_vec, worth a one-line doc caveat alongside the existing ProtoList::clear note; and parse/parse_with_overrides in remote_field.rs share enough structure to fold into one helper.
The test breadth at the trait-method layer (tuple and named shapes, override paths, last-write-wins, non-Eq elements) is genuinely good. Looking forward to the next revision.
… proof Fixes the unqualified .as_ref() in generated Deref/AsRef bodies (string.rs, bytes.rs, list.rs) -- ambiguous for any remote type implementing more than one AsRef<_> target, since method resolution isn't return-type-directed. Now uses fully-qualified <Remote as AsRef<Target>>::as_ref. Added a regression test (DualAsRef) that would fail to compile under the old unqualified form. Documents prominently, near the top of the crate docs, that these derives cover the binary codec only -- no serde/Arbitrary/reflection forwarders are generated, matching what the reference newtypes in examples/custom-types already require by hand. Migrates examples/custom-types' FlexStr to #[derive(ProtoString)] + #[buffa(remote = ...)], proving the derive works through a real buffa_build-generated message, binary round-trip, and JSON round-trip -- not just direct trait-method calls in this crate's own tests. Also folds parse/parse_with_overrides into one helper, and documents the unavoidable to_vec() copy in ProtoBytes's generated from_wire.
|
Thanks for the thorough review — all three addressed:
Smaller notes also done: folded Didn't touch the no_std compile-check suggestion — flagging it as a possible follow-up rather than silently skipping it. |
iainmcgin
left a comment
There was a problem hiding this comment.
[claude code] Re-review after 5254c41. All three blockers from the previous round are genuinely resolved, verified independently:
- Codec scope — the new crate-doc section is the documentation route the previous review allowed, and it names the failure mode precisely. (Two accuracy issues in it, flagged as inline comments.)
- End-to-end proof — the
FlexStrmigration is the real thing: ran the example throughbuffa_buildcodegen, binary and JSON round-trips, all assertions pass. I also verified the derivedfrom_wireis semantically identical to the removed hand-written impl:WirePayload::to_strmaps to the sameDecodeError::InvalidUtf8, and flexstr'sFrom<&str>delegates tofrom_ref, so the inline-vs-Arcallocation behavior is preserved with no intermediateString. - AsRef ambiguity — the UFCS fix is applied consistently across
Deref/AsRef/From/FromIteratorin all three trait derives, and theDualAsRefregression test locks it in.
One more small round before merge — everything below is a one-or-two-line fix (details in the inline comments):
- The single remaining unqualified method call on a remote type:
push's.extend(...)inlist.rs, plus a dropped struct where-clause on the same impl. Same class as theas_refblocker, so it should go out with this PR. - Two accuracy fixes in the new scope docs and one in
flex_str.rs's doc comment. map_storage.rstest writes the wrong type intoremote =.- Changelog fragment: CONTRIBUTING.md asks for a
.changes/unreleased/fragment per user-visible change, and a new publishable crate qualifies.task changelog-new -- -k Added -b '...'— thecheck-changelogCI job doesn't enforce presence, so it passed without one.
Non-blocking, fine as a follow-up (say which you prefer): the README "Workspace layout" table and docs/guide.md's custom-types section don't mention buffa-remote-derive yet — the guide still teaches the hand-written boilerplate this crate exists to remove, so one sentence plus a link there would help readers find it. Also purely cosmetic: the [WirePayload::into_bytes] link joins three pre-existing [buffa::*] links that can't resolve (the crate doesn't depend on buffa) — plain backticks without brackets would drop the rustdoc warnings; and examples/custom-types/Cargo.toml still says src/types.rs where the module is now src/types/.
The forwarder gaps (serde/Arbitrary/reflection generation) stay tracked in #212, which remains open for that scope.
| { | ||
| #[inline] | ||
| fn push(&mut self, value: #element_ty) { | ||
| #accessor.extend(::core::iter::once(value)); |
There was a problem hiding this comment.
[claude code] This is the one remaining unqualified method call on a remote type in the generated output — same ambiguity/hijack class as the as_ref fix this commit makes, and it sits eight lines below the comment explaining why plain method-call syntax isn't used. An inherent fn extend(&mut self, ...) on the remote collection would shadow Extend::extend (inherent methods win in method resolution). The where-clause already guarantees the bound, so:
<#field_ty as ::core::iter::Extend<#element_ty>>::extend(&mut #accessor, ::core::iter::once(value));While you're in this impl: it's also the only generated impl that replaces the struct's own where-clause instead of appending to it (compare the Deref/FromIterator/From impls above, and map.rs/box_ptr.rs, which all thread #where_clause through). A newtype declared as struct Foo<T>(SmallVec<[T; 4]>) where T: Copy; loses T: Copy on the ProtoList impl and fails well-formedness. Appending the struct's predicates to the hand-written where block fixes it.
| //! used as a message field in a JSON-enabled, fuzzed, or reflection/vtable | ||
| //! build needs those impls added by hand, exactly as the hand-written | ||
| //! reference newtypes do — `#[derive(serde::Serialize, serde::Deserialize)]` | ||
| //! with `#[serde(transparent)]` typically suffices when the remote type |
There was a problem hiding this comment.
[claude code] The #[serde(transparent)] advice is right for string and repeated newtypes but wrong for bytes (and map-of-bytes): codegen emits #[serde(with = <base64 module>)] on bytes fields, so serde places no Serialize bound on the storage type — a bytes newtype needs no serde impls at all. SmallBytes in examples/custom-types demonstrates this (it derives no serde and documents why). Worse, a consumer who follows this advice and adds #[serde(transparent)] to a bytes newtype gets an impl that serializes as a JSON array of numbers rather than base64 if it's ever used standalone. Worth splitting the claim by kind: string/repeated need the hand-written serde derives; bytes route through the base64 with helpers and don't.
| //! is no generic way to ask an arbitrary remote type to take ownership of a | ||
| //! borrowed/`Bytes`-backed payload without copying, so this derive can't | ||
| //! reach the zero-copy decode path the built-in `bytes::Bytes` representation | ||
| //! gets; hand-write `from_wire` against [`WirePayload::into_bytes`] instead if |
There was a problem hiding this comment.
[claude code] WirePayload::into_bytes won't deliver what this sentence implies: per its own docs it's zero-copy only for an Owned payload, and a single-chunk source — the overwhelmingly common decode path — arrives as Borrowed and is copied anyway. Only the built-in bytes::Bytes field representation gets guaranteed zero-copy. Suggest stating that plainly: a custom bytes type can't currently reach the zero-copy decode path, and the built-in Bytes representation is the remedy when the copy matters — rather than pointing at an escape hatch that still copies.
| /// conversions are generated by [`buffa_remote_derive::ProtoString`] — see | ||
| /// that crate for the full pattern this collapses (compare to | ||
| /// `small_bytes.rs`/`small_vec.rs` in this module, which still hand-write the | ||
| /// equivalent boilerplate for traits the derive crate doesn't yet cover). |
There was a problem hiding this comment.
[claude code] "traits the derive crate doesn't yet cover" isn't the real reason — ProtoBytes/ProtoList cover exactly the traits small_bytes.rs/small_vec.rs implement, and both wrapped types satisfy the derive bounds, so the derive would compile for them. What they'd actually lose is behavior: SmallBytes::from_wire uses SmallVec::from_slice (no intermediate Vec) where the derived path goes to_vec() → From<Vec<u8>>, and small_vec.rs's clear retains capacity where the derive's drops it. Stating the true reason makes these files a better teaching pair: derive for the common case, hand-write when the generic path can't express your allocation behavior.
|
|
||
| #[derive(Clone, PartialEq, Debug, DeriveMapStorage)] | ||
| #[buffa( | ||
| remote = std::collections::BTreeMap<K, V>, |
There was a problem hiding this comment.
[claude code] remote = std::collections::BTreeMap<K, V> but the wrapped field is OddMap<K, V>. The attribute is documentation-only (as remote_field.rs says), so nothing catches this — which makes the crate's own test the counterexample to the "makes the newtype's purpose legible" rationale. Should be remote = OddMap<K, V>. Longer-term it may be worth validating the attribute against the field type at derive time (they must be the same type in the newtype pattern, so a token-level comparison would catch exactly this), but that can be a follow-up.
…curacy Review round 2 fixes, applied maintainer-side: - ProtoList's generated `push` now calls `<Remote as Extend<T>>::extend` via a fully qualified path -- the one generated method call the UFCS pass missed, and the same inherent-method-shadowing class it fixed. Regression test: a remote collection whose inherent `extend` panics. - The generated ProtoList impl now carries the struct's own where-clause predicates (generics augmented via make_where_clause) instead of replacing them with the hand-written bound list. Regression test: a newtype declared with `where T: Display`. - Correct the crate docs' serde guidance to match codegen routing: singular/oneof strings and all bytes positions go through buffa's with-modules (no serde impls needed on the newtype); repeated elements, optional strings, and map values serialize through the newtype's own serde. Retire the WirePayload::into_bytes zero-copy suggestion (it copies for single-chunk payloads too) and the three unresolvable buffa::* intra-doc links. - map_storage test: point `remote =` at the actual wrapped type. - Doc ripple: examples/custom-types README now shows the derive form of FlexStr instead of the deleted hand-written impl, flex_str.rs states the real reason small_bytes/small_vec stay hand-written (allocation behavior, not trait coverage), docs/guide.md points at buffa-remote-derive as the low-boilerplate path, and the workspace table gains the buffa-remote-derive and buffa-yaml rows. - Add the changelog fragment for the new crate.
|
[claude code] Since the remaining punch list was small and mechanical, I've applied it directly to your branch rather than asking for another round — pushed as 6a6ee56 (
Verified end-to-end: a scratch consumer crate wiring Once CI is green this should be ready to land. Thanks again for the contribution and the responsive first round — the derive crate is a genuinely nice addition. |
main made WirePayload opaque (anthropics#247); the enum variants are no longer public, so the direct-construction tests switch to the borrowed() constructor. No-Verification-Needed: test-only mechanical rename after upstream merge
|
[claude code] The CI failure on 6a6ee56 was a semantic conflict with |
iainmcgin
left a comment
There was a problem hiding this comment.
[claude code] All items from both review rounds are addressed (the last round applied maintainer-side, per the comments above), CI is fully green on the merge with main, and the derive was verified end-to-end through a real buffa_build-generated message. Ready to land.
|
[claude code] Merged as 976f66d — thanks @rsd-darshan for the contribution and the responsive review round. The derives are a real quality-of-life improvement for custom owned types. |
Closes #212 — a
#[derive(...)] #[buffa(remote = ...)]macro that generates the newtype boilerplate (Deref,AsRef/DerefMut, the relevantFromimpls, and the buffa trait itself) needed to use a foreign type as a custom owned field type.Two tiers, one crate
ProtoString,ProtoBytes,ProtoList— the remote type's existing supertrait bounds (From<&str>,From<Vec<u8>>,FromIterator<T>, etc.) give the macro everything it needs; no extra configuration beyond naming the remote type.ProtoBox,MapStorage— their reference newtypes (examples/custom-types/src/types/small_box.rs,index_map.rs) call inherent methods on the remote type (smallbox::SmallBox::into_inner(),indexmap::IndexMap::insert()), not trait methods, so the macro can't synthesize the call purely from bounds. These default to the conventional method names (new/into_innerfor pointers;len/insert/clear/iterfor maps) with an attribute override for a remote type that names them differently:The override and the default are both rendered as UFCS-style calls (
Type::method(&mut self.0, args...)), so a differently-named method with the same receiver shape drops in directly.Notes
#[buffa(remote = ...)]attribute is documentation, not codegen input — the macro always reads the field's actual type. Comparing the attribute's value against the field's type isn't possible from within a derive macro without resolvinguseimports, so the attribute is required (and must parse as a type, catching typos) but isn't checked for equality against the field.ProtoList's generatedclearreinitializes viaDefault::default()rather than retaining capacity — documented trade-off, acceptable per the trait's "where the underlying type allows" contract.MapStorage'sDefault/FromIteratorare required by the message codec, not by this derive (a derived impl would wrongly forceK: Default/V: Default) — must be hand-written, same reasoning asProtoList.Testing
Integration tests against real foreign types (
ecow::EcoString,smallvec::SmallVec,smallbox::SmallBox,indexmap::IndexMap, plus hand-rolled types with non-conventional method names to exercise the override paths), covering both tuple-struct and named-field newtype shapes, decode/encode round-trips, and generic element types withoutEq/Ord.task lintandtask testpass clean across the workspace.