diff --git a/.changes/unreleased/fixed-20261007-161335.yaml b/.changes/unreleased/fixed-20261007-161335.yaml new file mode 100644 index 00000000..992b2bbb --- /dev/null +++ b/.changes/unreleased/fixed-20261007-161335.yaml @@ -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 diff --git a/buffa-remote-derive/src/forwarders.rs b/buffa-remote-derive/src/forwarders.rs index 58002506..0d0b2cc7 100644 --- a/buffa-remote-derive/src/forwarders.rs +++ b/buffa-remote-derive/src/forwarders.rs @@ -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; @@ -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( @@ -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, @@ -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) { + 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!(); + 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!(); + 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! { diff --git a/buffa-remote-derive/tests/serde_forwarder.rs b/buffa-remote-derive/tests/serde_forwarder.rs index 01e65096..9a00e742 100644 --- a/buffa-remote-derive/tests/serde_forwarder.rs +++ b/buffa-remote-derive/tests/serde_forwarder.rs @@ -87,6 +87,24 @@ impl 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(value: T, json: &str) { assert_eq!(serde_json::to_string(&value).unwrap(), json); assert_eq!(serde_json::from_str::(json).unwrap(), value); @@ -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 _ = as arbitrary::Arbitrary<'static>>::arbitrary( + &mut arbitrary::Unstructured::new(&[7]), + ) + .unwrap(); + + let value: HigherRankedLifetimeCollision<'static, u8> = buffa::ProtoBox::new(8); + roundtrip(value, "8"); + + let _ = + 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() {