Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .changes/unreleased/fixed-20261007-161335.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Fixed
body: '**Remote derives handle lifetime names that match generated parameters.** Previously, serde or arbitrary derives failed to compile when the newtype used one of those names. The generated impls now choose an unused lifetime name.'
time: 2026-10-07T16:13:35.304956+02:00
76 changes: 72 additions & 4 deletions buffa-remote-derive/src/forwarders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
//! emitted unconditionally. The crate docs show how a consumer makes it
//! conditional with `cfg_attr`. Reflection impls are written by hand.

use proc_macro2::{Ident, Span, TokenStream};
use quote::quote;
use std::collections::HashSet;

use proc_macro2::{Ident, Span, TokenStream, TokenTree};
use quote::{quote, ToTokens};
use syn::{parse_quote, GenericParam, Lifetime, LifetimeParam, WherePredicate};

use crate::remote_field::RemoteField;
Expand Down Expand Up @@ -53,7 +55,7 @@ pub fn serde(remote: &RemoteField, form: SerdeForm) -> TokenStream {
..
} = remote;
let krate = Ident::new("serde", span);
let lifetime: Lifetime = parse_quote!('__buffa_de);
let lifetime = fresh_lifetime(generics, field_ty, "__buffa_de");
let mut ser_generics = generics.clone();
let mut de_generics = generics.clone();
de_generics.params.insert(
Expand Down Expand Up @@ -254,7 +256,7 @@ pub fn arbitrary(
// A fresh lifetime for `Arbitrary<'a>`, inserted ahead of the struct's own
// parameters (lifetimes must precede types) and named so it cannot collide
// with one the newtype declares.
let lifetime: Lifetime = parse_quote!('__buffa_arb);
let lifetime = fresh_lifetime(generics, &remote.field_ty, "__buffa_arb");
let mut arb_generics = generics.clone();
arb_generics.params.insert(
0,
Expand Down Expand Up @@ -301,10 +303,76 @@ pub fn arbitrary(
}
}

fn fresh_lifetime(generics: &syn::Generics, field_ty: &syn::Type, stem: &str) -> Lifetime {
let mut used = HashSet::new();
collect_lifetime_names(generics.to_token_stream(), &mut used);
if let Some(where_clause) = &generics.where_clause {
collect_lifetime_names(where_clause.to_token_stream(), &mut used);
}
collect_lifetime_names(field_ty.to_token_stream(), &mut used);
for suffix in 0usize.. {
let name = if suffix == 0 {
stem.to_string()
} else {
format!("{stem}_{suffix}")
};
if !used.contains(&name) {
return Lifetime::new(&format!("'{name}"), Span::call_site());
}
}
unreachable!()
}

fn collect_lifetime_names(tokens: TokenStream, names: &mut HashSet<String>) {
let mut tokens = tokens.into_iter().peekable();
while let Some(token) = tokens.next() {
match token {
TokenTree::Punct(punct) if punct.as_char() == '\'' => {
if let Some(TokenTree::Ident(ident)) = tokens.peek() {
names.insert(ident.to_string());
}
}
TokenTree::Group(group) => collect_lifetime_names(group.stream(), names),
_ => {}
}
}
}

#[cfg(test)]
mod tests {
use syn::parse_quote;

#[test]
fn fresh_lifetime_skips_existing_names() {
let generics: syn::Generics = parse_quote!(<'__buffa_de, '__buffa_de_1, T>);
let field_ty: syn::Type = parse_quote!(T);
assert_eq!(
super::fresh_lifetime(&generics, &field_ty, "__buffa_de").to_string(),
"'__buffa_de_2"
);
}

#[test]
fn fresh_lifetime_skips_higher_ranked_names() {
let mut generics: syn::Generics = parse_quote!(<T>);
generics.where_clause = Some(parse_quote!(where T: for<'__buffa_de> Fn(&'__buffa_de ())));
let field_ty: syn::Type = parse_quote!(T);
assert_eq!(
super::fresh_lifetime(&generics, &field_ty, "__buffa_de").to_string(),
"'__buffa_de_1"
);
}

#[test]
fn fresh_lifetime_skips_field_type_names() {
let generics: syn::Generics = parse_quote!(<T>);
let field_ty: syn::Type = parse_quote!(for<'__buffa_de> fn(&'__buffa_de ()) -> T);
assert_eq!(
super::fresh_lifetime(&generics, &field_ty, "__buffa_de").to_string(),
"'__buffa_de_1"
);
}

#[test]
fn serde_opt_in_is_per_type_and_repeatable() {
let plain: syn::DeriveInput = parse_quote! {
Expand Down
38 changes: 38 additions & 0 deletions buffa-remote-derive/tests/serde_forwarder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,24 @@ impl<T> core::ops::DerefMut for Scoped<'_, T> {
#[buffa(remote = Scoped<'s, T>, serde, arbitrary)]
struct ScopedPointer<'s, T>(Scoped<'s, T>);

#[derive(Clone, PartialEq, Debug, ProtoBox)]
#[buffa(remote = Scoped<'__buffa_de, T>, serde)]
struct DeserializeLifetimeCollision<'__buffa_de, T>(Scoped<'__buffa_de, T>);

#[derive(Clone, PartialEq, Debug, ProtoBox)]
#[buffa(remote = Scoped<'__buffa_arb, T>, arbitrary)]
struct ArbitraryLifetimeCollision<'__buffa_arb, T>(Scoped<'__buffa_arb, T>);

trait LifetimeBound<'a> {}

impl<'a> LifetimeBound<'a> for u8 {}

#[derive(Clone, PartialEq, Debug, ProtoBox)]
#[buffa(remote = Scoped<'scope, T>, serde, arbitrary)]
struct HigherRankedLifetimeCollision<'scope, T>(Scoped<'scope, T>)
where
T: for<'__buffa_de> LifetimeBound<'__buffa_de> + for<'__buffa_arb> LifetimeBound<'__buffa_arb>;

fn roundtrip<T: Serialize + DeserializeOwned + PartialEq + core::fmt::Debug>(value: T, json: &str) {
assert_eq!(serde_json::to_string(&value).unwrap(), json);
assert_eq!(serde_json::from_str::<T>(json).unwrap(), value);
Expand Down Expand Up @@ -237,6 +255,26 @@ fn lifetime_parameterised_newtype_borrows_from_the_input() {
assert_eq!(serde_json::to_string(&pointer).unwrap(), json);
}

#[test]
fn generated_lifetime_parameters_do_not_collide_with_newtype_parameters() {
let value: DeserializeLifetimeCollision<'static, u8> = buffa::ProtoBox::new(7);
roundtrip(value, "7");

let _ = <ArbitraryLifetimeCollision<'static, u8> as arbitrary::Arbitrary<'static>>::arbitrary(
&mut arbitrary::Unstructured::new(&[7]),
)
.unwrap();

let value: HigherRankedLifetimeCollision<'static, u8> = buffa::ProtoBox::new(8);
roundtrip(value, "8");

let _ =
<HigherRankedLifetimeCollision<'static, u8> as arbitrary::Arbitrary<'static>>::arbitrary(
&mut arbitrary::Unstructured::new(&[8]),
)
.unwrap();
}

/// `serde` and `arbitrary` in one attribute each emit their impl.
#[test]
fn serde_and_arbitrary_keys_compose() {
Expand Down
Loading