From c1196449099e58363520253ac8dc432643a7c54d Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 01:06:06 +0800 Subject: [PATCH 01/45] chore: ignore repo-local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ece769b..bc5e02c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .idea/ .vscode/ +.worktrees/ target/ fuzz/artifacts/ From fb907b2b60755739ba0b5f9225932b5e5c9ebea2 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 03:09:42 +0800 Subject: [PATCH 02/45] test: add failing enum derive coverage --- recallable/tests/enum_model.rs | 25 +++++ recallable/tests/enum_recall.rs | 33 +++++++ recallable/tests/enum_recallable.rs | 93 +++++++++++++++++++ recallable/tests/macro_expansion_failures.rs | 5 + .../ui/derive_fail_enum_borrowed_fields.rs | 8 ++ .../ui/derive_fail_enum_recall_nested.rs | 16 ++++ .../tests/ui/derive_fail_enum_recall_skip.rs | 12 +++ .../ui/model_fail_enum_recallable_variant.rs | 16 ++++ .../tests/ui/model_fail_enum_skip_variant.rs | 12 +++ 9 files changed, 220 insertions(+) create mode 100644 recallable/tests/enum_model.rs create mode 100644 recallable/tests/enum_recall.rs create mode 100644 recallable/tests/enum_recallable.rs create mode 100644 recallable/tests/ui/derive_fail_enum_borrowed_fields.rs create mode 100644 recallable/tests/ui/derive_fail_enum_recall_nested.rs create mode 100644 recallable/tests/ui/derive_fail_enum_recall_skip.rs create mode 100644 recallable/tests/ui/model_fail_enum_recallable_variant.rs create mode 100644 recallable/tests/ui/model_fail_enum_skip_variant.rs diff --git a/recallable/tests/enum_model.rs b/recallable/tests/enum_model.rs new file mode 100644 index 0000000..6f06a0a --- /dev/null +++ b/recallable/tests/enum_model.rs @@ -0,0 +1,25 @@ +#![cfg(feature = "serde")] + +use recallable::{Recall, Recallable, recallable_model}; + +#[recallable_model] +#[derive(Clone, Debug, PartialEq)] +enum ModelEnum { + Idle, + Ready { value: u32, label: String }, +} + +#[test] +fn test_recallable_model_supports_assignment_only_enums() { + let expected = ModelEnum::Ready { + value: 7, + label: "ok".to_string(), + }; + let json = serde_json::to_string(&expected).unwrap(); + let memento: ::Memento = serde_json::from_str(&json).unwrap(); + + let mut actual = ModelEnum::Idle; + actual.recall(memento); + + assert_eq!(actual, expected); +} diff --git a/recallable/tests/enum_recall.rs b/recallable/tests/enum_recall.rs new file mode 100644 index 0000000..24ae845 --- /dev/null +++ b/recallable/tests/enum_recall.rs @@ -0,0 +1,33 @@ +use recallable::{Recall, Recallable}; + +#[derive(Clone, Debug, PartialEq, recallable::Recallable, recallable::Recall)] +enum AssignmentOnlyEnum { + Idle, + Loading(T), + Ready { bytes: [u8; N], version: u8 }, +} + +type AssignmentOnlyMemento = as Recallable>::Memento; + +#[test] +fn test_assignment_only_enum_recall_switches_to_tuple_variant() { + let mut state = AssignmentOnlyEnum::::Idle; + state.recall(AssignmentOnlyMemento::Loading(7)); + assert_eq!(state, AssignmentOnlyEnum::Loading(7)); +} + +#[test] +fn test_assignment_only_enum_recall_switches_to_named_variant() { + let mut state = AssignmentOnlyEnum::::Loading(1); + state.recall(AssignmentOnlyMemento::Ready { + bytes: [4, 5], + version: 9, + }); + assert_eq!( + state, + AssignmentOnlyEnum::Ready { + bytes: [4, 5], + version: 9, + } + ); +} diff --git a/recallable/tests/enum_recallable.rs b/recallable/tests/enum_recallable.rs new file mode 100644 index 0000000..15c45e7 --- /dev/null +++ b/recallable/tests/enum_recallable.rs @@ -0,0 +1,93 @@ +use core::marker::PhantomData; +use std::any::TypeId; + +use recallable::Recallable; + +#[derive(Clone, Debug, PartialEq, recallable::Recallable)] +struct GenericInner { + value: T, +} + +#[derive(Clone, Debug, PartialEq, recallable::Recallable)] +enum NestedEnum { + Idle, + Ready { + #[recallable] + inner: GenericInner, + }, +} + +#[derive(Clone, Debug, PartialEq, recallable::Recallable)] +enum SkippedEnum<'a> { + Idle, + Borrowed { + #[recallable(skip)] + name: &'a str, + value: u8, + }, +} + +#[derive(Clone, Debug, PartialEq, recallable::Recallable)] +enum PhantomEnum<'a, T> { + Idle, + Value { + marker: PhantomData<&'a T>, + value: T, + }, +} + +#[derive(Clone, Debug, PartialEq, recallable::Recallable)] +enum BoundDependentEnum, U> { + Value { + value: T, + #[recallable(skip)] + marker: PhantomData, + }, +} + +#[derive(Clone, Debug, PartialEq, recallable::Recallable)] +enum SkippedGenericEnum { + Value(T), + Marker(#[recallable(skip)] PhantomData), +} + +#[test] +fn test_enum_with_recallable_field_derives_recallable() { + fn assert_recallable() {} + assert_recallable::>(); +} + +#[test] +fn test_enum_with_skipped_field_derives_recallable() { + type Memento = as Recallable>::Memento; + let _ = core::any::type_name::(); +} + +#[test] +fn test_enum_with_phantom_lifetime_derives_recallable() { + type Memento = as Recallable>::Memento; + let _ = core::any::type_name::(); +} + +#[test] +fn test_enum_memento_retains_bound_dependencies() { + type Left = as Recallable>::Memento; + type Right = as Recallable>::Memento; + + assert_ne!(TypeId::of::(), TypeId::of::()); +} + +#[test] +fn test_enum_memento_prunes_skipped_generic_params() { + type Left = as Recallable>::Memento; + type Right = as Recallable>::Memento; + + assert_eq!(TypeId::of::(), TypeId::of::()); +} + +#[cfg(feature = "impl_from")] +#[test] +fn test_enum_from_impl_is_generated_for_recallable_only_enums() { + let _: fn(NestedEnum) -> as Recallable>::Memento = + ::core::convert::From::from; +} diff --git a/recallable/tests/macro_expansion_failures.rs b/recallable/tests/macro_expansion_failures.rs index 21faf2a..98f4e64 100644 --- a/recallable/tests/macro_expansion_failures.rs +++ b/recallable/tests/macro_expansion_failures.rs @@ -4,6 +4,9 @@ fn derive_macro_reports_expected_failures() { tests.compile_fail("tests/ui/derive_fail_borrowed_fields.rs"); tests.compile_fail("tests/ui/derive_fail_multiple_borrowed_fields.rs"); tests.compile_fail("tests/ui/derive_fail_non_struct.rs"); + tests.compile_fail("tests/ui/derive_fail_enum_recall_nested.rs"); + tests.compile_fail("tests/ui/derive_fail_enum_recall_skip.rs"); + tests.compile_fail("tests/ui/derive_fail_enum_borrowed_fields.rs"); tests.compile_fail("tests/ui/derive_fail_recallable_field_not_path.rs"); tests.compile_fail("tests/ui/derive_fail_recallable_unknown_parameter.rs"); tests.compile_fail("tests/ui/derive_fail_recallable_skip_with_unknown_parameter.rs"); @@ -12,6 +15,8 @@ fn derive_macro_reports_expected_failures() { tests.compile_fail("tests/ui/derive_fail_recallable_skip_on_struct.rs"); tests.compile_fail("tests/ui/model_fail_recallable_skip_with_unknown_parameter.rs"); tests.compile_fail("tests/ui/model_fail_recallable_conflicting_attributes.rs"); + tests.compile_fail("tests/ui/model_fail_enum_recallable_variant.rs"); + tests.compile_fail("tests/ui/model_fail_enum_skip_variant.rs"); tests.pass("tests/ui/derive_pass_skip_memento_default_derives.rs"); #[cfg(feature = "serde")] { diff --git a/recallable/tests/ui/derive_fail_enum_borrowed_fields.rs b/recallable/tests/ui/derive_fail_enum_borrowed_fields.rs new file mode 100644 index 0000000..4d2ddaa --- /dev/null +++ b/recallable/tests/ui/derive_fail_enum_borrowed_fields.rs @@ -0,0 +1,8 @@ +use recallable::Recallable; + +#[derive(Recallable)] +enum BorrowedEnum<'a> { + Borrowed(&'a str), +} + +fn main() {} diff --git a/recallable/tests/ui/derive_fail_enum_recall_nested.rs b/recallable/tests/ui/derive_fail_enum_recall_nested.rs new file mode 100644 index 0000000..d6d8bb3 --- /dev/null +++ b/recallable/tests/ui/derive_fail_enum_recall_nested.rs @@ -0,0 +1,16 @@ +use recallable::{Recall, Recallable}; + +#[derive(Clone, Debug, PartialEq, Recallable)] +struct Inner { + value: u8, +} + +#[derive(Recallable, Recall)] +enum InvalidNestedRecallEnum { + Ready { + #[recallable] + inner: Inner, + }, +} + +fn main() {} diff --git a/recallable/tests/ui/derive_fail_enum_recall_skip.rs b/recallable/tests/ui/derive_fail_enum_recall_skip.rs new file mode 100644 index 0000000..7487792 --- /dev/null +++ b/recallable/tests/ui/derive_fail_enum_recall_skip.rs @@ -0,0 +1,12 @@ +use recallable::{Recall, Recallable}; + +#[derive(Recallable, Recall)] +enum InvalidSkippedRecallEnum { + Ready { + #[recallable(skip)] + sticky: u8, + value: u8, + }, +} + +fn main() {} diff --git a/recallable/tests/ui/model_fail_enum_recallable_variant.rs b/recallable/tests/ui/model_fail_enum_recallable_variant.rs new file mode 100644 index 0000000..eb2af8d --- /dev/null +++ b/recallable/tests/ui/model_fail_enum_recallable_variant.rs @@ -0,0 +1,16 @@ +use recallable::recallable_model; + +#[derive(Clone, Debug, PartialEq)] +struct Inner { + value: u8, +} + +#[recallable_model] +enum InvalidModelNestedEnum { + Ready { + #[recallable] + inner: Inner, + }, +} + +fn main() {} diff --git a/recallable/tests/ui/model_fail_enum_skip_variant.rs b/recallable/tests/ui/model_fail_enum_skip_variant.rs new file mode 100644 index 0000000..cf903d9 --- /dev/null +++ b/recallable/tests/ui/model_fail_enum_skip_variant.rs @@ -0,0 +1,12 @@ +use recallable::recallable_model; + +#[recallable_model] +enum InvalidModelSkippedEnum { + Ready { + #[recallable(skip)] + sticky: u8, + value: u8, + }, +} + +fn main() {} From e394d57269615ee5a41626f742c5d83c15c28bcb Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 03:15:31 +0800 Subject: [PATCH 03/45] feat: add enum recallable and memento generation --- recallable-macro/src/context.rs | 24 +- recallable-macro/src/context/from_impl.rs | 158 +++++++++- recallable-macro/src/context/internal.rs | 10 +- .../src/context/internal/bounds.rs | 62 +++- .../src/context/internal/fields.rs | 55 +++- .../src/context/internal/generics.rs | 24 +- recallable-macro/src/context/internal/ir.rs | 273 +++++++++++++++++- recallable-macro/src/context/memento_enum.rs | 109 +++++++ .../src/context/recallable_impl.rs | 39 ++- recallable-macro/src/lib.rs | 4 +- 10 files changed, 731 insertions(+), 27 deletions(-) create mode 100644 recallable-macro/src/context/memento_enum.rs diff --git a/recallable-macro/src/context.rs b/recallable-macro/src/context.rs index 26de6f8..0ad28c2 100644 --- a/recallable-macro/src/context.rs +++ b/recallable-macro/src/context.rs @@ -3,36 +3,44 @@ //! Semantic analysis and support logic live in the nested `internal` module. //! //! Code generation remains split into free functions in submodules: -//! - [`gen_memento_struct`] — companion memento struct definition +//! - `gen_memento_type` — companion memento struct or enum definition //! - [`gen_recallable_impl`] — `Recallable` trait implementation //! - [`gen_recall_impl`] — `Recall` trait implementation -//! - [`gen_from_impl`] — `From` for memento (behind `impl_from` feature) +//! - [`gen_from_impl`] — `From` for memento (behind `impl_from` feature) mod from_impl; mod internal; +mod memento_enum; mod memento_struct; mod recall_impl; mod recallable_impl; pub(super) use from_impl::gen_from_impl; pub(super) use internal::{ - CodegenEnv, FieldIr, FieldMember, FieldStrategy, StructIr, StructShape, - collect_recall_like_bounds, collect_shared_memento_bounds, crate_path, + CodegenEnv, EnumIr, FieldIr, FieldMember, FieldStrategy, ItemIr, StructIr, StructShape, + VariantIr, VariantShape, collect_recall_like_bounds, collect_recall_like_bounds_for_enum, + collect_shared_memento_bounds, collect_shared_memento_bounds_for_enum, crate_path, has_recallable_skip_attr, is_generic_type_param, }; -pub(super) use memento_struct::gen_memento_struct; pub(super) use recall_impl::gen_recall_impl; pub(super) use recallable_impl::gen_recallable_impl; pub(super) const SERDE_ENABLED: bool = cfg!(feature = "serde"); pub(super) const IMPL_FROM_ENABLED: bool = cfg!(feature = "impl_from"); +pub(crate) fn gen_memento_type(ir: &ItemIr, env: &CodegenEnv) -> proc_macro2::TokenStream { + match ir { + ItemIr::Struct(ir) => memento_struct::gen_memento_struct(ir, env), + ItemIr::Enum(ir) => memento_enum::gen_memento_enum(ir, env), + } +} + #[cfg(test)] mod tests { use quote::ToTokens; use syn::parse_quote; - use super::{CodegenEnv, StructIr, gen_memento_struct}; + use super::{CodegenEnv, ItemIr, gen_memento_type}; #[test] fn facade_reexports_support_analysis_and_codegen() { @@ -43,9 +51,9 @@ mod tests { } }; - let ir = StructIr::analyze(&input).unwrap(); + let ir = ItemIr::analyze(&input).unwrap(); let env = CodegenEnv::resolve(); - let memento: syn::ItemStruct = syn::parse2(gen_memento_struct(&ir, &env)).unwrap(); + let memento: syn::ItemStruct = syn::parse2(gen_memento_type(&ir, &env)).unwrap(); assert_eq!(memento.ident.to_string(), "ExampleMemento"); assert_eq!(memento.vis.to_token_stream().to_string(), "pub (crate)"); diff --git a/recallable-macro/src/context/from_impl.rs b/recallable-macro/src/context/from_impl.rs index 6df6f3d..00015c5 100644 --- a/recallable-macro/src/context/from_impl.rs +++ b/recallable-macro/src/context/from_impl.rs @@ -1,13 +1,21 @@ use proc_macro2::TokenStream as TokenStream2; -use quote::quote; +use quote::{ToTokens, format_ident, quote}; use syn::WherePredicate; use crate::context::{ - CodegenEnv, FieldIr, FieldStrategy, StructIr, StructShape, collect_shared_memento_bounds, + CodegenEnv, EnumIr, FieldIr, FieldStrategy, ItemIr, StructIr, StructShape, VariantIr, + VariantShape, collect_shared_memento_bounds, collect_shared_memento_bounds_for_enum, }; #[must_use] -pub(crate) fn gen_from_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { +pub(crate) fn gen_from_impl(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { + match ir { + ItemIr::Struct(ir) => gen_struct_from_impl(ir, env), + ItemIr::Enum(ir) => gen_enum_from_impl(ir, env), + } +} + +fn gen_struct_from_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { let impl_generics = ir.impl_generics(); let struct_type = ir.struct_type(); let memento_type = ir.memento_type(); @@ -23,6 +31,22 @@ pub(crate) fn gen_from_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { } } +fn gen_enum_from_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { + let impl_generics = ir.impl_generics(); + let enum_type = ir.enum_type(); + let memento_type = ir.memento_type(); + let where_clause = build_enum_from_where_clause(ir, env); + let from_method = build_enum_from_method(ir); + + quote! { + impl #impl_generics ::core::convert::From<#enum_type> + for #memento_type + #where_clause { + #from_method + } + } +} + fn build_from_method(ir: &StructIr) -> TokenStream2 { let struct_type = ir.struct_type(); let fn_body = build_from_body(ir); @@ -35,6 +59,18 @@ fn build_from_method(ir: &StructIr) -> TokenStream2 { } } +fn build_enum_from_method(ir: &EnumIr) -> TokenStream2 { + let enum_type = ir.enum_type(); + let fn_body = build_enum_from_body(ir); + + quote! { + #[inline] + fn from(value: #enum_type) -> Self { + #fn_body + } + } +} + fn build_from_body(ir: &StructIr) -> TokenStream2 { match ir.shape() { StructShape::Named => build_named_from_body(ir), @@ -43,6 +79,23 @@ fn build_from_body(ir: &StructIr) -> TokenStream2 { } } +fn build_enum_from_body(ir: &EnumIr) -> TokenStream2 { + let enum_name = ir.name(); + let memento_name = ir.memento_name(); + let arms = ir.variants().map(|variant| { + let variant_name = variant.name; + let pattern = build_variant_source_pattern(variant); + let expr = build_variant_from_expr(variant); + quote! { #enum_name::#variant_name #pattern => #memento_name::#variant_name #expr } + }); + + quote! { + match value { + #(#arms),* + } + } +} + fn build_named_from_body(ir: &StructIr) -> TokenStream2 { let inits = ir .memento_fields() @@ -58,6 +111,76 @@ fn build_named_from_field(field: &FieldIr) -> TokenStream2 { quote! { #member: #value } } +fn build_variant_source_pattern(variant: &VariantIr<'_>) -> TokenStream2 { + match variant.shape { + VariantShape::Named => { + let patterns = variant.fields.iter().enumerate().map(|(index, field)| { + let member = &field.member; + let binding = build_binding_pattern(field, index); + quote! { #member: #binding } + }); + quote! { { #(#patterns),* } } + } + VariantShape::Unnamed => { + let patterns = variant + .fields + .iter() + .enumerate() + .map(|(index, field)| build_binding_pattern(field, index)); + quote! { ( #(#patterns),* ) } + } + VariantShape::Unit => quote! {}, + } +} + +fn build_binding_pattern(field: &FieldIr<'_>, index: usize) -> TokenStream2 { + if field.strategy.is_skip() { + quote! { _ } + } else { + build_binding_ident(field, index).to_token_stream() + } +} + +fn build_binding_ident(field: &FieldIr<'_>, index: usize) -> syn::Ident { + match &field.member { + crate::context::FieldMember::Named(name) => (*name).clone(), + crate::context::FieldMember::Unnamed(_) => format_ident!("__recallable_field_{index}"), + } +} + +fn build_variant_from_expr(variant: &VariantIr<'_>) -> TokenStream2 { + let kept_fields: Vec<_> = variant + .fields + .iter() + .enumerate() + .filter(|(_, field)| !field.strategy.is_skip()) + .collect(); + + if kept_fields.is_empty() { + return quote! {}; + } + + match variant.shape { + VariantShape::Named => { + let inits = kept_fields.into_iter().map(|(index, field)| { + let member = &field.member; + let binding = build_binding_ident(field, index); + let value = build_from_binding_expr(field, &binding); + quote! { #member: #value } + }); + quote! { { #(#inits),* } } + } + VariantShape::Unnamed => { + let values = kept_fields.into_iter().map(|(index, field)| { + let binding = build_binding_ident(field, index); + build_from_binding_expr(field, &binding) + }); + quote! { ( #(#values),* ) } + } + VariantShape::Unit => quote! {}, + } +} + fn build_unnamed_from_body(ir: &StructIr) -> TokenStream2 { let values = ir .memento_fields() @@ -94,6 +217,14 @@ fn build_from_expr(field: &FieldIr) -> TokenStream2 { } } +fn build_from_binding_expr(field: &FieldIr<'_>, binding: &syn::Ident) -> TokenStream2 { + match &field.strategy { + FieldStrategy::StoreAsSelf => quote! { #binding }, + FieldStrategy::StoreAsMemento => quote! { ::core::convert::From::from(#binding) }, + FieldStrategy::Skip => unreachable!("filtered above"), + } +} + fn build_from_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { let bounds = collect_from_bounds(ir, env); ir.extend_where_clause(bounds) @@ -114,3 +245,24 @@ fn collect_from_bounds(ir: &StructIr, env: &CodegenEnv) -> Vec { bounds.extend(ir.whole_type_from_bounds(recallable_trait)); bounds } + +fn build_enum_from_where_clause(ir: &EnumIr, env: &CodegenEnv) -> Option { + let bounds = collect_enum_from_bounds(ir, env); + ir.extend_where_clause(bounds) +} + +fn collect_enum_from_bounds(ir: &EnumIr, env: &CodegenEnv) -> Vec { + let recallable_trait = &env.recallable_trait; + let mut bounds: Vec<_> = ir + .recallable_params() + .flat_map(|ty| -> [WherePredicate; 2] { + [ + syn::parse_quote! { #ty: #recallable_trait }, + syn::parse_quote! { #ty::Memento: ::core::convert::From<#ty> }, + ] + }) + .collect(); + bounds.extend(collect_shared_memento_bounds_for_enum(ir, env)); + bounds.extend(ir.whole_type_from_bounds(recallable_trait)); + bounds +} diff --git a/recallable-macro/src/context/internal.rs b/recallable-macro/src/context/internal.rs index d5b7d15..322dccc 100644 --- a/recallable-macro/src/context/internal.rs +++ b/recallable-macro/src/context/internal.rs @@ -7,8 +7,14 @@ mod ir; mod lifetime; mod util; -pub(crate) use bounds::{collect_recall_like_bounds, collect_shared_memento_bounds}; +pub(crate) use bounds::{ + collect_recall_like_bounds, collect_recall_like_bounds_for_enum, + collect_shared_memento_bounds, collect_shared_memento_bounds_for_enum, +}; pub(crate) use fields::has_recallable_skip_attr; pub(crate) use generics::is_generic_type_param; -pub(crate) use ir::{CodegenEnv, FieldIr, FieldMember, FieldStrategy, StructIr, StructShape}; +pub(crate) use ir::{ + CodegenEnv, EnumIr, FieldIr, FieldMember, FieldStrategy, ItemIr, StructIr, StructShape, + VariantIr, VariantShape, +}; pub(crate) use util::crate_path; diff --git a/recallable-macro/src/context/internal/bounds.rs b/recallable-macro/src/context/internal/bounds.rs index 6d5cc15..af4eccc 100644 --- a/recallable-macro/src/context/internal/bounds.rs +++ b/recallable-macro/src/context/internal/bounds.rs @@ -2,7 +2,7 @@ use proc_macro2::TokenStream as TokenStream2; use quote::quote; use syn::WherePredicate; -use super::ir::{CodegenEnv, StructIr}; +use super::ir::{CodegenEnv, EnumIr, StructIr}; /// Policy for which derives and nested bounds the generated memento should receive. #[derive(Debug)] @@ -72,6 +72,14 @@ pub(crate) fn collect_shared_memento_bounds( collect_shared_memento_bounds_with_spec(ir, env, &ir.memento_trait_spec()) } +#[must_use] +pub(crate) fn collect_shared_memento_bounds_for_enum( + ir: &EnumIr, + env: &CodegenEnv, +) -> Vec { + collect_shared_memento_bounds_with_spec_for_enum(ir, env, &ir.memento_trait_spec()) +} + fn collect_shared_memento_bounds_with_spec( ir: &StructIr, env: &CodegenEnv, @@ -89,6 +97,23 @@ fn collect_shared_memento_bounds_with_spec( bounds } +fn collect_shared_memento_bounds_with_spec_for_enum( + ir: &EnumIr, + env: &CodegenEnv, + memento_trait_spec: &MementoTraitSpec, +) -> Vec { + let recallable_trait = &env.recallable_trait; + let memento_trait_bounds = memento_trait_spec.common_bound_tokens(); + + let mut bounds = ir.recallable_memento_bounds(&memento_trait_bounds); + bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &memento_trait_bounds)); + if let Some(deserialize_owned) = memento_trait_spec.serde_nested_bound() { + bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &deserialize_owned)); + } + + bounds +} + #[must_use] pub(crate) fn collect_recall_like_bounds( ir: &StructIr, @@ -98,6 +123,15 @@ pub(crate) fn collect_recall_like_bounds( collect_recall_like_bounds_with_spec(ir, env, direct_bound, &ir.memento_trait_spec()) } +#[must_use] +pub(crate) fn collect_recall_like_bounds_for_enum( + ir: &EnumIr, + env: &CodegenEnv, + direct_bound: &TokenStream2, +) -> Vec { + collect_recall_like_bounds_with_spec_for_enum(ir, env, direct_bound, &ir.memento_trait_spec()) +} + fn collect_recall_like_bounds_with_spec( ir: &StructIr, env: &CodegenEnv, @@ -124,6 +158,32 @@ fn collect_recall_like_bounds_with_spec( bounds } +fn collect_recall_like_bounds_with_spec_for_enum( + ir: &EnumIr, + env: &CodegenEnv, + direct_bound: &TokenStream2, + memento_trait_spec: &MementoTraitSpec, +) -> Vec { + let shared_memento_bounds = + collect_shared_memento_bounds_with_spec_for_enum(ir, env, memento_trait_spec); + let shared_param_bound_count = ir.recallable_params().count(); + + let mut bounds = ir.recallable_bounds(direct_bound); + bounds.extend( + shared_memento_bounds + .iter() + .take(shared_param_bound_count) + .cloned(), + ); + bounds.extend(ir.whole_type_bounds(direct_bound)); + bounds.extend( + shared_memento_bounds + .into_iter() + .skip(shared_param_bound_count), + ); + bounds +} + #[cfg(test)] mod tests { use quote::{ToTokens, quote}; diff --git a/recallable-macro/src/context/internal/fields.rs b/recallable-macro/src/context/internal/fields.rs index df1b161..72b1717 100644 --- a/recallable-macro/src/context/internal/fields.rs +++ b/recallable-macro/src/context/internal/fields.rs @@ -1,11 +1,15 @@ use std::collections::HashSet; -use syn::{Data, DataStruct, DeriveInput, Field, Fields, Index, Meta, PathArguments, Type}; +use syn::{ + Data, DataEnum, DataStruct, DeriveInput, Field, Fields, Index, Meta, PathArguments, Type, + Variant, + punctuated::Punctuated, +}; use super::generics::{ BareTypeParam, GenericParamLookup, GenericUsage, collect_generic_dependencies_in_type, }; -use super::ir::{FieldIr, FieldMember, FieldStrategy}; +use super::ir::{FieldIr, FieldMember, FieldStrategy, VariantIr, VariantShape}; use super::lifetime::{field_uses_struct_lifetime, is_phantom_data}; use super::util::is_recallable_attr; @@ -29,6 +33,19 @@ pub(super) fn extract_struct_fields(input: &DeriveInput) -> syn::Result<&Fields> } } +pub(super) fn extract_enum_variants( + input: &DeriveInput, +) -> syn::Result<&Punctuated> { + if let Data::Enum(DataEnum { variants, .. }) = &input.data { + Ok(variants) + } else { + Err(syn::Error::new_spanned( + input, + "This derive macro can only be applied to structs or enums", + )) + } +} + fn determine_field_behavior(field: &Field) -> syn::Result> { let mut saw_recall = false; let mut saw_skip = false; @@ -114,6 +131,7 @@ pub(super) fn collect_field_irs<'a>( if is_phantom_data(ty) && field_uses_struct_lifetime(ty, struct_lifetimes) { field_irs.push(FieldIr { + source: field, memento_index: None, member, ty, @@ -145,6 +163,7 @@ pub(super) fn collect_field_irs<'a>( let memento_index = (!strategy.is_skip()).then_some(memento_counter); field_irs.push(FieldIr { + source: field, memento_index, member, ty, @@ -158,6 +177,38 @@ pub(super) fn collect_field_irs<'a>( Ok((usage, field_irs)) } +pub(super) fn collect_variant_irs<'a>( + variants: &'a Punctuated, + struct_lifetimes: &HashSet<&'a syn::Ident>, + generic_lookup: &GenericParamLookup<'a>, +) -> syn::Result<(GenericUsage, Vec>)> { + let mut usage = GenericUsage::default(); + let mut variant_irs = Vec::with_capacity(variants.len()); + + for variant in variants { + let (variant_usage, fields) = + collect_field_irs(&variant.fields, struct_lifetimes, generic_lookup)?; + usage.retained.extend(variant_usage.retained); + usage + .recallable_type_params + .extend(variant_usage.recallable_type_params); + + let shape = match &variant.fields { + Fields::Named(_) => VariantShape::Named, + Fields::Unnamed(_) => VariantShape::Unnamed, + Fields::Unit => VariantShape::Unit, + }; + + variant_irs.push(VariantIr { + name: &variant.ident, + shape, + fields, + }); + } + + Ok((usage, variant_irs)) +} + #[must_use] pub(crate) fn has_recallable_skip_attr(field: &Field) -> bool { // Use determine_field_behavior for consistent validation. diff --git a/recallable-macro/src/context/internal/generics.rs b/recallable-macro/src/context/internal/generics.rs index 845bf8c..739010a 100644 --- a/recallable-macro/src/context/internal/generics.rs +++ b/recallable-macro/src/context/internal/generics.rs @@ -5,7 +5,7 @@ use quote::quote; use syn::visit::Visit; use syn::{GenericParam, Generics, Ident, PathArguments, Type, WhereClause, WherePredicate}; -use super::ir::FieldIr; +use super::ir::{FieldIr, VariantIr}; /// Whether a generic parameter is kept on the generated memento type. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -138,6 +138,28 @@ pub(super) fn collect_marker_param_indices( .collect() } +#[must_use] +pub(super) fn collect_variant_marker_param_indices( + variants: &[VariantIr<'_>], + generic_params: &[GenericParamPlan<'_>], + generic_lookup: &GenericParamLookup<'_>, +) -> Vec { + let referenced_by_fields: HashSet<_> = variants + .iter() + .flat_map(|variant| variant.fields.iter()) + .filter(|field| !field.strategy.is_skip()) + .flat_map(|field| collect_generic_dependencies_in_type(field.ty, generic_lookup)) + .collect(); + + generic_params + .iter() + .enumerate() + .filter_map(|(index, plan)| { + (plan.is_retained() && !referenced_by_fields.contains(&index)).then_some(index) + }) + .collect() +} + #[must_use] pub(super) fn plan_memento_generics<'a>( generics: &'a Generics, diff --git a/recallable-macro/src/context/internal/ir.rs b/recallable-macro/src/context/internal/ir.rs index bb1052e..1822e35 100644 --- a/recallable-macro/src/context/internal/ir.rs +++ b/recallable-macro/src/context/internal/ir.rs @@ -3,18 +3,18 @@ use std::collections::HashSet; use proc_macro2::TokenStream as TokenStream2; use quote::{ToTokens, quote}; use syn::{ - DeriveInput, Fields, Generics, Ident, ImplGenerics, Index, Type, Visibility, WhereClause, - WherePredicate, + DeriveInput, Field, Fields, Generics, Ident, ImplGenerics, Index, Type, Visibility, + WhereClause, WherePredicate, }; use crate::context::SERDE_ENABLED; use super::bounds::MementoTraitSpec; -use super::fields::{collect_field_irs, extract_struct_fields}; +use super::fields::{collect_field_irs, collect_variant_irs, extract_struct_fields}; use super::generics::{ - GenericParamPlan, collect_marker_param_indices, is_generic_type_param, marker_component, - plan_memento_generics, + GenericParamPlan, collect_marker_param_indices, collect_variant_marker_param_indices, + is_generic_type_param, marker_component, plan_memento_generics, }; use super::lifetime::{collect_struct_lifetimes, validate_no_borrowed_fields}; use super::util::{crate_path, is_recallable_attr}; @@ -49,7 +49,7 @@ pub(crate) enum FieldStrategy { } impl FieldStrategy { - pub(super) const fn is_skip(self) -> bool { + pub(crate) const fn is_skip(self) -> bool { matches!(self, Self::Skip) } } @@ -75,12 +75,27 @@ impl CodegenEnv { #[derive(Debug)] pub(crate) struct FieldIr<'a> { + pub(crate) source: &'a Field, pub(crate) memento_index: Option, pub(crate) member: FieldMember<'a>, pub(crate) ty: &'a Type, pub(crate) strategy: FieldStrategy, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum VariantShape { + Named, + Unnamed, + Unit, +} + +#[derive(Debug)] +pub(crate) struct VariantIr<'a> { + pub(crate) name: &'a Ident, + pub(crate) shape: VariantShape, + pub(crate) fields: Vec>, +} + /// How a field member is referenced in generated tokens. #[derive(Debug, Clone)] pub(crate) enum FieldMember<'a> { @@ -114,6 +129,26 @@ pub(crate) struct StructIr<'a> { skip_memento_default_derives: bool, } +#[derive(Debug)] +pub(crate) struct EnumIr<'a> { + name: &'a Ident, + visibility: &'a Visibility, + generics: &'a Generics, + variants: Vec>, + memento_name: Ident, + generic_type_param_idents: HashSet<&'a Ident>, + generic_params: Vec>, + memento_where_clause: Option, + marker_param_indices: Vec, + skip_memento_default_derives: bool, +} + +#[derive(Debug)] +pub(crate) enum ItemIr<'a> { + Struct(StructIr<'a>), + Enum(EnumIr<'a>), +} + fn has_skip_memento_default_derives(input: &DeriveInput) -> syn::Result { let mut skip_memento_default_derives = false; for attr in input.attrs.iter().filter(|a| is_recallable_attr(a)) { @@ -131,6 +166,232 @@ fn has_skip_memento_default_derives(input: &DeriveInput) -> syn::Result { Ok(skip_memento_default_derives) } +impl<'a> ItemIr<'a> { + pub(crate) fn analyze(input: &'a DeriveInput) -> syn::Result { + match &input.data { + syn::Data::Struct(_) => Ok(Self::Struct(StructIr::analyze(input)?)), + syn::Data::Enum(data) => Ok(Self::Enum(EnumIr::analyze(input, &data.variants)?)), + _ => Err(syn::Error::new_spanned( + input, + "This derive macro can only be applied to structs or enums", + )), + } + } +} + +impl<'a> EnumIr<'a> { + pub(crate) fn analyze( + input: &'a DeriveInput, + variants: &'a syn::punctuated::Punctuated, + ) -> syn::Result { + let struct_lifetimes = collect_struct_lifetimes(&input.generics); + for variant in variants { + validate_no_borrowed_fields(&variant.fields, &struct_lifetimes)?; + } + + let generic_lookup = super::generics::GenericParamLookup::new(&input.generics); + let generic_type_param_idents = input + .generics + .type_params() + .map(|param| ¶m.ident) + .collect(); + let (usage, variant_irs) = + collect_variant_irs(variants, &struct_lifetimes, &generic_lookup)?; + let (generic_params, memento_where_clause) = + plan_memento_generics(&input.generics, usage, &generic_lookup); + let marker_param_indices = + collect_variant_marker_param_indices(&variant_irs, &generic_params, &generic_lookup); + + Ok(Self { + name: &input.ident, + visibility: &input.vis, + generics: &input.generics, + variants: variant_irs, + memento_name: quote::format_ident!("{}Memento", input.ident), + generic_type_param_idents, + generic_params, + memento_where_clause, + marker_param_indices, + skip_memento_default_derives: has_skip_memento_default_derives(input)?, + }) + } + + pub(crate) const fn name(&self) -> &'a Ident { + self.name + } + + pub(crate) fn enum_type(&self) -> TokenStream2 { + let name = &self.name; + let (_, type_generics, _) = self.generics.split_for_impl(); + quote! { #name #type_generics } + } + + pub(crate) const fn memento_name(&self) -> &Ident { + &self.memento_name + } + + pub(crate) const fn visibility(&self) -> &'a Visibility { + self.visibility + } + + pub(crate) fn impl_generics(&self) -> ImplGenerics<'_> { + let (impl_generics, _, _) = self.generics.split_for_impl(); + impl_generics + } + + pub(crate) const fn generic_type_param_idents(&self) -> &HashSet<&'a Ident> { + &self.generic_type_param_idents + } + + #[must_use] + pub(crate) const fn memento_trait_spec(&self) -> MementoTraitSpec { + MementoTraitSpec::new(SERDE_ENABLED, self.skip_memento_default_derives) + } + + #[must_use] + pub(crate) fn memento_decl_generics(&self) -> TokenStream2 { + let mut params = self + .generic_params + .iter() + .filter(|plan| plan.is_retained()) + .map(GenericParamPlan::decl_param) + .peekable(); + + if params.peek().is_none() { + quote! {} + } else { + quote! { <#(#params),*> } + } + } + + pub(crate) const fn memento_where_clause(&self) -> Option<&WhereClause> { + self.memento_where_clause.as_ref() + } + + #[must_use] + pub(crate) const fn has_synthetic_marker(&self) -> bool { + !self.marker_param_indices.is_empty() + } + + #[must_use] + pub(crate) fn synthetic_marker_type(&self) -> Option { + if self.marker_param_indices.is_empty() { + return None; + } + + let components = self + .marker_param_indices + .iter() + .map(|&index| marker_component(self.generic_params[index].param)); + + Some(quote! { + ::core::marker::PhantomData<(#(#components,)*)> + }) + } + + pub(crate) fn recallable_params(&self) -> impl Iterator { + self.generic_params + .iter() + .filter_map(GenericParamPlan::recallable_ident) + } + + #[must_use] + pub(crate) fn memento_type(&self) -> TokenStream2 { + let name = &self.memento_name; + let mut args = self + .generic_params + .iter() + .filter(|plan| plan.is_retained()) + .map(GenericParamPlan::type_arg) + .peekable(); + + if args.peek().is_none() { + quote! { #name } + } else { + quote! { #name<#(#args),*> } + } + } + + pub(crate) fn variants(&self) -> impl Iterator> { + self.variants.iter() + } + + pub(super) fn recallable_bounds(&self, bound: &TokenStream2) -> Vec { + self.recallable_params() + .map(|ty| syn::parse_quote! { #ty: #bound }) + .collect() + } + + pub(super) fn recallable_memento_bounds(&self, bound: &TokenStream2) -> Vec { + self.recallable_params() + .map(|ty| syn::parse_quote! { #ty::Memento: #bound }) + .collect() + } + + fn whole_type_bound_targets(&self) -> impl Iterator { + let mut seen = HashSet::new(); + + self.variants + .iter() + .flat_map(|variant| variant.fields.iter()) + .filter_map(move |field| match field.strategy { + FieldStrategy::StoreAsMemento + if !is_generic_type_param(field.ty, &self.generic_type_param_idents) + && seen.insert(field.ty) => + { + Some(field.ty) + } + _ => None, + }) + } + + #[must_use] + pub(super) fn whole_type_bounds(&self, bound: &TokenStream2) -> Vec { + self.whole_type_bound_targets() + .map(|ty| syn::parse_quote! { #ty: #bound }) + .collect() + } + + pub(super) fn whole_type_memento_bounds( + &self, + recallable_trait: &TokenStream2, + bound: &TokenStream2, + ) -> impl Iterator { + self.whole_type_bound_targets() + .map(move |ty| syn::parse_quote! { <#ty as #recallable_trait>::Memento: #bound }) + } + + pub(crate) fn whole_type_from_bounds( + &self, + recallable_trait: &TokenStream2, + ) -> impl Iterator { + self.whole_type_bound_targets().flat_map(move |ty| { + [ + syn::parse_quote! { #ty: #recallable_trait }, + syn::parse_quote! { <#ty as #recallable_trait>::Memento: ::core::convert::From<#ty> }, + ] + }) + } + + pub(crate) fn extend_where_clause( + &self, + extra: impl IntoIterator, + ) -> Option { + let mut where_clause = self.generics.where_clause.clone(); + let mut extra_iter = extra.into_iter().peekable(); + if extra_iter.peek().is_none() { + where_clause + } else { + where_clause + .get_or_insert(syn::parse_quote! { where }) + .predicates + .extend(extra_iter); + + where_clause + } + } +} + impl<'a> StructIr<'a> { pub(crate) fn analyze(input: &'a DeriveInput) -> syn::Result { let fields = extract_struct_fields(input)?; diff --git a/recallable-macro/src/context/memento_enum.rs b/recallable-macro/src/context/memento_enum.rs new file mode 100644 index 0000000..0f66e72 --- /dev/null +++ b/recallable-macro/src/context/memento_enum.rs @@ -0,0 +1,109 @@ +use std::collections::HashSet; + +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::{Ident, WhereClause, WherePredicate}; + +use crate::context::{ + CodegenEnv, EnumIr, FieldMember, FieldStrategy, SERDE_ENABLED, VariantIr, VariantShape, + collect_recall_like_bounds_for_enum, is_generic_type_param, +}; + +#[must_use] +pub(crate) fn gen_memento_enum(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { + let derives = ir.memento_trait_spec().derive_attr(); + let visibility = ir.visibility(); + let memento_name = ir.memento_name(); + let memento_generics = ir.memento_decl_generics(); + let where_clause = build_memento_where_clause(ir, env); + let variants = ir + .variants() + .map(|variant| build_memento_variant(variant, &env.recallable_trait, ir.generic_type_param_idents())) + .chain(ir.synthetic_marker_type().into_iter().map(|marker_ty| build_marker_variant(&marker_ty))); + + quote! { + #derives + #visibility enum #memento_name #memento_generics #where_clause { + #(#variants),* + } + } +} + +fn build_memento_where_clause(ir: &EnumIr, env: &CodegenEnv) -> Option { + let mut where_clause = ir + .memento_where_clause() + .cloned() + .unwrap_or(syn::parse_quote! { where }); + let bounded_types = collect_memento_bounds(ir, env); + where_clause.predicates.extend(bounded_types); + + if where_clause.predicates.is_empty() { + None + } else { + Some(where_clause) + } +} + +fn collect_memento_bounds(ir: &EnumIr, env: &CodegenEnv) -> Vec { + collect_recall_like_bounds_for_enum(ir, env, &env.recallable_trait) +} + +fn build_memento_variant( + variant: &VariantIr<'_>, + recallable_trait: &TokenStream2, + generic_type_params: &HashSet<&Ident>, +) -> TokenStream2 { + let name = variant.name; + let fields: Vec<_> = variant + .fields + .iter() + .filter(|field| !field.strategy.is_skip()) + .map(|field| build_memento_field(field, recallable_trait, generic_type_params)) + .collect(); + + let shape = if fields.is_empty() { + VariantShape::Unit + } else { + variant.shape + }; + + match shape { + VariantShape::Named => quote! { #name { #(#fields),* } }, + VariantShape::Unnamed => quote! { #name(#(#fields),*) }, + VariantShape::Unit => quote! { #name }, + } +} + +fn build_memento_field( + field: &crate::context::FieldIr<'_>, + recallable_trait: &TokenStream2, + generic_type_params: &HashSet<&Ident>, +) -> TokenStream2 { + let ty = field.ty; + let field_ty = match &field.strategy { + FieldStrategy::StoreAsMemento => { + if is_generic_type_param(ty, generic_type_params) { + quote! { #ty::Memento } + } else { + quote! { <#ty as #recallable_trait>::Memento } + } + } + FieldStrategy::StoreAsSelf => quote! { #ty }, + FieldStrategy::Skip => unreachable!("filtered above"), + }; + + match &field.member { + FieldMember::Named(name) => quote! { #name: #field_ty }, + FieldMember::Unnamed(_) => quote! { #field_ty }, + } +} + +fn build_marker_variant(marker_ty: &TokenStream2) -> TokenStream2 { + let serde_attr = SERDE_ENABLED.then_some(quote! { #[serde(skip)] }); + + quote! { + #[doc(hidden)] + #serde_attr + __RecallableMarker(#marker_ty) + } +} diff --git a/recallable-macro/src/context/recallable_impl.rs b/recallable-macro/src/context/recallable_impl.rs index da36b09..45886ac 100644 --- a/recallable-macro/src/context/recallable_impl.rs +++ b/recallable-macro/src/context/recallable_impl.rs @@ -2,10 +2,17 @@ use proc_macro2::TokenStream as TokenStream2; use quote::quote; use syn::WherePredicate; -use crate::context::{CodegenEnv, StructIr, collect_recall_like_bounds}; +use crate::context::{CodegenEnv, EnumIr, ItemIr, StructIr, collect_recall_like_bounds, collect_recall_like_bounds_for_enum}; #[must_use] -pub(crate) fn gen_recallable_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { +pub(crate) fn gen_recallable_impl(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { + match ir { + ItemIr::Struct(ir) => gen_struct_recallable_impl(ir, env), + ItemIr::Enum(ir) => gen_enum_recallable_impl(ir, env), + } +} + +fn gen_struct_recallable_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { let impl_generics = ir.impl_generics(); let recallable_trait = &env.recallable_trait; let struct_type = ir.struct_type(); @@ -29,3 +36,31 @@ fn build_recallable_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option Vec { collect_recall_like_bounds(ir, env, &env.recallable_trait) } + +fn gen_enum_recallable_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { + let impl_generics = ir.impl_generics(); + let recallable_trait = &env.recallable_trait; + let enum_type = ir.enum_type(); + let where_clause = build_enum_recallable_where_clause(ir, env); + let memento_type = ir.memento_type(); + + quote! { + impl #impl_generics #recallable_trait + for #enum_type + #where_clause { + type Memento = #memento_type; + } + } +} + +fn build_enum_recallable_where_clause( + ir: &EnumIr, + env: &CodegenEnv, +) -> Option { + let extra_bounds = collect_enum_recallable_bounds(ir, env); + ir.extend_where_clause(extra_bounds) +} + +fn collect_enum_recallable_bounds(ir: &EnumIr, env: &CodegenEnv) -> Vec { + collect_recall_like_bounds_for_enum(ir, env, &env.recallable_trait) +} diff --git a/recallable-macro/src/lib.rs b/recallable-macro/src/lib.rs index 1a61367..981fded 100644 --- a/recallable-macro/src/lib.rs +++ b/recallable-macro/src/lib.rs @@ -83,13 +83,13 @@ pub fn recallable_model(attr: TokenStream, item: TokenStream) -> TokenStream { #[proc_macro_derive(Recallable, attributes(recallable))] pub fn derive_recallable(input: TokenStream) -> TokenStream { let input: DeriveInput = parse_macro_input!(input as DeriveInput); - let ir = match context::StructIr::analyze(&input) { + let ir = match context::ItemIr::analyze(&input) { Ok(ir) => ir, Err(e) => return e.to_compile_error().into(), }; let env = context::CodegenEnv::resolve(); - let memento_struct = context::gen_memento_struct(&ir, &env); + let memento_struct = context::gen_memento_type(&ir, &env); let recallable_impl = context::gen_recallable_impl(&ir, &env); let from_impl = if context::IMPL_FROM_ENABLED { let from_impl = context::gen_from_impl(&ir, &env); From 62306991cd978e2657ba7fc366a45af8463090ec Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 03:21:49 +0800 Subject: [PATCH 04/45] feat: add assignment-only enum recall derive --- recallable-macro/src/context.rs | 9 +- recallable-macro/src/context/internal.rs | 4 +- recallable-macro/src/context/internal/ir.rs | 35 +++++++ recallable-macro/src/context/recall_impl.rs | 34 ++++++- .../src/context/recallable_impl.rs | 98 ++++++++++++++++++- recallable-macro/src/lib.rs | 7 +- recallable/tests/enum_recall.rs | 10 +- recallable/tests/ui/derive_fail_non_struct.rs | 4 +- 8 files changed, 187 insertions(+), 14 deletions(-) diff --git a/recallable-macro/src/context.rs b/recallable-macro/src/context.rs index 0ad28c2..8d5b892 100644 --- a/recallable-macro/src/context.rs +++ b/recallable-macro/src/context.rs @@ -17,10 +17,11 @@ mod recallable_impl; pub(super) use from_impl::gen_from_impl; pub(super) use internal::{ - CodegenEnv, EnumIr, FieldIr, FieldMember, FieldStrategy, ItemIr, StructIr, StructShape, - VariantIr, VariantShape, collect_recall_like_bounds, collect_recall_like_bounds_for_enum, - collect_shared_memento_bounds, collect_shared_memento_bounds_for_enum, crate_path, - has_recallable_skip_attr, is_generic_type_param, + CodegenEnv, EnumIr, EnumRecallMode, FieldIr, FieldMember, FieldStrategy, ItemIr, StructIr, + StructShape, VariantIr, VariantShape, collect_recall_like_bounds, + collect_recall_like_bounds_for_enum, collect_shared_memento_bounds, + collect_shared_memento_bounds_for_enum, crate_path, has_recallable_skip_attr, + is_generic_type_param, }; pub(super) use recall_impl::gen_recall_impl; pub(super) use recallable_impl::gen_recallable_impl; diff --git a/recallable-macro/src/context/internal.rs b/recallable-macro/src/context/internal.rs index 322dccc..8705948 100644 --- a/recallable-macro/src/context/internal.rs +++ b/recallable-macro/src/context/internal.rs @@ -14,7 +14,7 @@ pub(crate) use bounds::{ pub(crate) use fields::has_recallable_skip_attr; pub(crate) use generics::is_generic_type_param; pub(crate) use ir::{ - CodegenEnv, EnumIr, FieldIr, FieldMember, FieldStrategy, ItemIr, StructIr, StructShape, - VariantIr, VariantShape, + CodegenEnv, EnumIr, EnumRecallMode, FieldIr, FieldMember, FieldStrategy, ItemIr, StructIr, + StructShape, VariantIr, VariantShape, }; pub(crate) use util::crate_path; diff --git a/recallable-macro/src/context/internal/ir.rs b/recallable-macro/src/context/internal/ir.rs index 1822e35..3f4f19a 100644 --- a/recallable-macro/src/context/internal/ir.rs +++ b/recallable-macro/src/context/internal/ir.rs @@ -149,6 +149,12 @@ pub(crate) enum ItemIr<'a> { Enum(EnumIr<'a>), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum EnumRecallMode { + AssignmentOnly, + ManualOnly, +} + fn has_skip_memento_default_derives(input: &DeriveInput) -> syn::Result { let mut skip_memento_default_derives = false; for attr in input.attrs.iter().filter(|a| is_recallable_attr(a)) { @@ -316,6 +322,35 @@ impl<'a> EnumIr<'a> { self.variants.iter() } + pub(crate) fn recall_mode(&self) -> EnumRecallMode { + if self + .variants + .iter() + .flat_map(|variant| variant.fields.iter()) + .any(|field| !matches!(field.strategy, FieldStrategy::StoreAsSelf)) + { + EnumRecallMode::ManualOnly + } else { + EnumRecallMode::AssignmentOnly + } + } + + pub(crate) fn ensure_recall_derivable(&self) -> syn::Result<()> { + if let Some(field) = self + .variants + .iter() + .flat_map(|variant| variant.fields.iter()) + .find(|field| !matches!(field.strategy, FieldStrategy::StoreAsSelf)) + { + return Err(syn::Error::new_spanned( + field.source, + "enum `Recall` derive only supports assignment-only variant fields", + )); + } + + Ok(()) + } + pub(super) fn recallable_bounds(&self, bound: &TokenStream2) -> Vec { self.recallable_params() .map(|ty| syn::parse_quote! { #ty: #bound }) diff --git a/recallable-macro/src/context/recall_impl.rs b/recallable-macro/src/context/recall_impl.rs index 3409b24..efe3c85 100644 --- a/recallable-macro/src/context/recall_impl.rs +++ b/recallable-macro/src/context/recall_impl.rs @@ -3,11 +3,19 @@ use quote::quote; use syn::WherePredicate; use crate::context::{ - CodegenEnv, FieldIr, FieldMember, FieldStrategy, StructIr, collect_recall_like_bounds, + CodegenEnv, EnumIr, FieldIr, FieldMember, FieldStrategy, ItemIr, StructIr, + collect_recall_like_bounds, collect_recall_like_bounds_for_enum, }; #[must_use] -pub(crate) fn gen_recall_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { +pub(crate) fn gen_recall_impl(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { + match ir { + ItemIr::Struct(ir) => gen_struct_recall_impl(ir, env), + ItemIr::Enum(ir) => gen_enum_recall_impl(ir, env), + } +} + +fn gen_struct_recall_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { let recall_trait = &env.recall_trait; let impl_generics = ir.impl_generics(); let where_clause = build_recall_where_clause(ir, env); @@ -23,6 +31,28 @@ pub(crate) fn gen_recall_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { } } +fn gen_enum_recall_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { + let recall_trait = &env.recall_trait; + let impl_generics = ir.impl_generics(); + let enum_type = ir.enum_type(); + let where_clause = ir.extend_where_clause(collect_recall_like_bounds_for_enum( + ir, + env, + recall_trait, + )); + + quote! { + impl #impl_generics #recall_trait + for #enum_type + #where_clause { + #[inline] + fn recall(&mut self, memento: Self::Memento) { + *self = Self::__recallable_rebuild_from_memento(memento); + } + } + } +} + fn build_recall_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { let extra_bounds = collect_recall_bounds(ir, env); ir.extend_where_clause(extra_bounds) diff --git a/recallable-macro/src/context/recallable_impl.rs b/recallable-macro/src/context/recallable_impl.rs index 45886ac..2975588 100644 --- a/recallable-macro/src/context/recallable_impl.rs +++ b/recallable-macro/src/context/recallable_impl.rs @@ -1,8 +1,11 @@ use proc_macro2::TokenStream as TokenStream2; -use quote::quote; +use quote::{ToTokens, format_ident, quote}; use syn::WherePredicate; -use crate::context::{CodegenEnv, EnumIr, ItemIr, StructIr, collect_recall_like_bounds, collect_recall_like_bounds_for_enum}; +use crate::context::{ + CodegenEnv, EnumIr, EnumRecallMode, FieldIr, FieldMember, ItemIr, StructIr, VariantIr, + VariantShape, collect_recall_like_bounds, collect_recall_like_bounds_for_enum, +}; #[must_use] pub(crate) fn gen_recallable_impl(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { @@ -43,6 +46,7 @@ fn gen_enum_recallable_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { let enum_type = ir.enum_type(); let where_clause = build_enum_recallable_where_clause(ir, env); let memento_type = ir.memento_type(); + let rebuild_helper = gen_enum_rebuild_helper(ir, env); quote! { impl #impl_generics #recallable_trait @@ -50,6 +54,8 @@ fn gen_enum_recallable_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { #where_clause { type Memento = #memento_type; } + + #rebuild_helper } } @@ -64,3 +70,91 @@ fn build_enum_recallable_where_clause( fn collect_enum_recallable_bounds(ir: &EnumIr, env: &CodegenEnv) -> Vec { collect_recall_like_bounds_for_enum(ir, env, &env.recallable_trait) } + +fn gen_enum_rebuild_helper(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { + if !matches!(ir.recall_mode(), EnumRecallMode::AssignmentOnly) { + return quote! {}; + } + + let impl_generics = ir.impl_generics(); + let enum_type = ir.enum_type(); + let recallable_trait = &env.recallable_trait; + let memento_name = ir.memento_name(); + let where_clause = build_enum_recallable_where_clause(ir, env); + let arms = ir.variants().map(|variant| { + let variant_name = variant.name; + let pattern = build_variant_memento_pattern(variant); + let expr = build_variant_rebuild_expr(variant, ir.name()); + quote! { #memento_name::#variant_name #pattern => #expr } + }); + let marker_arm = ir.synthetic_marker_type().map(|_| { + quote! { #memento_name::__RecallableMarker(_) => unreachable!("marker variant is never constructed"), } + }); + + quote! { + impl #impl_generics #enum_type #where_clause { + #[inline] + fn __recallable_rebuild_from_memento( + memento: <#enum_type as #recallable_trait>::Memento, + ) -> Self { + match memento { + #(#arms,)* + #marker_arm + } + } + } + } +} + +fn build_variant_memento_pattern(variant: &VariantIr<'_>) -> TokenStream2 { + match variant.shape { + VariantShape::Named => { + let bindings = variant + .fields + .iter() + .enumerate() + .map(|(index, field)| build_binding_ident(field, index).to_token_stream()); + quote! { { #(#bindings),* } } + } + VariantShape::Unnamed => { + let bindings = variant + .fields + .iter() + .enumerate() + .map(|(index, field)| build_binding_ident(field, index).to_token_stream()); + quote! { ( #(#bindings),* ) } + } + VariantShape::Unit => quote! {}, + } +} + +fn build_variant_rebuild_expr(variant: &VariantIr<'_>, enum_name: &syn::Ident) -> TokenStream2 { + let variant_name = variant.name; + + match variant.shape { + VariantShape::Named => { + let inits = variant.fields.iter().enumerate().map(|(index, field)| { + let member = &field.member; + let binding = build_binding_ident(field, index); + quote! { #member: #binding } + }); + quote! { #enum_name::#variant_name { #(#inits),* } } + } + VariantShape::Unnamed => { + let values = variant + .fields + .iter() + .enumerate() + .map(|(index, field)| build_binding_ident(field, index).to_token_stream()); + quote! { #enum_name::#variant_name(#(#values),*) } + } + VariantShape::Unit => quote! { #enum_name::#variant_name }, + } +} + +fn build_binding_ident(field: &FieldIr<'_>, index: usize) -> syn::Ident { + match &field.member { + FieldMember::Named(name) => (*name).clone(), + FieldMember::Unnamed(_) => format_ident!("__recallable_field_{index}"), + } +} diff --git a/recallable-macro/src/lib.rs b/recallable-macro/src/lib.rs index 981fded..b0301aa 100644 --- a/recallable-macro/src/lib.rs +++ b/recallable-macro/src/lib.rs @@ -127,10 +127,15 @@ pub fn derive_recallable(input: TokenStream) -> TokenStream { /// `Recall` implementation. pub fn derive_recall(input: TokenStream) -> TokenStream { let input: DeriveInput = parse_macro_input!(input as DeriveInput); - let ir = match context::StructIr::analyze(&input) { + let ir = match context::ItemIr::analyze(&input) { Ok(ir) => ir, Err(e) => return e.to_compile_error().into(), }; + if let context::ItemIr::Enum(enum_ir) = &ir + && let Err(e) = enum_ir.ensure_recall_derivable() + { + return e.to_compile_error().into(); + } let env = context::CodegenEnv::resolve(); let recall_impl = context::gen_recall_impl(&ir, &env); diff --git a/recallable/tests/enum_recall.rs b/recallable/tests/enum_recall.rs index 24ae845..8320956 100644 --- a/recallable/tests/enum_recall.rs +++ b/recallable/tests/enum_recall.rs @@ -1,10 +1,16 @@ +use core::marker::PhantomData; + use recallable::{Recall, Recallable}; #[derive(Clone, Debug, PartialEq, recallable::Recallable, recallable::Recall)] enum AssignmentOnlyEnum { Idle, Loading(T), - Ready { bytes: [u8; N], version: u8 }, + Ready { + bytes: [u8; 2], + version: u8, + marker: PhantomData<[(); N]>, + }, } type AssignmentOnlyMemento = as Recallable>::Memento; @@ -22,12 +28,14 @@ fn test_assignment_only_enum_recall_switches_to_named_variant() { state.recall(AssignmentOnlyMemento::Ready { bytes: [4, 5], version: 9, + marker: PhantomData, }); assert_eq!( state, AssignmentOnlyEnum::Ready { bytes: [4, 5], version: 9, + marker: PhantomData, } ); } diff --git a/recallable/tests/ui/derive_fail_non_struct.rs b/recallable/tests/ui/derive_fail_non_struct.rs index ae2b592..254a395 100644 --- a/recallable/tests/ui/derive_fail_non_struct.rs +++ b/recallable/tests/ui/derive_fail_non_struct.rs @@ -1,8 +1,8 @@ use recallable::Recallable; #[derive(Recallable)] -enum NotAStruct { - Value(i32), +union NotSupported { + value: u32, } fn main() {} From 19f4d7b88335b9007c4e75f0e0e45751f01c4362 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 03:23:18 +0800 Subject: [PATCH 05/45] feat: support recallable_model on assignment-only enums --- recallable-macro/src/context/internal/ir.rs | 14 +-- recallable-macro/src/model_macro.rs | 92 +++++++++++++++++-- .../derive_fail_enum_borrowed_fields.stderr | 5 + .../ui/derive_fail_enum_recall_nested.stderr | 6 ++ .../ui/derive_fail_enum_recall_skip.stderr | 6 ++ .../tests/ui/derive_fail_non_struct.stderr | 6 +- .../model_fail_enum_recallable_variant.stderr | 5 + .../ui/model_fail_enum_skip_variant.stderr | 5 + 8 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 recallable/tests/ui/derive_fail_enum_borrowed_fields.stderr create mode 100644 recallable/tests/ui/derive_fail_enum_recall_nested.stderr create mode 100644 recallable/tests/ui/derive_fail_enum_recall_skip.stderr create mode 100644 recallable/tests/ui/model_fail_enum_recallable_variant.stderr create mode 100644 recallable/tests/ui/model_fail_enum_skip_variant.stderr diff --git a/recallable-macro/src/context/internal/ir.rs b/recallable-macro/src/context/internal/ir.rs index 3f4f19a..6e46529 100644 --- a/recallable-macro/src/context/internal/ir.rs +++ b/recallable-macro/src/context/internal/ir.rs @@ -11,7 +11,9 @@ use crate::context::SERDE_ENABLED; use super::bounds::MementoTraitSpec; -use super::fields::{collect_field_irs, collect_variant_irs, extract_struct_fields}; +use super::fields::{ + collect_field_irs, collect_variant_irs, extract_enum_variants, extract_struct_fields, +}; use super::generics::{ GenericParamPlan, collect_marker_param_indices, collect_variant_marker_param_indices, is_generic_type_param, marker_component, plan_memento_generics, @@ -176,7 +178,10 @@ impl<'a> ItemIr<'a> { pub(crate) fn analyze(input: &'a DeriveInput) -> syn::Result { match &input.data { syn::Data::Struct(_) => Ok(Self::Struct(StructIr::analyze(input)?)), - syn::Data::Enum(data) => Ok(Self::Enum(EnumIr::analyze(input, &data.variants)?)), + syn::Data::Enum(_) => Ok(Self::Enum(EnumIr::analyze( + input, + extract_enum_variants(input)?, + )?)), _ => Err(syn::Error::new_spanned( input, "This derive macro can only be applied to structs or enums", @@ -274,11 +279,6 @@ impl<'a> EnumIr<'a> { self.memento_where_clause.as_ref() } - #[must_use] - pub(crate) const fn has_synthetic_marker(&self) -> bool { - !self.marker_param_indices.is_empty() - } - #[must_use] pub(crate) fn synthetic_marker_type(&self) -> Option { if self.marker_param_indices.is_empty() { diff --git a/recallable-macro/src/model_macro.rs b/recallable-macro/src/model_macro.rs index b33efc5..ab4dafc 100644 --- a/recallable-macro/src/model_macro.rs +++ b/recallable-macro/src/model_macro.rs @@ -1,10 +1,42 @@ use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; -use quote::quote; -use syn::{Fields, ItemStruct, parse_macro_input, parse_quote}; +use quote::{ToTokens, quote}; +use syn::{Fields, Item, ItemEnum, ItemStruct, parse_quote}; -use crate::context::{SERDE_ENABLED, crate_path, has_recallable_skip_attr}; +use crate::context::{self, EnumRecallMode, SERDE_ENABLED, crate_path, has_recallable_skip_attr}; + +enum ModelItem { + Struct(ItemStruct), + Enum(ItemEnum), +} + +impl ModelItem { + fn attrs(&self) -> &[syn::Attribute] { + match self { + Self::Struct(item) => &item.attrs, + Self::Enum(item) => &item.attrs, + } + } + + fn attrs_mut(&mut self) -> &mut Vec { + match self { + Self::Struct(item) => &mut item.attrs, + Self::Enum(item) => &mut item.attrs, + } + } + + fn add_serde_skip_attrs(&mut self) { + match self { + Self::Struct(item) => add_serde_skip_attrs_to_fields(&mut item.fields), + Self::Enum(item) => { + for variant in &mut item.variants { + add_serde_skip_attrs_to_fields(&mut variant.fields); + } + } + } + } +} #[must_use] pub(super) fn expand(attr: TokenStream, item: TokenStream) -> TokenStream { @@ -14,21 +46,51 @@ pub(super) fn expand(attr: TokenStream, item: TokenStream) -> TokenStream { } let crate_path = crate_path(); - let mut input = parse_macro_input!(item as ItemStruct); + let mut model_item = match parse_model_item(item) { + Ok(item) => item, + Err(e) => return e.to_compile_error().into(), + }; + let derive_input: syn::DeriveInput = match &model_item { + ModelItem::Struct(item) => match syn::parse2(item.to_token_stream()) { + Ok(input) => input, + Err(e) => return e.to_compile_error().into(), + }, + ModelItem::Enum(item) => match syn::parse2(item.to_token_stream()) { + Ok(input) => input, + Err(e) => return e.to_compile_error().into(), + }, + }; + let ir = match context::ItemIr::analyze(&derive_input) { + Ok(ir) => ir, + Err(e) => return e.to_compile_error().into(), + }; + if let context::ItemIr::Enum(enum_ir) = &ir + && enum_ir.recall_mode() == EnumRecallMode::ManualOnly + { + return syn::Error::new_spanned( + &derive_input.ident, + "`#[recallable_model]` on enums requires assignment-only variants because it injects `Recall`", + ) + .to_compile_error() + .into(); + } - if SERDE_ENABLED && let Err(e) = check_no_serialize_derive(&input.attrs) { + if SERDE_ENABLED && let Err(e) = check_no_serialize_derive(model_item.attrs()) { return e.to_compile_error().into(); } let derives = build_model_derive_attr(&crate_path); - input.attrs.push(derives); + model_item.attrs_mut().push(derives); if SERDE_ENABLED { - add_serde_skip_attrs(&mut input.fields); + model_item.add_serde_skip_attrs(); } - (quote! { #input }).into() + match model_item { + ModelItem::Struct(item) => quote! { #item }.into(), + ModelItem::Enum(item) => quote! { #item }.into(), + } } fn validate_model_attr(attr: &TokenStream2) -> syn::Result<()> { @@ -55,7 +117,19 @@ fn build_model_derive_attr(crate_path: &TokenStream2) -> syn::Attribute { } } -fn add_serde_skip_attrs(fields: &mut Fields) { +fn parse_model_item(item: TokenStream) -> syn::Result { + let item: Item = syn::parse(item)?; + match item { + Item::Struct(item) => Ok(ModelItem::Struct(item)), + Item::Enum(item) => Ok(ModelItem::Enum(item)), + other => Err(syn::Error::new_spanned( + other, + "`#[recallable_model]` can only be applied to structs or enums", + )), + } +} + +fn add_serde_skip_attrs_to_fields(fields: &mut Fields) { for field in fields.iter_mut() { if has_recallable_skip_attr(field) { field.attrs.push(parse_quote! { #[serde(skip)] }); diff --git a/recallable/tests/ui/derive_fail_enum_borrowed_fields.stderr b/recallable/tests/ui/derive_fail_enum_borrowed_fields.stderr new file mode 100644 index 0000000..fd6b377 --- /dev/null +++ b/recallable/tests/ui/derive_fail_enum_borrowed_fields.stderr @@ -0,0 +1,5 @@ +error: Recall derives do not support borrowed fields + --> tests/ui/derive_fail_enum_borrowed_fields.rs:5:14 + | +5 | Borrowed(&'a str), + | ^^^^^^^ diff --git a/recallable/tests/ui/derive_fail_enum_recall_nested.stderr b/recallable/tests/ui/derive_fail_enum_recall_nested.stderr new file mode 100644 index 0000000..0eca273 --- /dev/null +++ b/recallable/tests/ui/derive_fail_enum_recall_nested.stderr @@ -0,0 +1,6 @@ +error: enum `Recall` derive only supports assignment-only variant fields + --> tests/ui/derive_fail_enum_recall_nested.rs:11:9 + | +11 | / #[recallable] +12 | | inner: Inner, + | |____________________^ diff --git a/recallable/tests/ui/derive_fail_enum_recall_skip.stderr b/recallable/tests/ui/derive_fail_enum_recall_skip.stderr new file mode 100644 index 0000000..5deed84 --- /dev/null +++ b/recallable/tests/ui/derive_fail_enum_recall_skip.stderr @@ -0,0 +1,6 @@ +error: enum `Recall` derive only supports assignment-only variant fields + --> tests/ui/derive_fail_enum_recall_skip.rs:6:9 + | +6 | / #[recallable(skip)] +7 | | sticky: u8, + | |__________________^ diff --git a/recallable/tests/ui/derive_fail_non_struct.stderr b/recallable/tests/ui/derive_fail_non_struct.stderr index 56d102d..319e129 100644 --- a/recallable/tests/ui/derive_fail_non_struct.stderr +++ b/recallable/tests/ui/derive_fail_non_struct.stderr @@ -1,7 +1,7 @@ -error: This derive macro can only be applied to structs +error: This derive macro can only be applied to structs or enums --> tests/ui/derive_fail_non_struct.rs:4:1 | -4 | / enum NotAStruct { -5 | | Value(i32), +4 | / union NotSupported { +5 | | value: u32, 6 | | } | |_^ diff --git a/recallable/tests/ui/model_fail_enum_recallable_variant.stderr b/recallable/tests/ui/model_fail_enum_recallable_variant.stderr new file mode 100644 index 0000000..788b95c --- /dev/null +++ b/recallable/tests/ui/model_fail_enum_recallable_variant.stderr @@ -0,0 +1,5 @@ +error: `#[recallable_model]` on enums requires assignment-only variants because it injects `Recall` + --> tests/ui/model_fail_enum_recallable_variant.rs:9:6 + | +9 | enum InvalidModelNestedEnum { + | ^^^^^^^^^^^^^^^^^^^^^^ diff --git a/recallable/tests/ui/model_fail_enum_skip_variant.stderr b/recallable/tests/ui/model_fail_enum_skip_variant.stderr new file mode 100644 index 0000000..e8bb005 --- /dev/null +++ b/recallable/tests/ui/model_fail_enum_skip_variant.stderr @@ -0,0 +1,5 @@ +error: `#[recallable_model]` on enums requires assignment-only variants because it injects `Recall` + --> tests/ui/model_fail_enum_skip_variant.rs:4:6 + | +4 | enum InvalidModelSkippedEnum { + | ^^^^^^^^^^^^^^^^^^^^^^^ From 36fb05b125c3defba32da3fe92e03ac5db7dfa98 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 03:24:21 +0800 Subject: [PATCH 06/45] docs: describe enum derive support and assignment-only gating --- GUIDE.md | 18 ++++++++++++------ README.md | 5 ++++- recallable-macro/src/lib.rs | 35 ++++++++++++++++++++++------------- recallable/src/lib.rs | 12 ++++++++++-- 4 files changed, 48 insertions(+), 22 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index f7c875c..1f98206 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -581,11 +581,14 @@ This manual style pairs naturally with: The derive macros support more than just simple named structs. -### Supported struct shapes +### Supported item shapes - named structs - tuple structs - unit structs +- enums for `Recallable` +- enums for `Recall` and `recallable_model` only when every variant field is + assignment-only ### Supported generic forms @@ -637,7 +640,7 @@ write-side output format by default. ### `#[recallable_model]` -Convenience attribute for the common struct model path. +Convenience attribute for the common struct or assignment-only enum model path. It is the recommended default whether or not `serde` is enabled; with `serde` enabled it also removes extra derive boilerplate. @@ -661,9 +664,10 @@ Generates the `Recall` implementation. Behavior: -- plain fields are assigned directly -- `#[recallable]` fields call nested `recall` -- `#[recallable(skip)]` fields are left untouched +- struct fields are handled as before +- enum derives are supported only for assignment-only variants +- enums with nested `#[recallable]` or skipped fields should derive + `Recallable` and implement `Recall` or `TryRecall` manually ### `#[recallable]` @@ -718,7 +722,9 @@ pub trait TryRecall: Recallable { ## Current limitations -- Derive macros currently support structs only; enum support is not implemented +- `#[derive(Recallable)]` supports enums under the normal field rules +- `#[derive(Recall)]` and `#[recallable_model]` support enums only for + assignment-only variants - Borrowed non-skipped state fields are rejected - `#[recallable]` is path-only and does not accept tuple/reference/slice/function syntax directly diff --git a/README.md b/README.md index 2bfbdba..4873115 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,10 @@ impl Recall for EngineState { ## Current Limitations -- Derive macros currently support structs only: named, tuple, and unit structs +- Derive macros support structs and enums +- `#[derive(Recallable)]` supports enums under the normal field rules +- `#[derive(Recall)]` and `#[recallable_model]` support enums only when every + variant field is assignment-only - Borrowed state fields are rejected unless they are skipped - `#[recallable]` is path-only: it supports type parameters, path types, and associated types, but not tuple/reference/slice/function syntax directly diff --git a/recallable-macro/src/lib.rs b/recallable-macro/src/lib.rs index b0301aa..173586b 100644 --- a/recallable-macro/src/lib.rs +++ b/recallable-macro/src/lib.rs @@ -4,16 +4,18 @@ //! //! Provided macros: //! -//! - `#[recallable_model]`: injects `Recallable`/`Recall` derives; with the `serde` -//! Cargo feature enabled for this macro crate it also adds `serde::Serialize` -//! and applies `#[serde(skip)]` to fields marked `#[recallable(skip)]`. +//! - `#[recallable_model]`: injects `Recallable`/`Recall` derives for structs and +//! assignment-only enums; with the `serde` Cargo feature enabled for this macro +//! crate it also adds `serde::Serialize` and applies `#[serde(skip)]` to fields +//! marked `#[recallable(skip)]`. //! -//! - `#[derive(Recallable)]`: generates an internal companion memento struct, exposes -//! it as `::Memento`, and emits the `Recallable` impl; with the -//! `impl_from` Cargo feature it also generates `From` for the memento type. +//! - `#[derive(Recallable)]`: generates an internal companion memento type, exposes +//! it as `::Memento`, and emits the `Recallable` impl; with the +//! `impl_from` Cargo feature it also generates `From` for the memento type. //! -//! - `#[derive(Recall)]`: generates the `Recall` implementation and recursively -//! recalls fields annotated with `#[recallable]`. +//! - `#[derive(Recall)]`: generates the `Recall` implementation, recursively +//! recalls struct fields annotated with `#[recallable]`, and supports enums only +//! when every variant field is assignment-only. //! //! Feature flags are evaluated in the `recallable-macro` crate itself. See `context` //! for details about the generated memento struct and trait implementations. @@ -26,7 +28,8 @@ use syn::{DeriveInput, parse_macro_input}; mod context; mod model_macro; -/// Attribute macro that augments a struct with `Recallable`/`Recall` derives. +/// Attribute macro that augments a struct or assignment-only enum with +/// `Recallable`/`Recall` derives. /// /// - Always adds `#[derive(Recallable, Recall)]`. /// - When the `serde` feature is enabled for the macro crate, it also adds @@ -48,6 +51,11 @@ pub fn recallable_model(attr: TokenStream, item: TokenStream) -> TokenStream { /// Derive macro that generates the companion memento type and `Recallable` impl. /// +/// Supports structs directly. +/// Supports enums by generating an enum-shaped memento with matching variants. +/// For enums, `#[derive(Recall)]` and `#[recallable_model]` are available only +/// when every variant field is assignment-only. +/// /// The generated memento type: /// - mirrors the original struct shape (named/tuple/unit), /// - includes fields unless marked with `#[recallable(skip)]`, @@ -60,9 +68,9 @@ pub fn recallable_model(attr: TokenStream, item: TokenStream) -> TokenStream { /// `::Memento`. The macro does not prescribe one canonical container /// semantics; it uses whatever memento shape the field type defines. /// -/// The companion struct itself is generated as an internal implementation detail. The supported -/// way to name it is `::Memento`. It is intended to be produced and consumed -/// alongside the source struct, primarily through `Recall::recall`/`TryRecall::try_recall`, not as +/// The companion type itself is generated as an internal implementation detail. The supported +/// way to name it is `::Memento`. It is intended to be produced and consumed +/// alongside the source item, primarily through `Recall::recall`/`TryRecall::try_recall`, not as /// a field-inspection surface with widened visibility. /// /// The `Recallable` impl sets `type Memento` to that generated type and adds any required generic @@ -78,7 +86,7 @@ pub fn recallable_model(attr: TokenStream, item: TokenStream) -> TokenStream { /// still derived on the memento even with this attribute. /// /// When the `impl_from` feature is enabled for the macro crate, a -/// `From` implementation is also generated for the memento type. For `#[recallable]` +/// `From` implementation is also generated for the memento type. For `#[recallable]` /// fields, that additionally requires `::Memento: From`. #[proc_macro_derive(Recallable, attributes(recallable))] pub fn derive_recallable(input: TokenStream) -> TokenStream { @@ -125,6 +133,7 @@ pub fn derive_recallable(input: TokenStream) -> TokenStream { /// /// For `#[recallable]` fields, replace/merge behavior comes from the field type's own /// `Recall` implementation. +/// Enums are supported only when every variant field is assignment-only. pub fn derive_recall(input: TokenStream) -> TokenStream { let input: DeriveInput = parse_macro_input!(input as DeriveInput); let ir = match context::ItemIr::analyze(&input) { diff --git a/recallable/src/lib.rs b/recallable/src/lib.rs index 336969f..707d360 100644 --- a/recallable/src/lib.rs +++ b/recallable/src/lib.rs @@ -26,7 +26,7 @@ extern crate self as recallable; -/// Attribute macro that prepares a struct for the Memento pattern. +/// Attribute macro that prepares a struct or assignment-only enum for the Memento pattern. /// /// Adds `#[derive(Recallable, Recall)]` automatically. When the `serde` feature is enabled, /// also derives `serde::Serialize` on the struct and injects `#[serde(skip)]` on fields @@ -65,7 +65,12 @@ extern crate self as recallable; /// ``` pub use recallable_macro::recallable_model; -/// Derive macro that generates a companion memento struct and the [`Recallable`] trait impl. +/// Derive macro that generates a companion memento type and the [`Recallable`] trait impl. +/// +/// Supports structs directly. +/// Supports enums by generating an enum-shaped memento with matching variants. +/// For enums, `#[derive(Recall)]` and `#[recallable_model]` are available only +/// when every variant field is assignment-only. /// /// The memento struct mirrors the original but replaces `#[recallable]`-annotated fields /// with their `::Memento` type and omits `#[recallable(skip)]` fields. @@ -122,6 +127,9 @@ pub use recallable_macro::Recallable; /// Fields marked `#[recallable(skip)]` are left untouched. /// For `#[recallable]` fields, replace/merge behavior comes from the field type's own /// [`Recall`] implementation. +/// Enums are supported only when every variant field is assignment-only. +/// Enums with nested `#[recallable]` or skipped fields should derive +/// [`Recallable`] and implement [`Recall`] or [`TryRecall`] manually. /// /// Lifetime parameters are supported only when the generated memento can stay owned: /// non-skipped fields may not borrow one of the struct's lifetimes. Skipped borrowed From d1eaa3536a89575d19a3c8e32fe1734724e52b15 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 03:45:30 +0800 Subject: [PATCH 07/45] test: lock derive module split seams --- recallable-macro/src/context.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/recallable-macro/src/context.rs b/recallable-macro/src/context.rs index 8d5b892..f6f0b58 100644 --- a/recallable-macro/src/context.rs +++ b/recallable-macro/src/context.rs @@ -43,6 +43,36 @@ mod tests { use super::{CodegenEnv, ItemIr, gen_memento_type}; + #[test] + fn split_internal_reexports_cover_both_item_kinds() { + use crate::context::internal::{enums::EnumIr, shared::CodegenEnv, structs::StructIr}; + use syn::parse_quote; + + let struct_input: syn::DeriveInput = parse_quote! { + struct Example { + value: T, + } + }; + let enum_input: syn::DeriveInput = parse_quote! { + enum Choice { + One(T), + } + }; + + let struct_ir = StructIr::analyze(&struct_input).unwrap(); + let enum_ir = EnumIr::analyze(&enum_input).unwrap(); + let env = CodegenEnv::resolve(); + + assert_eq!(struct_ir.memento_name().to_string(), "ExampleMemento"); + assert_eq!(enum_ir.memento_name().to_string(), "ChoiceMemento"); + assert_eq!( + crate::context::memento::gen_memento_type(&crate::context::ItemIr::Struct(struct_ir), &env) + .to_string() + .contains("ExampleMemento"), + true + ); + } + #[test] fn facade_reexports_support_analysis_and_codegen() { let input: syn::DeriveInput = parse_quote! { From dfdb80ea75bd275120a41e17b16f50babfc3fe79 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 03:55:06 +0800 Subject: [PATCH 08/45] refactor(macro): split derive backend by item kind --- recallable-macro/src/context.rs | 8 +- .../src/context/from_impl/enums.rs | 153 +++++ recallable-macro/src/context/from_impl/mod.rs | 14 + .../src/context/from_impl/structs.rs | 116 ++++ recallable-macro/src/context/internal.rs | 27 +- .../src/context/internal/enums/bounds.rs | 65 ++ .../src/context/internal/enums/ir.rs | 336 +++++++++ .../src/context/internal/enums/mod.rs | 5 + recallable-macro/src/context/internal/ir.rs | 650 ------------------ .../src/context/internal/shared/bounds.rs | 102 +++ .../src/context/internal/shared/env.rs | 21 + .../context/internal/{ => shared}/fields.rs | 110 ++- .../context/internal/{ => shared}/generics.rs | 46 +- .../src/context/internal/shared/item.rs | 41 ++ .../context/internal/{ => shared}/lifetime.rs | 29 +- .../src/context/internal/shared/mod.rs | 14 + .../src/context/internal/shared/util.rs | 25 + .../context/internal/{ => structs}/bounds.rs | 168 +---- .../src/context/internal/structs/ir.rs | 284 ++++++++ .../src/context/internal/structs/mod.rs | 5 + recallable-macro/src/context/internal/util.rs | 34 - recallable-macro/src/context/memento/enums.rs | 109 +++ recallable-macro/src/context/memento/mod.rs | 14 + .../src/context/memento/structs.rs | 148 ++++ .../src/context/recall_impl/enums.rs | 27 + .../src/context/recall_impl/mod.rs | 14 + .../src/context/recall_impl/structs.rs | 83 +++ .../src/context/recallable_impl/enums.rs | 128 ++++ .../src/context/recallable_impl/mod.rs | 14 + .../src/context/recallable_impl/structs.rs | 31 + 30 files changed, 1837 insertions(+), 984 deletions(-) create mode 100644 recallable-macro/src/context/from_impl/enums.rs create mode 100644 recallable-macro/src/context/from_impl/mod.rs create mode 100644 recallable-macro/src/context/from_impl/structs.rs create mode 100644 recallable-macro/src/context/internal/enums/bounds.rs create mode 100644 recallable-macro/src/context/internal/enums/ir.rs create mode 100644 recallable-macro/src/context/internal/enums/mod.rs delete mode 100644 recallable-macro/src/context/internal/ir.rs create mode 100644 recallable-macro/src/context/internal/shared/bounds.rs create mode 100644 recallable-macro/src/context/internal/shared/env.rs rename recallable-macro/src/context/internal/{ => shared}/fields.rs (67%) rename recallable-macro/src/context/internal/{ => shared}/generics.rs (90%) create mode 100644 recallable-macro/src/context/internal/shared/item.rs rename recallable-macro/src/context/internal/{ => shared}/lifetime.rs (75%) create mode 100644 recallable-macro/src/context/internal/shared/mod.rs create mode 100644 recallable-macro/src/context/internal/shared/util.rs rename recallable-macro/src/context/internal/{ => structs}/bounds.rs (59%) create mode 100644 recallable-macro/src/context/internal/structs/ir.rs create mode 100644 recallable-macro/src/context/internal/structs/mod.rs delete mode 100644 recallable-macro/src/context/internal/util.rs create mode 100644 recallable-macro/src/context/memento/enums.rs create mode 100644 recallable-macro/src/context/memento/mod.rs create mode 100644 recallable-macro/src/context/memento/structs.rs create mode 100644 recallable-macro/src/context/recall_impl/enums.rs create mode 100644 recallable-macro/src/context/recall_impl/mod.rs create mode 100644 recallable-macro/src/context/recall_impl/structs.rs create mode 100644 recallable-macro/src/context/recallable_impl/enums.rs create mode 100644 recallable-macro/src/context/recallable_impl/mod.rs create mode 100644 recallable-macro/src/context/recallable_impl/structs.rs diff --git a/recallable-macro/src/context.rs b/recallable-macro/src/context.rs index f6f0b58..8992b69 100644 --- a/recallable-macro/src/context.rs +++ b/recallable-macro/src/context.rs @@ -10,8 +10,7 @@ mod from_impl; mod internal; -mod memento_enum; -mod memento_struct; +mod memento; mod recall_impl; mod recallable_impl; @@ -30,10 +29,7 @@ pub(super) const SERDE_ENABLED: bool = cfg!(feature = "serde"); pub(super) const IMPL_FROM_ENABLED: bool = cfg!(feature = "impl_from"); pub(crate) fn gen_memento_type(ir: &ItemIr, env: &CodegenEnv) -> proc_macro2::TokenStream { - match ir { - ItemIr::Struct(ir) => memento_struct::gen_memento_struct(ir, env), - ItemIr::Enum(ir) => memento_enum::gen_memento_enum(ir, env), - } + memento::gen_memento_type(ir, env) } #[cfg(test)] diff --git a/recallable-macro/src/context/from_impl/enums.rs b/recallable-macro/src/context/from_impl/enums.rs new file mode 100644 index 0000000..41b4491 --- /dev/null +++ b/recallable-macro/src/context/from_impl/enums.rs @@ -0,0 +1,153 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::{ToTokens, format_ident, quote}; +use syn::WherePredicate; + +use crate::context::{ + CodegenEnv, EnumIr, FieldIr, FieldMember, FieldStrategy, VariantIr, VariantShape, + collect_shared_memento_bounds_for_enum, +}; + +#[must_use] +pub(crate) fn gen_enum_from_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { + let impl_generics = ir.impl_generics(); + let enum_type = ir.enum_type(); + let memento_type = ir.memento_type(); + let where_clause = build_enum_from_where_clause(ir, env); + let from_method = build_enum_from_method(ir); + + quote! { + impl #impl_generics ::core::convert::From<#enum_type> + for #memento_type + #where_clause { + #from_method + } + } +} + +fn build_enum_from_method(ir: &EnumIr) -> TokenStream2 { + let enum_type = ir.enum_type(); + let fn_body = build_enum_from_body(ir); + + quote! { + #[inline] + fn from(value: #enum_type) -> Self { + #fn_body + } + } +} + +fn build_enum_from_body(ir: &EnumIr) -> TokenStream2 { + let enum_name = ir.name(); + let memento_name = ir.memento_name(); + let arms = ir.variants().map(|variant| { + let variant_name = variant.name; + let pattern = build_variant_source_pattern(variant); + let expr = build_variant_from_expr(variant); + quote! { #enum_name::#variant_name #pattern => #memento_name::#variant_name #expr } + }); + + quote! { + match value { + #(#arms),* + } + } +} + +fn build_variant_source_pattern(variant: &VariantIr<'_>) -> TokenStream2 { + match variant.shape { + VariantShape::Named => { + let patterns = variant.fields.iter().enumerate().map(|(index, field)| { + let member = &field.member; + let binding = build_binding_pattern(field, index); + quote! { #member: #binding } + }); + quote! { { #(#patterns),* } } + } + VariantShape::Unnamed => { + let patterns = variant + .fields + .iter() + .enumerate() + .map(|(index, field)| build_binding_pattern(field, index)); + quote! { ( #(#patterns),* ) } + } + VariantShape::Unit => quote! {}, + } +} + +fn build_binding_pattern(field: &FieldIr<'_>, index: usize) -> TokenStream2 { + if field.strategy.is_skip() { + quote! { _ } + } else { + build_binding_ident(field, index).to_token_stream() + } +} + +fn build_binding_ident(field: &FieldIr<'_>, index: usize) -> syn::Ident { + match &field.member { + FieldMember::Named(name) => (*name).clone(), + FieldMember::Unnamed(_) => format_ident!("__recallable_field_{index}"), + } +} + +fn build_variant_from_expr(variant: &VariantIr<'_>) -> TokenStream2 { + let kept_fields: Vec<_> = variant + .fields + .iter() + .enumerate() + .filter(|(_, field)| !field.strategy.is_skip()) + .collect(); + + if kept_fields.is_empty() { + return quote! {}; + } + + match variant.shape { + VariantShape::Named => { + let inits = kept_fields.into_iter().map(|(index, field)| { + let member = &field.member; + let binding = build_binding_ident(field, index); + let value = build_from_binding_expr(field, &binding); + quote! { #member: #value } + }); + quote! { { #(#inits),* } } + } + VariantShape::Unnamed => { + let values = kept_fields.into_iter().map(|(index, field)| { + let binding = build_binding_ident(field, index); + build_from_binding_expr(field, &binding) + }); + quote! { ( #(#values),* ) } + } + VariantShape::Unit => quote! {}, + } +} + +fn build_from_binding_expr(field: &FieldIr<'_>, binding: &syn::Ident) -> TokenStream2 { + match &field.strategy { + FieldStrategy::StoreAsSelf => quote! { #binding }, + FieldStrategy::StoreAsMemento => quote! { ::core::convert::From::from(#binding) }, + FieldStrategy::Skip => unreachable!("filtered above"), + } +} + +fn build_enum_from_where_clause(ir: &EnumIr, env: &CodegenEnv) -> Option { + let bounds = collect_enum_from_bounds(ir, env); + ir.extend_where_clause(bounds) +} + +fn collect_enum_from_bounds(ir: &EnumIr, env: &CodegenEnv) -> Vec { + let recallable_trait = &env.recallable_trait; + let mut bounds: Vec<_> = ir + .recallable_params() + .flat_map(|ty| -> [WherePredicate; 2] { + [ + syn::parse_quote! { #ty: #recallable_trait }, + syn::parse_quote! { #ty::Memento: ::core::convert::From<#ty> }, + ] + }) + .collect(); + bounds.extend(collect_shared_memento_bounds_for_enum(ir, env)); + bounds.extend(ir.whole_type_from_bounds(recallable_trait)); + bounds +} diff --git a/recallable-macro/src/context/from_impl/mod.rs b/recallable-macro/src/context/from_impl/mod.rs new file mode 100644 index 0000000..f4f4480 --- /dev/null +++ b/recallable-macro/src/context/from_impl/mod.rs @@ -0,0 +1,14 @@ +mod enums; +mod structs; + +use proc_macro2::TokenStream as TokenStream2; + +use crate::context::{CodegenEnv, ItemIr}; + +#[must_use] +pub(crate) fn gen_from_impl(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { + match ir { + ItemIr::Struct(ir) => structs::gen_struct_from_impl(ir, env), + ItemIr::Enum(ir) => enums::gen_enum_from_impl(ir, env), + } +} diff --git a/recallable-macro/src/context/from_impl/structs.rs b/recallable-macro/src/context/from_impl/structs.rs new file mode 100644 index 0000000..e9f8168 --- /dev/null +++ b/recallable-macro/src/context/from_impl/structs.rs @@ -0,0 +1,116 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::WherePredicate; + +use crate::context::{ + CodegenEnv, FieldIr, FieldStrategy, StructIr, StructShape, collect_shared_memento_bounds, +}; + +#[must_use] +pub(crate) fn gen_struct_from_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { + let impl_generics = ir.impl_generics(); + let struct_type = ir.struct_type(); + let memento_type = ir.memento_type(); + let where_clause = build_from_where_clause(ir, env); + let from_method = build_from_method(ir); + + quote! { + impl #impl_generics ::core::convert::From<#struct_type> + for #memento_type + #where_clause { + #from_method + } + } +} + +fn build_from_method(ir: &StructIr) -> TokenStream2 { + let struct_type = ir.struct_type(); + let fn_body = build_from_body(ir); + + quote! { + #[inline] + fn from(value: #struct_type) -> Self { + #fn_body + } + } +} + +fn build_from_body(ir: &StructIr) -> TokenStream2 { + match ir.shape() { + StructShape::Named => build_named_from_body(ir), + StructShape::Unnamed => build_unnamed_from_body(ir), + StructShape::Unit => build_unit_from_body(ir), + } +} + +fn build_named_from_body(ir: &StructIr) -> TokenStream2 { + let inits = ir + .memento_fields() + .map(build_named_from_field) + .chain(ir.has_synthetic_marker().then(build_named_marker_init)); + + quote! { Self { #(#inits),* } } +} + +fn build_named_from_field(field: &FieldIr) -> TokenStream2 { + let member = &field.member; + let value = build_from_expr(field); + quote! { #member: #value } +} + +fn build_unnamed_from_body(ir: &StructIr) -> TokenStream2 { + let values = ir + .memento_fields() + .map(build_from_expr) + .chain(ir.has_synthetic_marker().then(build_marker_init)); + + quote! { Self(#(#values),*) } +} + +fn build_unit_from_body(ir: &StructIr) -> TokenStream2 { + if ir.has_synthetic_marker() { + quote! { Self { _recallable_marker: ::core::marker::PhantomData } } + } else { + quote! { Self } + } +} + +fn build_marker_init() -> TokenStream2 { + quote! { ::core::marker::PhantomData } +} + +fn build_named_marker_init() -> TokenStream2 { + quote! { _recallable_marker: ::core::marker::PhantomData } +} + +fn build_from_expr(field: &FieldIr) -> TokenStream2 { + let member = &field.member; + match &field.strategy { + FieldStrategy::StoreAsSelf => quote! { value.#member }, + FieldStrategy::StoreAsMemento => { + quote! { ::core::convert::From::from(value.#member) } + } + FieldStrategy::Skip => unreachable!("memento_fields() filters skipped fields"), + } +} + +fn build_from_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { + let bounds = collect_from_bounds(ir, env); + ir.extend_where_clause(bounds) +} + +fn collect_from_bounds(ir: &StructIr, env: &CodegenEnv) -> Vec { + let recallable_trait = &env.recallable_trait; + let mut bounds: Vec<_> = ir + .recallable_params() + .flat_map(|ty| -> [WherePredicate; 2] { + [ + syn::parse_quote! { #ty: #recallable_trait }, + syn::parse_quote! { #ty::Memento: ::core::convert::From<#ty> }, + ] + }) + .collect(); + bounds.extend(collect_shared_memento_bounds(ir, env)); + bounds.extend(ir.whole_type_from_bounds(recallable_trait)); + bounds +} diff --git a/recallable-macro/src/context/internal.rs b/recallable-macro/src/context/internal.rs index 8705948..1fc493f 100644 --- a/recallable-macro/src/context/internal.rs +++ b/recallable-macro/src/context/internal.rs @@ -1,20 +1,17 @@ //! Semantic analysis and shared helper backend for the `context` codegen facade. -mod bounds; -mod fields; -mod generics; -mod ir; -mod lifetime; -mod util; +pub(crate) mod enums; +pub(crate) mod shared; +pub(crate) mod structs; -pub(crate) use bounds::{ - collect_recall_like_bounds, collect_recall_like_bounds_for_enum, - collect_shared_memento_bounds, collect_shared_memento_bounds_for_enum, +pub(crate) use enums::{ + EnumIr, EnumRecallMode, VariantIr, VariantShape, collect_recall_like_bounds_for_enum, + collect_shared_memento_bounds_for_enum, }; -pub(crate) use fields::has_recallable_skip_attr; -pub(crate) use generics::is_generic_type_param; -pub(crate) use ir::{ - CodegenEnv, EnumIr, EnumRecallMode, FieldIr, FieldMember, FieldStrategy, ItemIr, StructIr, - StructShape, VariantIr, VariantShape, +pub(crate) use shared::{ + CodegenEnv, FieldIr, FieldMember, FieldStrategy, ItemIr, crate_path, + has_recallable_skip_attr, is_generic_type_param, +}; +pub(crate) use structs::{ + StructIr, StructShape, collect_recall_like_bounds, collect_shared_memento_bounds, }; -pub(crate) use util::crate_path; diff --git a/recallable-macro/src/context/internal/enums/bounds.rs b/recallable-macro/src/context/internal/enums/bounds.rs new file mode 100644 index 0000000..7c94080 --- /dev/null +++ b/recallable-macro/src/context/internal/enums/bounds.rs @@ -0,0 +1,65 @@ +use proc_macro2::TokenStream as TokenStream2; +use syn::WherePredicate; + +use crate::context::internal::enums::EnumIr; +use crate::context::internal::shared::{CodegenEnv, MementoTraitSpec}; + +#[must_use] +pub(crate) fn collect_shared_memento_bounds_for_enum( + ir: &EnumIr, + env: &CodegenEnv, +) -> Vec { + collect_shared_memento_bounds_with_spec_for_enum(ir, env, &ir.memento_trait_spec()) +} + +#[must_use] +pub(crate) fn collect_recall_like_bounds_for_enum( + ir: &EnumIr, + env: &CodegenEnv, + direct_bound: &TokenStream2, +) -> Vec { + collect_recall_like_bounds_with_spec_for_enum(ir, env, direct_bound, &ir.memento_trait_spec()) +} + +fn collect_shared_memento_bounds_with_spec_for_enum( + ir: &EnumIr, + env: &CodegenEnv, + memento_trait_spec: &MementoTraitSpec, +) -> Vec { + let recallable_trait = &env.recallable_trait; + let memento_trait_bounds = memento_trait_spec.common_bound_tokens(); + + let mut bounds = ir.recallable_memento_bounds(&memento_trait_bounds); + bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &memento_trait_bounds)); + if let Some(deserialize_owned) = memento_trait_spec.serde_nested_bound() { + bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &deserialize_owned)); + } + + bounds +} + +fn collect_recall_like_bounds_with_spec_for_enum( + ir: &EnumIr, + env: &CodegenEnv, + direct_bound: &TokenStream2, + memento_trait_spec: &MementoTraitSpec, +) -> Vec { + let shared_memento_bounds = + collect_shared_memento_bounds_with_spec_for_enum(ir, env, memento_trait_spec); + let shared_param_bound_count = ir.recallable_params().count(); + + let mut bounds = ir.recallable_bounds(direct_bound); + bounds.extend( + shared_memento_bounds + .iter() + .take(shared_param_bound_count) + .cloned(), + ); + bounds.extend(ir.whole_type_bounds(direct_bound)); + bounds.extend( + shared_memento_bounds + .into_iter() + .skip(shared_param_bound_count), + ); + bounds +} diff --git a/recallable-macro/src/context/internal/enums/ir.rs b/recallable-macro/src/context/internal/enums/ir.rs new file mode 100644 index 0000000..5e48de0 --- /dev/null +++ b/recallable-macro/src/context/internal/enums/ir.rs @@ -0,0 +1,336 @@ +use std::collections::HashSet; + +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::{ + DeriveInput, Generics, Ident, ImplGenerics, Type, Visibility, WhereClause, WherePredicate, +}; + +use crate::context::SERDE_ENABLED; +use crate::context::internal::shared::bounds::MementoTraitSpec; +use crate::context::internal::shared::fields::{FieldIr, FieldStrategy, collect_field_irs}; +use crate::context::internal::shared::generics::{ + GenericParamPlan, GenericParamLookup, collect_variant_marker_param_indices, + is_generic_type_param, marker_component, plan_memento_generics, +}; +use crate::context::internal::shared::item::has_skip_memento_default_derives; +use crate::context::internal::shared::lifetime::{ + collect_struct_lifetimes, validate_no_borrowed_fields, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum VariantShape { + Named, + Unnamed, + Unit, +} + +#[derive(Debug)] +pub(crate) struct VariantIr<'a> { + pub(crate) name: &'a Ident, + pub(crate) shape: VariantShape, + pub(crate) fields: Vec>, +} + +#[derive(Debug)] +pub(crate) struct EnumIr<'a> { + name: &'a Ident, + visibility: &'a Visibility, + generics: &'a Generics, + variants: Vec>, + memento_name: Ident, + generic_type_param_idents: HashSet<&'a Ident>, + generic_params: Vec>, + memento_where_clause: Option, + marker_param_indices: Vec, + skip_memento_default_derives: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum EnumRecallMode { + AssignmentOnly, + ManualOnly, +} + +fn extract_enum_variants( + input: &DeriveInput, +) -> syn::Result<&syn::punctuated::Punctuated> { + if let syn::Data::Enum(data) = &input.data { + Ok(&data.variants) + } else { + Err(syn::Error::new_spanned( + input, + "This derive macro can only be applied to structs or enums", + )) + } +} + +fn collect_variant_irs<'a>( + variants: &'a syn::punctuated::Punctuated, + struct_lifetimes: &HashSet<&'a syn::Ident>, + generic_lookup: &GenericParamLookup<'a>, +) -> syn::Result<( + crate::context::internal::shared::generics::GenericUsage, + Vec>, +)> { + let mut usage = crate::context::internal::shared::generics::GenericUsage::default(); + let mut variant_irs = Vec::with_capacity(variants.len()); + + for variant in variants { + let (variant_usage, fields) = + collect_field_irs(&variant.fields, struct_lifetimes, generic_lookup)?; + usage.retained.extend(variant_usage.retained); + usage + .recallable_type_params + .extend(variant_usage.recallable_type_params); + + let shape = match &variant.fields { + syn::Fields::Named(_) => VariantShape::Named, + syn::Fields::Unnamed(_) => VariantShape::Unnamed, + syn::Fields::Unit => VariantShape::Unit, + }; + + variant_irs.push(VariantIr { + name: &variant.ident, + shape, + fields, + }); + } + + Ok((usage, variant_irs)) +} + +impl<'a> EnumIr<'a> { + pub(crate) fn analyze(input: &'a DeriveInput) -> syn::Result { + let variants = extract_enum_variants(input)?; + let struct_lifetimes = collect_struct_lifetimes(&input.generics); + for variant in variants { + validate_no_borrowed_fields(&variant.fields, &struct_lifetimes)?; + } + + let generic_lookup = GenericParamLookup::new(&input.generics); + let generic_type_param_idents = input + .generics + .type_params() + .map(|param| ¶m.ident) + .collect(); + let (usage, variant_irs) = + collect_variant_irs(variants, &struct_lifetimes, &generic_lookup)?; + let (generic_params, memento_where_clause) = + plan_memento_generics(&input.generics, usage, &generic_lookup); + let marker_param_indices = + collect_variant_marker_param_indices(&variant_irs, &generic_params, &generic_lookup); + + Ok(Self { + name: &input.ident, + visibility: &input.vis, + generics: &input.generics, + variants: variant_irs, + memento_name: quote::format_ident!("{}Memento", input.ident), + generic_type_param_idents, + generic_params, + memento_where_clause, + marker_param_indices, + skip_memento_default_derives: has_skip_memento_default_derives(input)?, + }) + } + + pub(crate) const fn name(&self) -> &'a Ident { + self.name + } + + pub(crate) fn enum_type(&self) -> TokenStream2 { + let name = &self.name; + let (_, type_generics, _) = self.generics.split_for_impl(); + quote! { #name #type_generics } + } + + pub(crate) const fn memento_name(&self) -> &Ident { + &self.memento_name + } + + pub(crate) const fn visibility(&self) -> &'a Visibility { + self.visibility + } + + pub(crate) fn impl_generics(&self) -> ImplGenerics<'_> { + let (impl_generics, _, _) = self.generics.split_for_impl(); + impl_generics + } + + pub(crate) const fn generic_type_param_idents(&self) -> &HashSet<&'a Ident> { + &self.generic_type_param_idents + } + + #[must_use] + pub(crate) const fn memento_trait_spec(&self) -> MementoTraitSpec { + MementoTraitSpec::new(SERDE_ENABLED, self.skip_memento_default_derives) + } + + #[must_use] + pub(crate) fn memento_decl_generics(&self) -> TokenStream2 { + let mut params = self + .generic_params + .iter() + .filter(|plan| plan.is_retained()) + .map(GenericParamPlan::decl_param) + .peekable(); + + if params.peek().is_none() { + quote! {} + } else { + quote! { <#(#params),*> } + } + } + + pub(crate) const fn memento_where_clause(&self) -> Option<&WhereClause> { + self.memento_where_clause.as_ref() + } + + #[must_use] + pub(crate) fn synthetic_marker_type(&self) -> Option { + if self.marker_param_indices.is_empty() { + return None; + } + + let components = self + .marker_param_indices + .iter() + .map(|&index| marker_component(self.generic_params[index].param)); + + Some(quote! { + ::core::marker::PhantomData<(#(#components,)*)> + }) + } + + pub(crate) fn recallable_params(&self) -> impl Iterator { + self.generic_params + .iter() + .filter_map(GenericParamPlan::recallable_ident) + } + + #[must_use] + pub(crate) fn memento_type(&self) -> TokenStream2 { + let name = &self.memento_name; + let mut args = self + .generic_params + .iter() + .filter(|plan| plan.is_retained()) + .map(GenericParamPlan::type_arg) + .peekable(); + + if args.peek().is_none() { + quote! { #name } + } else { + quote! { #name<#(#args),*> } + } + } + + pub(crate) fn variants(&self) -> impl Iterator> { + self.variants.iter() + } + + pub(crate) fn recall_mode(&self) -> EnumRecallMode { + if self + .variants + .iter() + .flat_map(|variant| variant.fields.iter()) + .any(|field| !matches!(field.strategy, FieldStrategy::StoreAsSelf)) + { + EnumRecallMode::ManualOnly + } else { + EnumRecallMode::AssignmentOnly + } + } + + pub(crate) fn ensure_recall_derivable(&self) -> syn::Result<()> { + if let Some(field) = self + .variants + .iter() + .flat_map(|variant| variant.fields.iter()) + .find(|field| !matches!(field.strategy, FieldStrategy::StoreAsSelf)) + { + return Err(syn::Error::new_spanned( + field.source, + "enum `Recall` derive only supports assignment-only variant fields", + )); + } + + Ok(()) + } + + pub(crate) fn recallable_bounds(&self, bound: &TokenStream2) -> Vec { + self.recallable_params() + .map(|ty| syn::parse_quote! { #ty: #bound }) + .collect() + } + + pub(crate) fn recallable_memento_bounds(&self, bound: &TokenStream2) -> Vec { + self.recallable_params() + .map(|ty| syn::parse_quote! { #ty::Memento: #bound }) + .collect() + } + + fn whole_type_bound_targets(&self) -> impl Iterator { + let mut seen = HashSet::new(); + + self.variants + .iter() + .flat_map(|variant| variant.fields.iter()) + .filter_map(move |field| match field.strategy { + FieldStrategy::StoreAsMemento + if !is_generic_type_param(field.ty, &self.generic_type_param_idents) + && seen.insert(field.ty) => + { + Some(field.ty) + } + _ => None, + }) + } + + #[must_use] + pub(crate) fn whole_type_bounds(&self, bound: &TokenStream2) -> Vec { + self.whole_type_bound_targets() + .map(|ty| syn::parse_quote! { #ty: #bound }) + .collect() + } + + pub(crate) fn whole_type_memento_bounds( + &self, + recallable_trait: &TokenStream2, + bound: &TokenStream2, + ) -> impl Iterator { + self.whole_type_bound_targets() + .map(move |ty| syn::parse_quote! { <#ty as #recallable_trait>::Memento: #bound }) + } + + pub(crate) fn whole_type_from_bounds( + &self, + recallable_trait: &TokenStream2, + ) -> impl Iterator { + self.whole_type_bound_targets().flat_map(move |ty| { + [ + syn::parse_quote! { #ty: #recallable_trait }, + syn::parse_quote! { <#ty as #recallable_trait>::Memento: ::core::convert::From<#ty> }, + ] + }) + } + + pub(crate) fn extend_where_clause( + &self, + extra: impl IntoIterator, + ) -> Option { + let mut where_clause = self.generics.where_clause.clone(); + let mut extra_iter = extra.into_iter().peekable(); + if extra_iter.peek().is_none() { + where_clause + } else { + where_clause + .get_or_insert(syn::parse_quote! { where }) + .predicates + .extend(extra_iter); + + where_clause + } + } +} diff --git a/recallable-macro/src/context/internal/enums/mod.rs b/recallable-macro/src/context/internal/enums/mod.rs new file mode 100644 index 0000000..e3fccea --- /dev/null +++ b/recallable-macro/src/context/internal/enums/mod.rs @@ -0,0 +1,5 @@ +mod bounds; +mod ir; + +pub(crate) use bounds::{collect_recall_like_bounds_for_enum, collect_shared_memento_bounds_for_enum}; +pub(crate) use ir::{EnumIr, EnumRecallMode, VariantIr, VariantShape}; diff --git a/recallable-macro/src/context/internal/ir.rs b/recallable-macro/src/context/internal/ir.rs deleted file mode 100644 index 6e46529..0000000 --- a/recallable-macro/src/context/internal/ir.rs +++ /dev/null @@ -1,650 +0,0 @@ -use std::collections::HashSet; - -use proc_macro2::TokenStream as TokenStream2; -use quote::{ToTokens, quote}; -use syn::{ - DeriveInput, Field, Fields, Generics, Ident, ImplGenerics, Index, Type, Visibility, - WhereClause, WherePredicate, -}; - -use crate::context::SERDE_ENABLED; - -use super::bounds::MementoTraitSpec; - -use super::fields::{ - collect_field_irs, collect_variant_irs, extract_enum_variants, extract_struct_fields, -}; -use super::generics::{ - GenericParamPlan, collect_marker_param_indices, collect_variant_marker_param_indices, - is_generic_type_param, marker_component, plan_memento_generics, -}; -use super::lifetime::{collect_struct_lifetimes, validate_no_borrowed_fields}; -use super::util::{crate_path, is_recallable_attr}; - -/// The structural shape of the source struct and generated memento. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum StructShape { - Named, - Unnamed, - Unit, -} - -impl StructShape { - const fn from_fields(fields: &Fields) -> Self { - match fields { - Fields::Named(_) => Self::Named, - Fields::Unnamed(_) => Self::Unnamed, - Fields::Unit => Self::Unit, - } - } -} - -/// How a field is represented in the generated memento. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum FieldStrategy { - /// Field excluded from memento entirely. - Skip, - /// Field copied as-is into memento (type unchanged). - StoreAsSelf, - /// Field stored as its memento type, recalled recursively. - StoreAsMemento, -} - -impl FieldStrategy { - pub(crate) const fn is_skip(self) -> bool { - matches!(self, Self::Skip) - } -} - -#[derive(Debug)] -pub(crate) struct CodegenEnv { - /// Fully qualified path to the `Recallable` trait. - pub(crate) recallable_trait: TokenStream2, - /// Fully qualified path to the `Recall` trait. - pub(crate) recall_trait: TokenStream2, -} - -impl CodegenEnv { - #[must_use] - pub(crate) fn resolve() -> Self { - let crate_path = crate_path(); - Self { - recallable_trait: quote! { #crate_path::Recallable }, - recall_trait: quote! { #crate_path::Recall }, - } - } -} - -#[derive(Debug)] -pub(crate) struct FieldIr<'a> { - pub(crate) source: &'a Field, - pub(crate) memento_index: Option, - pub(crate) member: FieldMember<'a>, - pub(crate) ty: &'a Type, - pub(crate) strategy: FieldStrategy, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum VariantShape { - Named, - Unnamed, - Unit, -} - -#[derive(Debug)] -pub(crate) struct VariantIr<'a> { - pub(crate) name: &'a Ident, - pub(crate) shape: VariantShape, - pub(crate) fields: Vec>, -} - -/// How a field member is referenced in generated tokens. -#[derive(Debug, Clone)] -pub(crate) enum FieldMember<'a> { - /// Access by named field, such as `value`. - Named(&'a Ident), - /// Access by tuple-field index, such as `.0`. - Unnamed(Index), -} - -impl<'a> ToTokens for FieldMember<'a> { - fn to_tokens(&self, tokens: &mut TokenStream2) { - match self { - FieldMember::Named(ident) => ident.to_tokens(tokens), - FieldMember::Unnamed(index) => index.to_tokens(tokens), - } - } -} - -#[derive(Debug)] -pub(crate) struct StructIr<'a> { - name: &'a Ident, - visibility: &'a Visibility, - generics: &'a Generics, - shape: StructShape, - fields: Vec>, - memento_name: Ident, - generic_type_param_idents: HashSet<&'a Ident>, - generic_params: Vec>, - memento_where_clause: Option, - marker_param_indices: Vec, - skip_memento_default_derives: bool, -} - -#[derive(Debug)] -pub(crate) struct EnumIr<'a> { - name: &'a Ident, - visibility: &'a Visibility, - generics: &'a Generics, - variants: Vec>, - memento_name: Ident, - generic_type_param_idents: HashSet<&'a Ident>, - generic_params: Vec>, - memento_where_clause: Option, - marker_param_indices: Vec, - skip_memento_default_derives: bool, -} - -#[derive(Debug)] -pub(crate) enum ItemIr<'a> { - Struct(StructIr<'a>), - Enum(EnumIr<'a>), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum EnumRecallMode { - AssignmentOnly, - ManualOnly, -} - -fn has_skip_memento_default_derives(input: &DeriveInput) -> syn::Result { - let mut skip_memento_default_derives = false; - for attr in input.attrs.iter().filter(|a| is_recallable_attr(a)) { - attr.parse_nested_meta(|meta| { - if meta.path.is_ident("skip_memento_default_derives") { - skip_memento_default_derives = true; - Ok(()) - } else if meta.path.is_ident("skip") { - Err(meta.error("`skip` is a field-level attribute, not a struct-level attribute")) - } else { - Err(meta.error("unrecognized `recallable` parameter")) - } - })?; - } - Ok(skip_memento_default_derives) -} - -impl<'a> ItemIr<'a> { - pub(crate) fn analyze(input: &'a DeriveInput) -> syn::Result { - match &input.data { - syn::Data::Struct(_) => Ok(Self::Struct(StructIr::analyze(input)?)), - syn::Data::Enum(_) => Ok(Self::Enum(EnumIr::analyze( - input, - extract_enum_variants(input)?, - )?)), - _ => Err(syn::Error::new_spanned( - input, - "This derive macro can only be applied to structs or enums", - )), - } - } -} - -impl<'a> EnumIr<'a> { - pub(crate) fn analyze( - input: &'a DeriveInput, - variants: &'a syn::punctuated::Punctuated, - ) -> syn::Result { - let struct_lifetimes = collect_struct_lifetimes(&input.generics); - for variant in variants { - validate_no_borrowed_fields(&variant.fields, &struct_lifetimes)?; - } - - let generic_lookup = super::generics::GenericParamLookup::new(&input.generics); - let generic_type_param_idents = input - .generics - .type_params() - .map(|param| ¶m.ident) - .collect(); - let (usage, variant_irs) = - collect_variant_irs(variants, &struct_lifetimes, &generic_lookup)?; - let (generic_params, memento_where_clause) = - plan_memento_generics(&input.generics, usage, &generic_lookup); - let marker_param_indices = - collect_variant_marker_param_indices(&variant_irs, &generic_params, &generic_lookup); - - Ok(Self { - name: &input.ident, - visibility: &input.vis, - generics: &input.generics, - variants: variant_irs, - memento_name: quote::format_ident!("{}Memento", input.ident), - generic_type_param_idents, - generic_params, - memento_where_clause, - marker_param_indices, - skip_memento_default_derives: has_skip_memento_default_derives(input)?, - }) - } - - pub(crate) const fn name(&self) -> &'a Ident { - self.name - } - - pub(crate) fn enum_type(&self) -> TokenStream2 { - let name = &self.name; - let (_, type_generics, _) = self.generics.split_for_impl(); - quote! { #name #type_generics } - } - - pub(crate) const fn memento_name(&self) -> &Ident { - &self.memento_name - } - - pub(crate) const fn visibility(&self) -> &'a Visibility { - self.visibility - } - - pub(crate) fn impl_generics(&self) -> ImplGenerics<'_> { - let (impl_generics, _, _) = self.generics.split_for_impl(); - impl_generics - } - - pub(crate) const fn generic_type_param_idents(&self) -> &HashSet<&'a Ident> { - &self.generic_type_param_idents - } - - #[must_use] - pub(crate) const fn memento_trait_spec(&self) -> MementoTraitSpec { - MementoTraitSpec::new(SERDE_ENABLED, self.skip_memento_default_derives) - } - - #[must_use] - pub(crate) fn memento_decl_generics(&self) -> TokenStream2 { - let mut params = self - .generic_params - .iter() - .filter(|plan| plan.is_retained()) - .map(GenericParamPlan::decl_param) - .peekable(); - - if params.peek().is_none() { - quote! {} - } else { - quote! { <#(#params),*> } - } - } - - pub(crate) const fn memento_where_clause(&self) -> Option<&WhereClause> { - self.memento_where_clause.as_ref() - } - - #[must_use] - pub(crate) fn synthetic_marker_type(&self) -> Option { - if self.marker_param_indices.is_empty() { - return None; - } - - let components = self - .marker_param_indices - .iter() - .map(|&index| marker_component(self.generic_params[index].param)); - - Some(quote! { - ::core::marker::PhantomData<(#(#components,)*)> - }) - } - - pub(crate) fn recallable_params(&self) -> impl Iterator { - self.generic_params - .iter() - .filter_map(GenericParamPlan::recallable_ident) - } - - #[must_use] - pub(crate) fn memento_type(&self) -> TokenStream2 { - let name = &self.memento_name; - let mut args = self - .generic_params - .iter() - .filter(|plan| plan.is_retained()) - .map(GenericParamPlan::type_arg) - .peekable(); - - if args.peek().is_none() { - quote! { #name } - } else { - quote! { #name<#(#args),*> } - } - } - - pub(crate) fn variants(&self) -> impl Iterator> { - self.variants.iter() - } - - pub(crate) fn recall_mode(&self) -> EnumRecallMode { - if self - .variants - .iter() - .flat_map(|variant| variant.fields.iter()) - .any(|field| !matches!(field.strategy, FieldStrategy::StoreAsSelf)) - { - EnumRecallMode::ManualOnly - } else { - EnumRecallMode::AssignmentOnly - } - } - - pub(crate) fn ensure_recall_derivable(&self) -> syn::Result<()> { - if let Some(field) = self - .variants - .iter() - .flat_map(|variant| variant.fields.iter()) - .find(|field| !matches!(field.strategy, FieldStrategy::StoreAsSelf)) - { - return Err(syn::Error::new_spanned( - field.source, - "enum `Recall` derive only supports assignment-only variant fields", - )); - } - - Ok(()) - } - - pub(super) fn recallable_bounds(&self, bound: &TokenStream2) -> Vec { - self.recallable_params() - .map(|ty| syn::parse_quote! { #ty: #bound }) - .collect() - } - - pub(super) fn recallable_memento_bounds(&self, bound: &TokenStream2) -> Vec { - self.recallable_params() - .map(|ty| syn::parse_quote! { #ty::Memento: #bound }) - .collect() - } - - fn whole_type_bound_targets(&self) -> impl Iterator { - let mut seen = HashSet::new(); - - self.variants - .iter() - .flat_map(|variant| variant.fields.iter()) - .filter_map(move |field| match field.strategy { - FieldStrategy::StoreAsMemento - if !is_generic_type_param(field.ty, &self.generic_type_param_idents) - && seen.insert(field.ty) => - { - Some(field.ty) - } - _ => None, - }) - } - - #[must_use] - pub(super) fn whole_type_bounds(&self, bound: &TokenStream2) -> Vec { - self.whole_type_bound_targets() - .map(|ty| syn::parse_quote! { #ty: #bound }) - .collect() - } - - pub(super) fn whole_type_memento_bounds( - &self, - recallable_trait: &TokenStream2, - bound: &TokenStream2, - ) -> impl Iterator { - self.whole_type_bound_targets() - .map(move |ty| syn::parse_quote! { <#ty as #recallable_trait>::Memento: #bound }) - } - - pub(crate) fn whole_type_from_bounds( - &self, - recallable_trait: &TokenStream2, - ) -> impl Iterator { - self.whole_type_bound_targets().flat_map(move |ty| { - [ - syn::parse_quote! { #ty: #recallable_trait }, - syn::parse_quote! { <#ty as #recallable_trait>::Memento: ::core::convert::From<#ty> }, - ] - }) - } - - pub(crate) fn extend_where_clause( - &self, - extra: impl IntoIterator, - ) -> Option { - let mut where_clause = self.generics.where_clause.clone(); - let mut extra_iter = extra.into_iter().peekable(); - if extra_iter.peek().is_none() { - where_clause - } else { - where_clause - .get_or_insert(syn::parse_quote! { where }) - .predicates - .extend(extra_iter); - - where_clause - } - } -} - -impl<'a> StructIr<'a> { - pub(crate) fn analyze(input: &'a DeriveInput) -> syn::Result { - let fields = extract_struct_fields(input)?; - let struct_lifetimes = collect_struct_lifetimes(&input.generics); - validate_no_borrowed_fields(fields, &struct_lifetimes)?; - - let shape = StructShape::from_fields(fields); - let memento_name = quote::format_ident!("{}Memento", input.ident); - let generic_lookup = super::generics::GenericParamLookup::new(&input.generics); - let generic_type_param_idents = input - .generics - .type_params() - .map(|param| ¶m.ident) - .collect(); - let (usage, field_irs) = collect_field_irs(fields, &struct_lifetimes, &generic_lookup)?; - let (generic_params, memento_where_clause) = - plan_memento_generics(&input.generics, usage, &generic_lookup); - let marker_param_indices = - collect_marker_param_indices(&field_irs, &generic_params, &generic_lookup); - let skip_memento_default_derives = has_skip_memento_default_derives(input)?; - - Ok(Self { - name: &input.ident, - visibility: &input.vis, - generics: &input.generics, - shape, - fields: field_irs, - memento_name, - generic_type_param_idents, - generic_params, - memento_where_clause, - marker_param_indices, - skip_memento_default_derives, - }) - } - - pub(crate) fn struct_type(&self) -> TokenStream2 { - let name = &self.name; - let (_, type_generics, _) = self.generics.split_for_impl(); - quote! { #name #type_generics } - } - - pub(crate) const fn memento_name(&self) -> &Ident { - &self.memento_name - } - - pub(crate) const fn visibility(&self) -> &'a Visibility { - self.visibility - } - - #[must_use] - pub(crate) const fn shape(&self) -> StructShape { - self.shape - } - - pub(crate) fn impl_generics(&self) -> ImplGenerics<'_> { - let (impl_generics, _, _) = self.generics.split_for_impl(); - impl_generics - } - - pub(crate) const fn generic_type_param_idents(&self) -> &HashSet<&'a Ident> { - &self.generic_type_param_idents - } - - #[must_use] - pub(crate) const fn memento_trait_spec(&self) -> MementoTraitSpec { - MementoTraitSpec::new(SERDE_ENABLED, self.skip_memento_default_derives) - } - - #[must_use] - pub(crate) fn memento_decl_generics(&self) -> TokenStream2 { - let mut params = self - .generic_params - .iter() - .filter(|plan| plan.is_retained()) - .map(GenericParamPlan::decl_param) - .peekable(); - - if params.peek().is_none() { - quote! {} - } else { - quote! { <#(#params),*> } - } - } - - pub(crate) const fn memento_where_clause(&self) -> Option<&WhereClause> { - self.memento_where_clause.as_ref() - } - - #[must_use] - pub(crate) fn generated_memento_shape(&self) -> StructShape { - if self.shape == StructShape::Unit && self.has_synthetic_marker() { - StructShape::Named - } else { - self.shape - } - } - - #[must_use] - pub(crate) const fn has_synthetic_marker(&self) -> bool { - !self.marker_param_indices.is_empty() - } - - #[must_use] - pub(crate) fn synthetic_marker_type(&self) -> Option { - if self.marker_param_indices.is_empty() { - return None; - } - - let components = self - .marker_param_indices - .iter() - .map(|&index| marker_component(self.generic_params[index].param)); - - Some(quote! { - ::core::marker::PhantomData<(#(#components,)*)> - }) - } - - pub(crate) fn recallable_params(&self) -> impl Iterator { - self.generic_params - .iter() - .filter_map(GenericParamPlan::recallable_ident) - } - - #[must_use] - pub(crate) fn memento_type(&self) -> TokenStream2 { - let name = &self.memento_name; - let mut args = self - .generic_params - .iter() - .filter(|plan| plan.is_retained()) - .map(GenericParamPlan::type_arg) - .peekable(); - - if args.peek().is_none() { - quote! { #name } - } else { - quote! { #name<#(#args),*> } - } - } - - pub(crate) fn memento_fields(&self) -> impl Iterator> { - self.fields.iter().filter(|field| !field.strategy.is_skip()) - } - - pub(super) fn recallable_bounds(&self, bound: &TokenStream2) -> Vec { - self.recallable_params() - .map(|ty| syn::parse_quote! { #ty: #bound }) - .collect() - } - - pub(super) fn recallable_memento_bounds(&self, bound: &TokenStream2) -> Vec { - self.recallable_params() - .map(|ty| syn::parse_quote! { #ty::Memento: #bound }) - .collect() - } - - fn whole_type_bound_targets(&self) -> impl Iterator { - let mut seen = HashSet::new(); - - self.fields - .iter() - .filter_map(move |field| match field.strategy { - FieldStrategy::StoreAsMemento - if !is_generic_type_param(field.ty, &self.generic_type_param_idents) - && seen.insert(field.ty) => - { - Some(field.ty) - } - _ => None, - }) - } - - #[must_use] - pub(super) fn whole_type_bounds(&self, bound: &TokenStream2) -> Vec { - self.whole_type_bound_targets() - .map(|ty| syn::parse_quote! { #ty: #bound }) - .collect() - } - - pub(super) fn whole_type_memento_bounds( - &self, - recallable_trait: &TokenStream2, - bound: &TokenStream2, - ) -> impl Iterator { - self.whole_type_bound_targets() - .map(move |ty| syn::parse_quote! { <#ty as #recallable_trait>::Memento: #bound }) - } - - pub(crate) fn whole_type_from_bounds( - &self, - recallable_trait: &TokenStream2, - ) -> impl Iterator { - self.whole_type_bound_targets() - .flat_map(move |ty| { - [ - syn::parse_quote! { #ty: #recallable_trait }, - syn::parse_quote! { <#ty as #recallable_trait>::Memento: ::core::convert::From<#ty> }, - ] - }) - } - - pub(crate) fn extend_where_clause( - &self, - extra: impl IntoIterator, - ) -> Option { - let mut where_clause = self.generics.where_clause.clone(); - let mut extra_iter = extra.into_iter().peekable(); - if extra_iter.peek().is_none() { - where_clause - } else { - where_clause - .get_or_insert(syn::parse_quote! { where }) - .predicates - .extend(extra_iter); - - where_clause - } - } -} diff --git a/recallable-macro/src/context/internal/shared/bounds.rs b/recallable-macro/src/context/internal/shared/bounds.rs new file mode 100644 index 0000000..4fb60b8 --- /dev/null +++ b/recallable-macro/src/context/internal/shared/bounds.rs @@ -0,0 +1,102 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; + +#[derive(Debug)] +pub(crate) struct MementoTraitSpec { + serde_enabled: bool, + derive_off: bool, +} + +impl MementoTraitSpec { + pub(crate) const fn new(serde_enabled: bool, derive_off: bool) -> Self { + Self { + serde_enabled, + derive_off, + } + } + + #[must_use] + pub(crate) fn derive_attr(&self) -> TokenStream2 { + match (self.has_common_traits(), self.serde_enabled) { + (true, true) => { + quote! { + #[derive( + ::core::clone::Clone, + ::core::fmt::Debug, + ::core::cmp::PartialEq, + ::serde::Deserialize + )] + } + } + (true, false) => { + quote! { + #[derive( + ::core::clone::Clone, + ::core::fmt::Debug, + ::core::cmp::PartialEq + )] + } + } + (false, true) => quote! { #[derive(::serde::Deserialize)] }, + (false, false) => quote! {}, + } + } + + pub(crate) fn common_bound_tokens(&self) -> TokenStream2 { + if self.has_common_traits() { + quote! { ::core::clone::Clone + ::core::fmt::Debug + ::core::cmp::PartialEq } + } else { + quote! {} + } + } + + pub(crate) fn serde_nested_bound(&self) -> Option { + self.serde_enabled + .then_some(quote!(::serde::de::DeserializeOwned)) + } + + const fn has_common_traits(&self) -> bool { + !self.derive_off + } +} + +#[cfg(test)] +mod tests { + use super::MementoTraitSpec; + + #[test] + fn memento_trait_spec_formats_derives_for_serde_modes() { + let serde_derives = MementoTraitSpec::new(true, false).derive_attr().to_string(); + assert!(serde_derives.contains(":: core :: clone :: Clone")); + assert!(serde_derives.contains(":: core :: fmt :: Debug")); + assert!(serde_derives.contains(":: core :: cmp :: PartialEq")); + assert!(serde_derives.contains(":: serde :: Deserialize")); + + let no_serde_derives = MementoTraitSpec::new(false, false) + .derive_attr() + .to_string(); + assert!(no_serde_derives.contains(":: core :: clone :: Clone")); + assert!(no_serde_derives.contains(":: core :: fmt :: Debug")); + assert!(no_serde_derives.contains(":: core :: cmp :: PartialEq")); + assert!(!no_serde_derives.contains(":: serde :: Deserialize")); + } + + #[test] + fn memento_trait_spec_derive_off_suppresses_common_traits() { + let derive_off_serde = MementoTraitSpec::new(true, true).derive_attr().to_string(); + assert!(!derive_off_serde.contains("Clone")); + assert!(!derive_off_serde.contains("Debug")); + assert!(!derive_off_serde.contains("PartialEq")); + assert!(derive_off_serde.contains(":: serde :: Deserialize")); + + let derive_off_no_serde = MementoTraitSpec::new(false, true).derive_attr().to_string(); + assert!(derive_off_no_serde.is_empty()); + } + + #[test] + fn memento_trait_spec_derive_off_empties_common_bounds() { + let spec = MementoTraitSpec::new(true, true); + assert!(spec.common_bound_tokens().is_empty()); + assert!(spec.serde_nested_bound().is_some()); + } +} diff --git a/recallable-macro/src/context/internal/shared/env.rs b/recallable-macro/src/context/internal/shared/env.rs new file mode 100644 index 0000000..f1d62c6 --- /dev/null +++ b/recallable-macro/src/context/internal/shared/env.rs @@ -0,0 +1,21 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; + +use super::util::crate_path; + +#[derive(Debug)] +pub(crate) struct CodegenEnv { + pub(crate) recallable_trait: TokenStream2, + pub(crate) recall_trait: TokenStream2, +} + +impl CodegenEnv { + #[must_use] + pub(crate) fn resolve() -> Self { + let crate_path = crate_path(); + Self { + recallable_trait: quote! { #crate_path::Recallable }, + recall_trait: quote! { #crate_path::Recall }, + } + } +} diff --git a/recallable-macro/src/context/internal/fields.rs b/recallable-macro/src/context/internal/shared/fields.rs similarity index 67% rename from recallable-macro/src/context/internal/fields.rs rename to recallable-macro/src/context/internal/shared/fields.rs index 72b1717..07548c8 100644 --- a/recallable-macro/src/context/internal/fields.rs +++ b/recallable-macro/src/context/internal/shared/fields.rs @@ -1,51 +1,58 @@ use std::collections::HashSet; -use syn::{ - Data, DataEnum, DataStruct, DeriveInput, Field, Fields, Index, Meta, PathArguments, Type, - Variant, - punctuated::Punctuated, -}; +use proc_macro2::TokenStream as TokenStream2; +use quote::ToTokens; +use syn::{Field, Ident, Index, Meta, PathArguments, Type}; use super::generics::{ BareTypeParam, GenericParamLookup, GenericUsage, collect_generic_dependencies_in_type, }; -use super::ir::{FieldIr, FieldMember, FieldStrategy, VariantIr, VariantShape}; use super::lifetime::{field_uses_struct_lifetime, is_phantom_data}; use super::util::is_recallable_attr; -/// Field-level behavior inferred from `#[recallable]` attributes during analysis. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum FieldBehavior { - /// Keep the field in the memento with its original type. - Keep, - /// Store the field as an inner memento and recall this field recursively. - Recall, +pub(crate) enum FieldStrategy { + Skip, + StoreAsSelf, + StoreAsMemento, } -pub(super) fn extract_struct_fields(input: &DeriveInput) -> syn::Result<&Fields> { - if let Data::Struct(DataStruct { fields, .. }) = &input.data { - Ok(fields) - } else { - Err(syn::Error::new_spanned( - input, - "This derive macro can only be applied to structs", - )) +impl FieldStrategy { + pub(crate) const fn is_skip(self) -> bool { + matches!(self, Self::Skip) } } -pub(super) fn extract_enum_variants( - input: &DeriveInput, -) -> syn::Result<&Punctuated> { - if let Data::Enum(DataEnum { variants, .. }) = &input.data { - Ok(variants) - } else { - Err(syn::Error::new_spanned( - input, - "This derive macro can only be applied to structs or enums", - )) +#[derive(Debug)] +pub(crate) struct FieldIr<'a> { + pub(crate) source: &'a Field, + pub(crate) memento_index: Option, + pub(crate) member: FieldMember<'a>, + pub(crate) ty: &'a Type, + pub(crate) strategy: FieldStrategy, +} + +#[derive(Debug, Clone)] +pub(crate) enum FieldMember<'a> { + Named(&'a Ident), + Unnamed(Index), +} + +impl<'a> ToTokens for FieldMember<'a> { + fn to_tokens(&self, tokens: &mut TokenStream2) { + match self { + Self::Named(ident) => ident.to_tokens(tokens), + Self::Unnamed(index) => index.to_tokens(tokens), + } } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FieldBehavior { + Keep, + Recall, +} + fn determine_field_behavior(field: &Field) -> syn::Result> { let mut saw_recall = false; let mut saw_skip = false; @@ -80,8 +87,8 @@ fn determine_field_behavior(field: &Field) -> syn::Result> } Ok(match (saw_recall, saw_skip) { - (true, false) => Some(FieldBehavior::Recall), // #[recallable] - (false, true) => None, // #[recallable(skip)] + (true, false) => Some(FieldBehavior::Recall), + (false, true) => None, (false, false) => Some(FieldBehavior::Keep), (true, true) => unreachable!("conflicting attributes handled above"), }) @@ -116,8 +123,8 @@ fn classify_recallable_field_type( } } -pub(super) fn collect_field_irs<'a>( - fields: &'a Fields, +pub(crate) fn collect_field_irs<'a>( + fields: &'a syn::Fields, struct_lifetimes: &HashSet<&'a syn::Ident>, generic_lookup: &GenericParamLookup<'a>, ) -> syn::Result<(GenericUsage, Vec>)> { @@ -177,43 +184,8 @@ pub(super) fn collect_field_irs<'a>( Ok((usage, field_irs)) } -pub(super) fn collect_variant_irs<'a>( - variants: &'a Punctuated, - struct_lifetimes: &HashSet<&'a syn::Ident>, - generic_lookup: &GenericParamLookup<'a>, -) -> syn::Result<(GenericUsage, Vec>)> { - let mut usage = GenericUsage::default(); - let mut variant_irs = Vec::with_capacity(variants.len()); - - for variant in variants { - let (variant_usage, fields) = - collect_field_irs(&variant.fields, struct_lifetimes, generic_lookup)?; - usage.retained.extend(variant_usage.retained); - usage - .recallable_type_params - .extend(variant_usage.recallable_type_params); - - let shape = match &variant.fields { - Fields::Named(_) => VariantShape::Named, - Fields::Unnamed(_) => VariantShape::Unnamed, - Fields::Unit => VariantShape::Unit, - }; - - variant_irs.push(VariantIr { - name: &variant.ident, - shape, - fields, - }); - } - - Ok((usage, variant_irs)) -} - #[must_use] pub(crate) fn has_recallable_skip_attr(field: &Field) -> bool { - // Use determine_field_behavior for consistent validation. - // In the attribute macro context, we intentionally ignore errors here - // because the derive macros will report them with proper spans. matches!(determine_field_behavior(field), Ok(None)) } diff --git a/recallable-macro/src/context/internal/generics.rs b/recallable-macro/src/context/internal/shared/generics.rs similarity index 90% rename from recallable-macro/src/context/internal/generics.rs rename to recallable-macro/src/context/internal/shared/generics.rs index 739010a..8e7d6cb 100644 --- a/recallable-macro/src/context/internal/generics.rs +++ b/recallable-macro/src/context/internal/shared/generics.rs @@ -5,37 +5,33 @@ use quote::quote; use syn::visit::Visit; use syn::{GenericParam, Generics, Ident, PathArguments, Type, WhereClause, WherePredicate}; -use super::ir::{FieldIr, VariantIr}; +use crate::context::internal::{enums::VariantIr, shared::FieldIr}; -/// Whether a generic parameter is kept on the generated memento type. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum GenericParamRetention { - /// Omit the parameter from the generated memento declaration. +pub(crate) enum GenericParamRetention { Dropped, - /// Retain the parameter because the memento shape depends on it. Retained, - /// Retain the type parameter and later add `Recallable`-related bounds for it. RetainedAsRecallable, } #[derive(Debug)] -pub(super) struct GenericParamPlan<'a> { - pub(super) param: &'a GenericParam, +pub(crate) struct GenericParamPlan<'a> { + pub(crate) param: &'a GenericParam, retention: GenericParamRetention, } impl<'a> GenericParamPlan<'a> { #[must_use] - pub(super) const fn is_retained(&self) -> bool { + pub(crate) const fn is_retained(&self) -> bool { !matches!(self.retention, GenericParamRetention::Dropped) } - pub(super) const fn decl_param(&self) -> &GenericParam { + pub(crate) const fn decl_param(&self) -> &GenericParam { self.param } #[must_use] - pub(super) fn type_arg(&self) -> TokenStream2 { + pub(crate) fn type_arg(&self) -> TokenStream2 { match self.param { GenericParam::Lifetime(param) => { let lifetime = ¶m.lifetime; @@ -53,7 +49,7 @@ impl<'a> GenericParamPlan<'a> { } #[must_use] - pub(super) const fn recallable_ident(&self) -> Option<&'a Ident> { + pub(crate) const fn recallable_ident(&self) -> Option<&'a Ident> { match (self.param, self.retention) { (GenericParam::Type(param), GenericParamRetention::RetainedAsRecallable) => { Some(¶m.ident) @@ -64,13 +60,13 @@ impl<'a> GenericParamPlan<'a> { } #[derive(Debug, Default)] -pub(super) struct GenericUsage { - pub(super) retained: HashSet, - pub(super) recallable_type_params: HashSet, +pub(crate) struct GenericUsage { + pub(crate) retained: HashSet, + pub(crate) recallable_type_params: HashSet, } #[derive(Debug)] -pub(super) struct GenericParamLookup<'a> { +pub(crate) struct GenericParamLookup<'a> { type_params: HashMap<&'a Ident, usize>, const_params: HashMap<&'a Ident, usize>, lifetime_params: HashMap<&'a Ident, usize>, @@ -78,7 +74,7 @@ pub(super) struct GenericParamLookup<'a> { impl<'a> GenericParamLookup<'a> { #[must_use] - pub(super) fn new(generics: &'a Generics) -> Self { + pub(crate) fn new(generics: &'a Generics) -> Self { let mut type_params = HashMap::new(); let mut const_params = HashMap::new(); let mut lifetime_params = HashMap::new(); @@ -105,7 +101,7 @@ impl<'a> GenericParamLookup<'a> { } #[must_use] - pub(super) fn type_param_index(&self, ident: &Ident) -> Option { + pub(crate) fn type_param_index(&self, ident: &Ident) -> Option { self.type_params.get(ident).copied() } @@ -114,11 +110,10 @@ impl<'a> GenericParamLookup<'a> { } } -/// Analysis-only classification for recalled field types. -pub(super) struct BareTypeParam(pub(super) usize); +pub(crate) struct BareTypeParam(pub(crate) usize); #[must_use] -pub(super) fn collect_marker_param_indices( +pub(crate) fn collect_marker_param_indices( fields: &[FieldIr<'_>], generic_params: &[GenericParamPlan<'_>], generic_lookup: &GenericParamLookup<'_>, @@ -139,7 +134,7 @@ pub(super) fn collect_marker_param_indices( } #[must_use] -pub(super) fn collect_variant_marker_param_indices( +pub(crate) fn collect_variant_marker_param_indices( variants: &[VariantIr<'_>], generic_params: &[GenericParamPlan<'_>], generic_lookup: &GenericParamLookup<'_>, @@ -161,7 +156,7 @@ pub(super) fn collect_variant_marker_param_indices( } #[must_use] -pub(super) fn plan_memento_generics<'a>( +pub(crate) fn plan_memento_generics<'a>( generics: &'a Generics, mut usage: GenericUsage, generic_lookup: &GenericParamLookup<'a>, @@ -307,7 +302,6 @@ impl<'ast, 'a> Visit<'ast> for GenericDependencyCollector<'a> { && matches!(first_segment.arguments, PathArguments::None) && let Some(index) = self.lookup.const_param_index(&first_segment.ident) { - // `syn` represents identity const arguments like `N` as `Type`. self.dependencies.insert(index); } } @@ -328,7 +322,7 @@ impl<'ast, 'a> Visit<'ast> for GenericDependencyCollector<'a> { } #[must_use] -pub(super) fn collect_generic_dependencies_in_type( +pub(crate) fn collect_generic_dependencies_in_type( ty: &Type, generic_lookup: &GenericParamLookup<'_>, ) -> HashSet { @@ -356,7 +350,7 @@ fn collect_generic_dependencies_in_where_predicate( } #[must_use] -pub(super) fn marker_component(param: &GenericParam) -> TokenStream2 { +pub(crate) fn marker_component(param: &GenericParam) -> TokenStream2 { match param { GenericParam::Lifetime(param) => { let lifetime = ¶m.lifetime; diff --git a/recallable-macro/src/context/internal/shared/item.rs b/recallable-macro/src/context/internal/shared/item.rs new file mode 100644 index 0000000..ea24c89 --- /dev/null +++ b/recallable-macro/src/context/internal/shared/item.rs @@ -0,0 +1,41 @@ +use syn::DeriveInput; + +use crate::context::internal::{enums::EnumIr, structs::StructIr}; + +use super::util::is_recallable_attr; + +#[derive(Debug)] +pub(crate) enum ItemIr<'a> { + Struct(StructIr<'a>), + Enum(EnumIr<'a>), +} + +pub(crate) fn has_skip_memento_default_derives(input: &DeriveInput) -> syn::Result { + let mut skip_memento_default_derives = false; + for attr in input.attrs.iter().filter(|a| is_recallable_attr(a)) { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("skip_memento_default_derives") { + skip_memento_default_derives = true; + Ok(()) + } else if meta.path.is_ident("skip") { + Err(meta.error("`skip` is a field-level attribute, not a struct-level attribute")) + } else { + Err(meta.error("unrecognized `recallable` parameter")) + } + })?; + } + Ok(skip_memento_default_derives) +} + +impl<'a> ItemIr<'a> { + pub(crate) fn analyze(input: &'a DeriveInput) -> syn::Result { + match &input.data { + syn::Data::Struct(_) => Ok(Self::Struct(StructIr::analyze(input)?)), + syn::Data::Enum(_) => Ok(Self::Enum(EnumIr::analyze(input)?)), + _ => Err(syn::Error::new_spanned( + input, + "This derive macro can only be applied to structs or enums", + )), + } + } +} diff --git a/recallable-macro/src/context/internal/lifetime.rs b/recallable-macro/src/context/internal/shared/lifetime.rs similarity index 75% rename from recallable-macro/src/context/internal/lifetime.rs rename to recallable-macro/src/context/internal/shared/lifetime.rs index af08586..1b30695 100644 --- a/recallable-macro/src/context/internal/lifetime.rs +++ b/recallable-macro/src/context/internal/shared/lifetime.rs @@ -5,7 +5,7 @@ use syn::{Fields, GenericParam, Generics, Ident, Type}; use super::fields::has_recallable_skip_attr; -pub(super) fn validate_no_borrowed_fields( +pub(crate) fn validate_no_borrowed_fields( fields: &Fields, struct_lifetimes: &HashSet<&Ident>, ) -> syn::Result<()> { @@ -39,7 +39,7 @@ pub(super) fn validate_no_borrowed_fields( } #[must_use] -pub(super) fn collect_struct_lifetimes(generics: &Generics) -> HashSet<&Ident> { +pub(crate) fn collect_struct_lifetimes(generics: &Generics) -> HashSet<&Ident> { generics .params .iter() @@ -63,17 +63,8 @@ impl<'ast> Visit<'ast> for LifetimeUsageChecker<'_> { } } -/// Heuristically detects PhantomData-shaped field types during analysis. -/// -/// This matches any path type whose final segment is `PhantomData`, so it accepts -/// `PhantomData`, `marker::PhantomData`, `core::marker::PhantomData`, -/// `::core::marker::PhantomData`, `std::marker::PhantomData`, and -/// `::std::marker::PhantomData`. -/// -/// Because proc macros cannot resolve types, this also intentionally matches any -/// user-defined type whose final path segment is `PhantomData`. #[must_use] -pub(super) fn is_phantom_data(ty: &Type) -> bool { +pub(crate) fn is_phantom_data(ty: &Type) -> bool { matches!( ty, Type::Path(p) @@ -82,7 +73,7 @@ pub(super) fn is_phantom_data(ty: &Type) -> bool { } #[must_use] -pub(super) fn field_uses_struct_lifetime(ty: &Type, struct_lifetimes: &HashSet<&Ident>) -> bool { +pub(crate) fn field_uses_struct_lifetime(ty: &Type, struct_lifetimes: &HashSet<&Ident>) -> bool { let mut checker = LifetimeUsageChecker { struct_lifetimes, found: false, @@ -101,16 +92,10 @@ mod tests { fn phantom_data_detection_accepts_common_path_variants() { assert!(is_phantom_data(&parse_quote!(PhantomData))); assert!(is_phantom_data(&parse_quote!(marker::PhantomData))); - assert!(is_phantom_data(&parse_quote!( - core::marker::PhantomData - ))); - assert!(is_phantom_data(&parse_quote!( - ::core::marker::PhantomData - ))); + assert!(is_phantom_data(&parse_quote!(core::marker::PhantomData))); + assert!(is_phantom_data(&parse_quote!(::core::marker::PhantomData))); assert!(is_phantom_data(&parse_quote!(std::marker::PhantomData))); - assert!(is_phantom_data(&parse_quote!( - ::std::marker::PhantomData - ))); + assert!(is_phantom_data(&parse_quote!(::std::marker::PhantomData))); } #[test] diff --git a/recallable-macro/src/context/internal/shared/mod.rs b/recallable-macro/src/context/internal/shared/mod.rs new file mode 100644 index 0000000..fd09fc4 --- /dev/null +++ b/recallable-macro/src/context/internal/shared/mod.rs @@ -0,0 +1,14 @@ +pub(crate) mod bounds; +pub(crate) mod env; +pub(crate) mod fields; +pub(crate) mod generics; +pub(crate) mod item; +pub(crate) mod lifetime; +pub(crate) mod util; + +pub(crate) use bounds::MementoTraitSpec; +pub(crate) use env::CodegenEnv; +pub(crate) use fields::{FieldIr, FieldMember, FieldStrategy, has_recallable_skip_attr}; +pub(crate) use generics::is_generic_type_param; +pub(crate) use item::ItemIr; +pub(crate) use util::crate_path; diff --git a/recallable-macro/src/context/internal/shared/util.rs b/recallable-macro/src/context/internal/shared/util.rs new file mode 100644 index 0000000..f893078 --- /dev/null +++ b/recallable-macro/src/context/internal/shared/util.rs @@ -0,0 +1,25 @@ +use proc_macro_crate::{FoundCrate, crate_name}; +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::quote; +use syn::{Attribute, Ident}; + +const RECALLABLE: &str = "recallable"; + +#[inline] +#[must_use] +pub(crate) fn crate_path() -> TokenStream2 { + match crate_name(RECALLABLE) { + Ok(FoundCrate::Itself) => quote! { ::recallable }, + Ok(FoundCrate::Name(name)) => { + let ident = Ident::new(&name, Span::call_site()); + quote! { ::#ident } + } + Err(_) => quote! { ::recallable }, + } +} + +#[inline] +#[must_use] +pub(crate) fn is_recallable_attr(attr: &Attribute) -> bool { + attr.path().is_ident(RECALLABLE) +} diff --git a/recallable-macro/src/context/internal/bounds.rs b/recallable-macro/src/context/internal/structs/bounds.rs similarity index 59% rename from recallable-macro/src/context/internal/bounds.rs rename to recallable-macro/src/context/internal/structs/bounds.rs index af4eccc..a945bc9 100644 --- a/recallable-macro/src/context/internal/bounds.rs +++ b/recallable-macro/src/context/internal/structs/bounds.rs @@ -1,68 +1,8 @@ use proc_macro2::TokenStream as TokenStream2; -use quote::quote; use syn::WherePredicate; -use super::ir::{CodegenEnv, EnumIr, StructIr}; - -/// Policy for which derives and nested bounds the generated memento should receive. -#[derive(Debug)] -pub(crate) struct MementoTraitSpec { - serde_enabled: bool, - derive_off: bool, -} - -impl MementoTraitSpec { - pub(super) const fn new(serde_enabled: bool, derive_off: bool) -> Self { - Self { - serde_enabled, - derive_off, - } - } - - #[must_use] - pub(crate) fn derive_attr(&self) -> TokenStream2 { - match (self.has_common_traits(), self.serde_enabled) { - (true, true) => { - quote! { - #[derive( - ::core::clone::Clone, - ::core::fmt::Debug, - ::core::cmp::PartialEq, - ::serde::Deserialize - )] - } - } - (true, false) => { - quote! { - #[derive( - ::core::clone::Clone, - ::core::fmt::Debug, - ::core::cmp::PartialEq - )] - } - } - (false, true) => quote! { #[derive(::serde::Deserialize)] }, - (false, false) => quote! {}, - } - } - - fn common_bound_tokens(&self) -> TokenStream2 { - if self.has_common_traits() { - quote! { ::core::clone::Clone + ::core::fmt::Debug + ::core::cmp::PartialEq } - } else { - quote! {} - } - } - - fn serde_nested_bound(&self) -> Option { - self.serde_enabled - .then_some(quote!(::serde::de::DeserializeOwned)) - } - - const fn has_common_traits(&self) -> bool { - !self.derive_off - } -} +use crate::context::internal::shared::{CodegenEnv, MementoTraitSpec}; +use crate::context::internal::structs::StructIr; #[must_use] pub(crate) fn collect_shared_memento_bounds( @@ -73,11 +13,12 @@ pub(crate) fn collect_shared_memento_bounds( } #[must_use] -pub(crate) fn collect_shared_memento_bounds_for_enum( - ir: &EnumIr, +pub(crate) fn collect_recall_like_bounds( + ir: &StructIr, env: &CodegenEnv, + direct_bound: &TokenStream2, ) -> Vec { - collect_shared_memento_bounds_with_spec_for_enum(ir, env, &ir.memento_trait_spec()) + collect_recall_like_bounds_with_spec(ir, env, direct_bound, &ir.memento_trait_spec()) } fn collect_shared_memento_bounds_with_spec( @@ -97,41 +38,6 @@ fn collect_shared_memento_bounds_with_spec( bounds } -fn collect_shared_memento_bounds_with_spec_for_enum( - ir: &EnumIr, - env: &CodegenEnv, - memento_trait_spec: &MementoTraitSpec, -) -> Vec { - let recallable_trait = &env.recallable_trait; - let memento_trait_bounds = memento_trait_spec.common_bound_tokens(); - - let mut bounds = ir.recallable_memento_bounds(&memento_trait_bounds); - bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &memento_trait_bounds)); - if let Some(deserialize_owned) = memento_trait_spec.serde_nested_bound() { - bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &deserialize_owned)); - } - - bounds -} - -#[must_use] -pub(crate) fn collect_recall_like_bounds( - ir: &StructIr, - env: &CodegenEnv, - direct_bound: &TokenStream2, -) -> Vec { - collect_recall_like_bounds_with_spec(ir, env, direct_bound, &ir.memento_trait_spec()) -} - -#[must_use] -pub(crate) fn collect_recall_like_bounds_for_enum( - ir: &EnumIr, - env: &CodegenEnv, - direct_bound: &TokenStream2, -) -> Vec { - collect_recall_like_bounds_with_spec_for_enum(ir, env, direct_bound, &ir.memento_trait_spec()) -} - fn collect_recall_like_bounds_with_spec( ir: &StructIr, env: &CodegenEnv, @@ -158,32 +64,6 @@ fn collect_recall_like_bounds_with_spec( bounds } -fn collect_recall_like_bounds_with_spec_for_enum( - ir: &EnumIr, - env: &CodegenEnv, - direct_bound: &TokenStream2, - memento_trait_spec: &MementoTraitSpec, -) -> Vec { - let shared_memento_bounds = - collect_shared_memento_bounds_with_spec_for_enum(ir, env, memento_trait_spec); - let shared_param_bound_count = ir.recallable_params().count(); - - let mut bounds = ir.recallable_bounds(direct_bound); - bounds.extend( - shared_memento_bounds - .iter() - .take(shared_param_bound_count) - .cloned(), - ); - bounds.extend(ir.whole_type_bounds(direct_bound)); - bounds.extend( - shared_memento_bounds - .into_iter() - .skip(shared_param_bound_count), - ); - bounds -} - #[cfg(test)] mod tests { use quote::{ToTokens, quote}; @@ -345,40 +225,4 @@ mod tests { ] ); } - - #[test] - fn memento_trait_spec_formats_derives_for_serde_modes() { - let serde_derives = MementoTraitSpec::new(true, false).derive_attr().to_string(); - assert!(serde_derives.contains(":: core :: clone :: Clone")); - assert!(serde_derives.contains(":: core :: fmt :: Debug")); - assert!(serde_derives.contains(":: core :: cmp :: PartialEq")); - assert!(serde_derives.contains(":: serde :: Deserialize")); - - let no_serde_derives = MementoTraitSpec::new(false, false) - .derive_attr() - .to_string(); - assert!(no_serde_derives.contains(":: core :: clone :: Clone")); - assert!(no_serde_derives.contains(":: core :: fmt :: Debug")); - assert!(no_serde_derives.contains(":: core :: cmp :: PartialEq")); - assert!(!no_serde_derives.contains(":: serde :: Deserialize")); - } - - #[test] - fn memento_trait_spec_derive_off_suppresses_common_traits() { - let derive_off_serde = MementoTraitSpec::new(true, true).derive_attr().to_string(); - assert!(!derive_off_serde.contains("Clone")); - assert!(!derive_off_serde.contains("Debug")); - assert!(!derive_off_serde.contains("PartialEq")); - assert!(derive_off_serde.contains(":: serde :: Deserialize")); - - let derive_off_no_serde = MementoTraitSpec::new(false, true).derive_attr().to_string(); - assert!(derive_off_no_serde.is_empty()); - } - - #[test] - fn memento_trait_spec_derive_off_empties_common_bounds() { - let spec = MementoTraitSpec::new(true, true); - assert!(spec.common_bound_tokens().is_empty()); - assert!(spec.serde_nested_bound().is_some()); - } } diff --git a/recallable-macro/src/context/internal/structs/ir.rs b/recallable-macro/src/context/internal/structs/ir.rs new file mode 100644 index 0000000..112d5fd --- /dev/null +++ b/recallable-macro/src/context/internal/structs/ir.rs @@ -0,0 +1,284 @@ +use std::collections::HashSet; + +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::{ + DeriveInput, Fields, Generics, Ident, ImplGenerics, Type, Visibility, WhereClause, + WherePredicate, +}; + +use crate::context::SERDE_ENABLED; +use crate::context::internal::shared::bounds::MementoTraitSpec; +use crate::context::internal::shared::fields::{FieldIr, FieldStrategy, collect_field_irs}; +use crate::context::internal::shared::generics::{ + GenericParamPlan, GenericParamLookup, collect_marker_param_indices, is_generic_type_param, + marker_component, plan_memento_generics, +}; +use crate::context::internal::shared::item::has_skip_memento_default_derives; +use crate::context::internal::shared::lifetime::{ + collect_struct_lifetimes, validate_no_borrowed_fields, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StructShape { + Named, + Unnamed, + Unit, +} + +impl StructShape { + const fn from_fields(fields: &Fields) -> Self { + match fields { + Fields::Named(_) => Self::Named, + Fields::Unnamed(_) => Self::Unnamed, + Fields::Unit => Self::Unit, + } + } +} + +#[derive(Debug)] +pub(crate) struct StructIr<'a> { + name: &'a Ident, + visibility: &'a Visibility, + generics: &'a Generics, + shape: StructShape, + fields: Vec>, + memento_name: Ident, + generic_type_param_idents: HashSet<&'a Ident>, + generic_params: Vec>, + memento_where_clause: Option, + marker_param_indices: Vec, + skip_memento_default_derives: bool, +} + +fn extract_struct_fields(input: &DeriveInput) -> syn::Result<&Fields> { + if let syn::Data::Struct(data) = &input.data { + Ok(&data.fields) + } else { + Err(syn::Error::new_spanned( + input, + "This derive macro can only be applied to structs", + )) + } +} + +impl<'a> StructIr<'a> { + pub(crate) fn analyze(input: &'a DeriveInput) -> syn::Result { + let fields = extract_struct_fields(input)?; + let struct_lifetimes = collect_struct_lifetimes(&input.generics); + validate_no_borrowed_fields(fields, &struct_lifetimes)?; + + let shape = StructShape::from_fields(fields); + let memento_name = quote::format_ident!("{}Memento", input.ident); + let generic_lookup = GenericParamLookup::new(&input.generics); + let generic_type_param_idents = input + .generics + .type_params() + .map(|param| ¶m.ident) + .collect(); + let (usage, field_irs) = collect_field_irs(fields, &struct_lifetimes, &generic_lookup)?; + let (generic_params, memento_where_clause) = + plan_memento_generics(&input.generics, usage, &generic_lookup); + let marker_param_indices = + collect_marker_param_indices(&field_irs, &generic_params, &generic_lookup); + let skip_memento_default_derives = has_skip_memento_default_derives(input)?; + + Ok(Self { + name: &input.ident, + visibility: &input.vis, + generics: &input.generics, + shape, + fields: field_irs, + memento_name, + generic_type_param_idents, + generic_params, + memento_where_clause, + marker_param_indices, + skip_memento_default_derives, + }) + } + + pub(crate) fn struct_type(&self) -> TokenStream2 { + let name = &self.name; + let (_, type_generics, _) = self.generics.split_for_impl(); + quote! { #name #type_generics } + } + + pub(crate) const fn memento_name(&self) -> &Ident { + &self.memento_name + } + + pub(crate) const fn visibility(&self) -> &'a Visibility { + self.visibility + } + + #[must_use] + pub(crate) const fn shape(&self) -> StructShape { + self.shape + } + + pub(crate) fn impl_generics(&self) -> ImplGenerics<'_> { + let (impl_generics, _, _) = self.generics.split_for_impl(); + impl_generics + } + + pub(crate) const fn generic_type_param_idents(&self) -> &HashSet<&'a Ident> { + &self.generic_type_param_idents + } + + #[must_use] + pub(crate) const fn memento_trait_spec(&self) -> MementoTraitSpec { + MementoTraitSpec::new(SERDE_ENABLED, self.skip_memento_default_derives) + } + + #[must_use] + pub(crate) fn memento_decl_generics(&self) -> TokenStream2 { + let mut params = self + .generic_params + .iter() + .filter(|plan| plan.is_retained()) + .map(GenericParamPlan::decl_param) + .peekable(); + + if params.peek().is_none() { + quote! {} + } else { + quote! { <#(#params),*> } + } + } + + pub(crate) const fn memento_where_clause(&self) -> Option<&WhereClause> { + self.memento_where_clause.as_ref() + } + + #[must_use] + pub(crate) fn generated_memento_shape(&self) -> StructShape { + if self.shape == StructShape::Unit && self.has_synthetic_marker() { + StructShape::Named + } else { + self.shape + } + } + + #[must_use] + pub(crate) const fn has_synthetic_marker(&self) -> bool { + !self.marker_param_indices.is_empty() + } + + #[must_use] + pub(crate) fn synthetic_marker_type(&self) -> Option { + if self.marker_param_indices.is_empty() { + return None; + } + + let components = self + .marker_param_indices + .iter() + .map(|&index| marker_component(self.generic_params[index].param)); + + Some(quote! { + ::core::marker::PhantomData<(#(#components,)*)> + }) + } + + pub(crate) fn recallable_params(&self) -> impl Iterator { + self.generic_params + .iter() + .filter_map(GenericParamPlan::recallable_ident) + } + + #[must_use] + pub(crate) fn memento_type(&self) -> TokenStream2 { + let name = &self.memento_name; + let mut args = self + .generic_params + .iter() + .filter(|plan| plan.is_retained()) + .map(GenericParamPlan::type_arg) + .peekable(); + + if args.peek().is_none() { + quote! { #name } + } else { + quote! { #name<#(#args),*> } + } + } + + pub(crate) fn memento_fields(&self) -> impl Iterator> { + self.fields.iter().filter(|field| !field.strategy.is_skip()) + } + + pub(crate) fn recallable_bounds(&self, bound: &TokenStream2) -> Vec { + self.recallable_params() + .map(|ty| syn::parse_quote! { #ty: #bound }) + .collect() + } + + pub(crate) fn recallable_memento_bounds(&self, bound: &TokenStream2) -> Vec { + self.recallable_params() + .map(|ty| syn::parse_quote! { #ty::Memento: #bound }) + .collect() + } + + fn whole_type_bound_targets(&self) -> impl Iterator { + let mut seen = HashSet::new(); + + self.fields + .iter() + .filter_map(move |field| match field.strategy { + FieldStrategy::StoreAsMemento + if !is_generic_type_param(field.ty, &self.generic_type_param_idents) + && seen.insert(field.ty) => + { + Some(field.ty) + } + _ => None, + }) + } + + #[must_use] + pub(crate) fn whole_type_bounds(&self, bound: &TokenStream2) -> Vec { + self.whole_type_bound_targets() + .map(|ty| syn::parse_quote! { #ty: #bound }) + .collect() + } + + pub(crate) fn whole_type_memento_bounds( + &self, + recallable_trait: &TokenStream2, + bound: &TokenStream2, + ) -> impl Iterator { + self.whole_type_bound_targets() + .map(move |ty| syn::parse_quote! { <#ty as #recallable_trait>::Memento: #bound }) + } + + pub(crate) fn whole_type_from_bounds( + &self, + recallable_trait: &TokenStream2, + ) -> impl Iterator { + self.whole_type_bound_targets().flat_map(move |ty| { + [ + syn::parse_quote! { #ty: #recallable_trait }, + syn::parse_quote! { <#ty as #recallable_trait>::Memento: ::core::convert::From<#ty> }, + ] + }) + } + + pub(crate) fn extend_where_clause( + &self, + extra: impl IntoIterator, + ) -> Option { + let mut where_clause = self.generics.where_clause.clone(); + let mut extra_iter = extra.into_iter().peekable(); + if extra_iter.peek().is_none() { + where_clause + } else { + where_clause + .get_or_insert(syn::parse_quote! { where }) + .predicates + .extend(extra_iter); + + where_clause + } + } +} diff --git a/recallable-macro/src/context/internal/structs/mod.rs b/recallable-macro/src/context/internal/structs/mod.rs new file mode 100644 index 0000000..725de13 --- /dev/null +++ b/recallable-macro/src/context/internal/structs/mod.rs @@ -0,0 +1,5 @@ +mod bounds; +mod ir; + +pub(crate) use bounds::{collect_recall_like_bounds, collect_shared_memento_bounds}; +pub(crate) use ir::{StructIr, StructShape}; diff --git a/recallable-macro/src/context/internal/util.rs b/recallable-macro/src/context/internal/util.rs deleted file mode 100644 index 81267aa..0000000 --- a/recallable-macro/src/context/internal/util.rs +++ /dev/null @@ -1,34 +0,0 @@ -use proc_macro_crate::{FoundCrate, crate_name}; -use proc_macro2::{Span, TokenStream as TokenStream2}; -use quote::quote; -use syn::{Attribute, Ident}; - -const RECALLABLE: &str = "recallable"; - -/// Returns the path used to reference the `recallable` crate in generated code. -/// -/// Uses `proc-macro-crate` to resolve the actual dependency name from `Cargo.toml`, -/// which handles crate renames (e.g., `my_recallable = { package = "recallable", ... }`). -/// -/// Even when the macro expands inside the `recallable` crate itself, prefer the -/// absolute `::recallable` path instead of `crate`. That keeps doctests working: -/// rustdoc compiles them as external crates, so `crate` would point at the -/// temporary doctest crate rather than the real `recallable` library. -#[inline] -#[must_use] -pub(crate) fn crate_path() -> TokenStream2 { - match crate_name(RECALLABLE) { - Ok(FoundCrate::Itself) => quote! { ::recallable }, - Ok(FoundCrate::Name(name)) => { - let ident = Ident::new(&name, Span::call_site()); - quote! { ::#ident } - } - Err(_) => quote! { ::recallable }, - } -} - -#[inline] -#[must_use] -pub(super) fn is_recallable_attr(attr: &Attribute) -> bool { - attr.path().is_ident(RECALLABLE) -} diff --git a/recallable-macro/src/context/memento/enums.rs b/recallable-macro/src/context/memento/enums.rs new file mode 100644 index 0000000..0f66e72 --- /dev/null +++ b/recallable-macro/src/context/memento/enums.rs @@ -0,0 +1,109 @@ +use std::collections::HashSet; + +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::{Ident, WhereClause, WherePredicate}; + +use crate::context::{ + CodegenEnv, EnumIr, FieldMember, FieldStrategy, SERDE_ENABLED, VariantIr, VariantShape, + collect_recall_like_bounds_for_enum, is_generic_type_param, +}; + +#[must_use] +pub(crate) fn gen_memento_enum(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { + let derives = ir.memento_trait_spec().derive_attr(); + let visibility = ir.visibility(); + let memento_name = ir.memento_name(); + let memento_generics = ir.memento_decl_generics(); + let where_clause = build_memento_where_clause(ir, env); + let variants = ir + .variants() + .map(|variant| build_memento_variant(variant, &env.recallable_trait, ir.generic_type_param_idents())) + .chain(ir.synthetic_marker_type().into_iter().map(|marker_ty| build_marker_variant(&marker_ty))); + + quote! { + #derives + #visibility enum #memento_name #memento_generics #where_clause { + #(#variants),* + } + } +} + +fn build_memento_where_clause(ir: &EnumIr, env: &CodegenEnv) -> Option { + let mut where_clause = ir + .memento_where_clause() + .cloned() + .unwrap_or(syn::parse_quote! { where }); + let bounded_types = collect_memento_bounds(ir, env); + where_clause.predicates.extend(bounded_types); + + if where_clause.predicates.is_empty() { + None + } else { + Some(where_clause) + } +} + +fn collect_memento_bounds(ir: &EnumIr, env: &CodegenEnv) -> Vec { + collect_recall_like_bounds_for_enum(ir, env, &env.recallable_trait) +} + +fn build_memento_variant( + variant: &VariantIr<'_>, + recallable_trait: &TokenStream2, + generic_type_params: &HashSet<&Ident>, +) -> TokenStream2 { + let name = variant.name; + let fields: Vec<_> = variant + .fields + .iter() + .filter(|field| !field.strategy.is_skip()) + .map(|field| build_memento_field(field, recallable_trait, generic_type_params)) + .collect(); + + let shape = if fields.is_empty() { + VariantShape::Unit + } else { + variant.shape + }; + + match shape { + VariantShape::Named => quote! { #name { #(#fields),* } }, + VariantShape::Unnamed => quote! { #name(#(#fields),*) }, + VariantShape::Unit => quote! { #name }, + } +} + +fn build_memento_field( + field: &crate::context::FieldIr<'_>, + recallable_trait: &TokenStream2, + generic_type_params: &HashSet<&Ident>, +) -> TokenStream2 { + let ty = field.ty; + let field_ty = match &field.strategy { + FieldStrategy::StoreAsMemento => { + if is_generic_type_param(ty, generic_type_params) { + quote! { #ty::Memento } + } else { + quote! { <#ty as #recallable_trait>::Memento } + } + } + FieldStrategy::StoreAsSelf => quote! { #ty }, + FieldStrategy::Skip => unreachable!("filtered above"), + }; + + match &field.member { + FieldMember::Named(name) => quote! { #name: #field_ty }, + FieldMember::Unnamed(_) => quote! { #field_ty }, + } +} + +fn build_marker_variant(marker_ty: &TokenStream2) -> TokenStream2 { + let serde_attr = SERDE_ENABLED.then_some(quote! { #[serde(skip)] }); + + quote! { + #[doc(hidden)] + #serde_attr + __RecallableMarker(#marker_ty) + } +} diff --git a/recallable-macro/src/context/memento/mod.rs b/recallable-macro/src/context/memento/mod.rs new file mode 100644 index 0000000..80e19f8 --- /dev/null +++ b/recallable-macro/src/context/memento/mod.rs @@ -0,0 +1,14 @@ +mod enums; +mod structs; + +use proc_macro2::TokenStream as TokenStream2; + +use crate::context::{CodegenEnv, ItemIr}; + +#[must_use] +pub(crate) fn gen_memento_type(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { + match ir { + ItemIr::Struct(ir) => structs::gen_memento_struct(ir, env), + ItemIr::Enum(ir) => enums::gen_memento_enum(ir, env), + } +} diff --git a/recallable-macro/src/context/memento/structs.rs b/recallable-macro/src/context/memento/structs.rs new file mode 100644 index 0000000..c69c571 --- /dev/null +++ b/recallable-macro/src/context/memento/structs.rs @@ -0,0 +1,148 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use std::collections::HashSet; +use syn::{Ident, WhereClause, WherePredicate}; + +use crate::context::{ + CodegenEnv, FieldIr, FieldMember, FieldStrategy, SERDE_ENABLED, StructIr, StructShape, + collect_recall_like_bounds, is_generic_type_param, +}; + +#[must_use] +pub(crate) fn gen_memento_struct(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { + let derives = ir.memento_trait_spec().derive_attr(); + let visibility = ir.visibility(); + let memento_name = ir.memento_name(); + let memento_generics = ir.memento_decl_generics(); + let body = build_memento_body(ir, env); + + quote! { + #derives + #visibility struct #memento_name #memento_generics #body + } +} + +fn build_memento_body(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { + let shape = ir.generated_memento_shape(); + let where_clause = build_memento_where_clause(ir, env); + let fields = memento_fields_with_marker(ir, env, shape); + + match shape { + StructShape::Named => quote! { #where_clause { #(#fields),* } }, + StructShape::Unnamed => quote! { ( #(#fields),* ) #where_clause; }, + StructShape::Unit => quote! { #where_clause; }, + } +} + +fn build_memento_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { + let mut where_clause = ir + .memento_where_clause() + .cloned() + .unwrap_or(syn::parse_quote! { where }); + let bounded_types = collect_memento_bounds(ir, env); + where_clause.predicates.extend(bounded_types); + + if where_clause.predicates.is_empty() { + None + } else { + Some(where_clause) + } +} + +fn collect_memento_bounds(ir: &StructIr, env: &CodegenEnv) -> Vec { + collect_recall_like_bounds(ir, env, &env.recallable_trait) +} + +fn memento_fields_with_marker<'ir, 'input>( + ir: &'ir StructIr<'input>, + env: &'ir CodegenEnv, + shape: StructShape, +) -> impl Iterator + 'ir { + let recallable_trait = &env.recallable_trait; + + ir.memento_fields() + .map(|field| build_memento_field(field, recallable_trait, ir.generic_type_param_idents())) + .chain( + ir.synthetic_marker_type() + .into_iter() + .map(move |marker_ty| build_marker_field(&marker_ty, shape)), + ) +} + +fn build_marker_field(marker_ty: &TokenStream2, shape: StructShape) -> TokenStream2 { + let serde_attr = SERDE_ENABLED.then_some(quote! { #[serde(skip, default)] }); + + match shape { + StructShape::Named => quote! { + #serde_attr + _recallable_marker: #marker_ty + }, + StructShape::Unnamed => quote! { + #serde_attr + #marker_ty + }, + StructShape::Unit => unreachable!("unit mementos with synthetic markers become named"), + } +} + +fn build_memento_field( + field: &FieldIr, + recallable_trait: &TokenStream2, + generic_type_params: &HashSet<&Ident>, +) -> TokenStream2 { + let ty = field.ty; + let field_ty = match &field.strategy { + FieldStrategy::StoreAsMemento => { + if is_generic_type_param(ty, generic_type_params) { + quote! { #ty::Memento } + } else { + quote! { <#ty as #recallable_trait>::Memento } + } + } + FieldStrategy::StoreAsSelf => quote! { #ty }, + FieldStrategy::Skip => unreachable!("memento_fields() filters skipped fields"), + }; + match &field.member { + FieldMember::Named(name) => quote! { #name: #field_ty }, + FieldMember::Unnamed(_) => quote! { #field_ty }, + } +} + +#[cfg(test)] +mod tests { + use quote::{ToTokens, quote}; + use syn::parse_quote; + + use super::{CodegenEnv, StructIr, gen_memento_struct}; + + #[test] + fn generated_memento_visibility_matches_companion_struct() { + let env = CodegenEnv { + recallable_trait: quote!(::recallable::Recallable), + recall_trait: quote!(::recallable::Recall), + }; + + let restricted_input: syn::DeriveInput = parse_quote! { + pub(crate) struct Example { + value: u32, + } + }; + let restricted_ir = StructIr::analyze(&restricted_input).unwrap(); + let restricted_memento: syn::ItemStruct = + syn::parse2(gen_memento_struct(&restricted_ir, &env)).unwrap(); + assert_eq!( + restricted_memento.vis.to_token_stream().to_string(), + quote!(pub(crate)).to_string() + ); + + let private_input: syn::DeriveInput = parse_quote! { + struct PrivateExample { + value: u32, + } + }; + let private_ir = StructIr::analyze(&private_input).unwrap(); + let private_memento: syn::ItemStruct = + syn::parse2(gen_memento_struct(&private_ir, &env)).unwrap(); + assert!(matches!(private_memento.vis, syn::Visibility::Inherited)); + } +} diff --git a/recallable-macro/src/context/recall_impl/enums.rs b/recallable-macro/src/context/recall_impl/enums.rs new file mode 100644 index 0000000..22242b2 --- /dev/null +++ b/recallable-macro/src/context/recall_impl/enums.rs @@ -0,0 +1,27 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; + +use crate::context::{CodegenEnv, EnumIr, collect_recall_like_bounds_for_enum}; + +#[must_use] +pub(crate) fn gen_enum_recall_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { + let recall_trait = &env.recall_trait; + let impl_generics = ir.impl_generics(); + let enum_type = ir.enum_type(); + let where_clause = ir.extend_where_clause(collect_recall_like_bounds_for_enum( + ir, + env, + recall_trait, + )); + + quote! { + impl #impl_generics #recall_trait + for #enum_type + #where_clause { + #[inline] + fn recall(&mut self, memento: Self::Memento) { + *self = Self::__recallable_rebuild_from_memento(memento); + } + } + } +} diff --git a/recallable-macro/src/context/recall_impl/mod.rs b/recallable-macro/src/context/recall_impl/mod.rs new file mode 100644 index 0000000..5c74b99 --- /dev/null +++ b/recallable-macro/src/context/recall_impl/mod.rs @@ -0,0 +1,14 @@ +mod enums; +mod structs; + +use proc_macro2::TokenStream as TokenStream2; + +use crate::context::{CodegenEnv, ItemIr}; + +#[must_use] +pub(crate) fn gen_recall_impl(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { + match ir { + ItemIr::Struct(ir) => structs::gen_struct_recall_impl(ir, env), + ItemIr::Enum(ir) => enums::gen_enum_recall_impl(ir, env), + } +} diff --git a/recallable-macro/src/context/recall_impl/structs.rs b/recallable-macro/src/context/recall_impl/structs.rs new file mode 100644 index 0000000..abbb71d --- /dev/null +++ b/recallable-macro/src/context/recall_impl/structs.rs @@ -0,0 +1,83 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::WherePredicate; + +use crate::context::{ + CodegenEnv, FieldIr, FieldMember, FieldStrategy, StructIr, collect_recall_like_bounds, +}; + +#[must_use] +pub(crate) fn gen_struct_recall_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { + let recall_trait = &env.recall_trait; + let impl_generics = ir.impl_generics(); + let where_clause = build_recall_where_clause(ir, env); + let struct_type = ir.struct_type(); + let recall_method = build_recall_method(ir, recall_trait); + + quote! { + impl #impl_generics #recall_trait + for #struct_type + #where_clause { + #recall_method + } + } +} + +fn build_recall_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { + let extra_bounds = collect_recall_bounds(ir, env); + ir.extend_where_clause(extra_bounds) +} + +fn collect_recall_bounds(ir: &StructIr, env: &CodegenEnv) -> Vec { + collect_recall_like_bounds(ir, env, &env.recall_trait) +} + +fn build_recall_method(ir: &StructIr, recall_trait: &TokenStream2) -> TokenStream2 { + let mut memento_fields = ir.memento_fields().peekable(); + let recall_param_name = build_recall_param_name(memento_fields.peek().is_some()); + let statements = memento_fields.map(|field| build_recall_statement(field, recall_trait)); + + quote! { + #[inline] + fn recall(&mut self, #recall_param_name: Self::Memento) { + #(#statements)* + } + } +} + +fn build_recall_param_name(has_memento_fields: bool) -> TokenStream2 { + if has_memento_fields { + quote! { memento } + } else { + quote! { _memento } + } +} + +fn build_recall_statement(field: &FieldIr, recall_trait: &TokenStream2) -> TokenStream2 { + let member = &field.member; + let memento_member = build_memento_member(field); + + match &field.strategy { + FieldStrategy::StoreAsSelf => { + quote! { self.#member = memento.#memento_member; } + } + FieldStrategy::StoreAsMemento => { + quote! { #recall_trait::recall(&mut self.#member, memento.#memento_member); } + } + FieldStrategy::Skip => unreachable!("memento_fields() filters skipped fields"), + } +} + +fn build_memento_member(field: &FieldIr) -> TokenStream2 { + let member = &field.member; + match member { + FieldMember::Named(name) => quote! { #name }, + FieldMember::Unnamed(_) => { + let memento_index = field + .memento_index + .expect("memento_fields() guarantees memento_index is Some"); + let idx = syn::Index::from(memento_index); + quote! { #idx } + } + } +} diff --git a/recallable-macro/src/context/recallable_impl/enums.rs b/recallable-macro/src/context/recallable_impl/enums.rs new file mode 100644 index 0000000..ad0c008 --- /dev/null +++ b/recallable-macro/src/context/recallable_impl/enums.rs @@ -0,0 +1,128 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::{ToTokens, format_ident, quote}; +use syn::WherePredicate; + +use crate::context::{ + CodegenEnv, EnumIr, EnumRecallMode, FieldIr, FieldMember, VariantIr, VariantShape, + collect_recall_like_bounds_for_enum, +}; + +#[must_use] +pub(crate) fn gen_enum_recallable_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { + let impl_generics = ir.impl_generics(); + let recallable_trait = &env.recallable_trait; + let enum_type = ir.enum_type(); + let where_clause = build_enum_recallable_where_clause(ir, env); + let memento_type = ir.memento_type(); + let rebuild_helper = gen_enum_rebuild_helper(ir, env); + + quote! { + impl #impl_generics #recallable_trait + for #enum_type + #where_clause { + type Memento = #memento_type; + } + + #rebuild_helper + } +} + +fn build_enum_recallable_where_clause( + ir: &EnumIr, + env: &CodegenEnv, +) -> Option { + let extra_bounds = collect_enum_recallable_bounds(ir, env); + ir.extend_where_clause(extra_bounds) +} + +fn collect_enum_recallable_bounds(ir: &EnumIr, env: &CodegenEnv) -> Vec { + collect_recall_like_bounds_for_enum(ir, env, &env.recallable_trait) +} + +fn gen_enum_rebuild_helper(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { + if !matches!(ir.recall_mode(), EnumRecallMode::AssignmentOnly) { + return quote! {}; + } + + let impl_generics = ir.impl_generics(); + let enum_type = ir.enum_type(); + let recallable_trait = &env.recallable_trait; + let memento_name = ir.memento_name(); + let where_clause = build_enum_recallable_where_clause(ir, env); + let arms = ir.variants().map(|variant| { + let variant_name = variant.name; + let pattern = build_variant_memento_pattern(variant); + let expr = build_variant_rebuild_expr(variant, ir.name()); + quote! { #memento_name::#variant_name #pattern => #expr } + }); + let marker_arm = ir.synthetic_marker_type().map(|_| { + quote! { #memento_name::__RecallableMarker(_) => unreachable!("marker variant is never constructed"), } + }); + + quote! { + impl #impl_generics #enum_type #where_clause { + #[inline] + fn __recallable_rebuild_from_memento( + memento: <#enum_type as #recallable_trait>::Memento, + ) -> Self { + match memento { + #(#arms,)* + #marker_arm + } + } + } + } +} + +fn build_variant_memento_pattern(variant: &VariantIr<'_>) -> TokenStream2 { + match variant.shape { + VariantShape::Named => { + let bindings = variant + .fields + .iter() + .enumerate() + .map(|(index, field)| build_binding_ident(field, index).to_token_stream()); + quote! { { #(#bindings),* } } + } + VariantShape::Unnamed => { + let bindings = variant + .fields + .iter() + .enumerate() + .map(|(index, field)| build_binding_ident(field, index).to_token_stream()); + quote! { ( #(#bindings),* ) } + } + VariantShape::Unit => quote! {}, + } +} + +fn build_variant_rebuild_expr(variant: &VariantIr<'_>, enum_name: &syn::Ident) -> TokenStream2 { + let variant_name = variant.name; + + match variant.shape { + VariantShape::Named => { + let inits = variant.fields.iter().enumerate().map(|(index, field)| { + let member = &field.member; + let binding = build_binding_ident(field, index); + quote! { #member: #binding } + }); + quote! { #enum_name::#variant_name { #(#inits),* } } + } + VariantShape::Unnamed => { + let values = variant + .fields + .iter() + .enumerate() + .map(|(index, field)| build_binding_ident(field, index).to_token_stream()); + quote! { #enum_name::#variant_name(#(#values),*) } + } + VariantShape::Unit => quote! { #enum_name::#variant_name }, + } +} + +fn build_binding_ident(field: &FieldIr<'_>, index: usize) -> syn::Ident { + match &field.member { + FieldMember::Named(name) => (*name).clone(), + FieldMember::Unnamed(_) => format_ident!("__recallable_field_{index}"), + } +} diff --git a/recallable-macro/src/context/recallable_impl/mod.rs b/recallable-macro/src/context/recallable_impl/mod.rs new file mode 100644 index 0000000..b4c8456 --- /dev/null +++ b/recallable-macro/src/context/recallable_impl/mod.rs @@ -0,0 +1,14 @@ +mod enums; +mod structs; + +use proc_macro2::TokenStream as TokenStream2; + +use crate::context::{CodegenEnv, ItemIr}; + +#[must_use] +pub(crate) fn gen_recallable_impl(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { + match ir { + ItemIr::Struct(ir) => structs::gen_struct_recallable_impl(ir, env), + ItemIr::Enum(ir) => enums::gen_enum_recallable_impl(ir, env), + } +} diff --git a/recallable-macro/src/context/recallable_impl/structs.rs b/recallable-macro/src/context/recallable_impl/structs.rs new file mode 100644 index 0000000..f49c177 --- /dev/null +++ b/recallable-macro/src/context/recallable_impl/structs.rs @@ -0,0 +1,31 @@ +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::WherePredicate; + +use crate::context::{CodegenEnv, StructIr, collect_recall_like_bounds}; + +#[must_use] +pub(crate) fn gen_struct_recallable_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { + let impl_generics = ir.impl_generics(); + let recallable_trait = &env.recallable_trait; + let struct_type = ir.struct_type(); + let where_clause = build_recallable_where_clause(ir, env); + let memento_type = ir.memento_type(); + + quote! { + impl #impl_generics #recallable_trait + for #struct_type + #where_clause { + type Memento = #memento_type; + } + } +} + +fn build_recallable_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { + let extra_bounds = collect_recallable_bounds(ir, env); + ir.extend_where_clause(extra_bounds) +} + +fn collect_recallable_bounds(ir: &StructIr, env: &CodegenEnv) -> Vec { + collect_recall_like_bounds(ir, env, &env.recallable_trait) +} From c8b40704111fe0860224f1523be0097947bba9f6 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 03:55:29 +0800 Subject: [PATCH 09/45] refactor(macro): remove obsolete flat emitter files --- recallable-macro/src/context/from_impl.rs | 268 ------------------ recallable-macro/src/context/memento_enum.rs | 109 ------- .../src/context/memento_struct.rs | 148 ---------- recallable-macro/src/context/recall_impl.rs | 113 -------- .../src/context/recallable_impl.rs | 160 ----------- 5 files changed, 798 deletions(-) delete mode 100644 recallable-macro/src/context/from_impl.rs delete mode 100644 recallable-macro/src/context/memento_enum.rs delete mode 100644 recallable-macro/src/context/memento_struct.rs delete mode 100644 recallable-macro/src/context/recall_impl.rs delete mode 100644 recallable-macro/src/context/recallable_impl.rs diff --git a/recallable-macro/src/context/from_impl.rs b/recallable-macro/src/context/from_impl.rs deleted file mode 100644 index 00015c5..0000000 --- a/recallable-macro/src/context/from_impl.rs +++ /dev/null @@ -1,268 +0,0 @@ -use proc_macro2::TokenStream as TokenStream2; -use quote::{ToTokens, format_ident, quote}; -use syn::WherePredicate; - -use crate::context::{ - CodegenEnv, EnumIr, FieldIr, FieldStrategy, ItemIr, StructIr, StructShape, VariantIr, - VariantShape, collect_shared_memento_bounds, collect_shared_memento_bounds_for_enum, -}; - -#[must_use] -pub(crate) fn gen_from_impl(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { - match ir { - ItemIr::Struct(ir) => gen_struct_from_impl(ir, env), - ItemIr::Enum(ir) => gen_enum_from_impl(ir, env), - } -} - -fn gen_struct_from_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { - let impl_generics = ir.impl_generics(); - let struct_type = ir.struct_type(); - let memento_type = ir.memento_type(); - let where_clause = build_from_where_clause(ir, env); - let from_method = build_from_method(ir); - - quote! { - impl #impl_generics ::core::convert::From<#struct_type> - for #memento_type - #where_clause { - #from_method - } - } -} - -fn gen_enum_from_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { - let impl_generics = ir.impl_generics(); - let enum_type = ir.enum_type(); - let memento_type = ir.memento_type(); - let where_clause = build_enum_from_where_clause(ir, env); - let from_method = build_enum_from_method(ir); - - quote! { - impl #impl_generics ::core::convert::From<#enum_type> - for #memento_type - #where_clause { - #from_method - } - } -} - -fn build_from_method(ir: &StructIr) -> TokenStream2 { - let struct_type = ir.struct_type(); - let fn_body = build_from_body(ir); - - quote! { - #[inline] - fn from(value: #struct_type) -> Self { - #fn_body - } - } -} - -fn build_enum_from_method(ir: &EnumIr) -> TokenStream2 { - let enum_type = ir.enum_type(); - let fn_body = build_enum_from_body(ir); - - quote! { - #[inline] - fn from(value: #enum_type) -> Self { - #fn_body - } - } -} - -fn build_from_body(ir: &StructIr) -> TokenStream2 { - match ir.shape() { - StructShape::Named => build_named_from_body(ir), - StructShape::Unnamed => build_unnamed_from_body(ir), - StructShape::Unit => build_unit_from_body(ir), - } -} - -fn build_enum_from_body(ir: &EnumIr) -> TokenStream2 { - let enum_name = ir.name(); - let memento_name = ir.memento_name(); - let arms = ir.variants().map(|variant| { - let variant_name = variant.name; - let pattern = build_variant_source_pattern(variant); - let expr = build_variant_from_expr(variant); - quote! { #enum_name::#variant_name #pattern => #memento_name::#variant_name #expr } - }); - - quote! { - match value { - #(#arms),* - } - } -} - -fn build_named_from_body(ir: &StructIr) -> TokenStream2 { - let inits = ir - .memento_fields() - .map(build_named_from_field) - .chain(ir.has_synthetic_marker().then(build_named_marker_init)); - - quote! { Self { #(#inits),* } } -} - -fn build_named_from_field(field: &FieldIr) -> TokenStream2 { - let member = &field.member; - let value = build_from_expr(field); - quote! { #member: #value } -} - -fn build_variant_source_pattern(variant: &VariantIr<'_>) -> TokenStream2 { - match variant.shape { - VariantShape::Named => { - let patterns = variant.fields.iter().enumerate().map(|(index, field)| { - let member = &field.member; - let binding = build_binding_pattern(field, index); - quote! { #member: #binding } - }); - quote! { { #(#patterns),* } } - } - VariantShape::Unnamed => { - let patterns = variant - .fields - .iter() - .enumerate() - .map(|(index, field)| build_binding_pattern(field, index)); - quote! { ( #(#patterns),* ) } - } - VariantShape::Unit => quote! {}, - } -} - -fn build_binding_pattern(field: &FieldIr<'_>, index: usize) -> TokenStream2 { - if field.strategy.is_skip() { - quote! { _ } - } else { - build_binding_ident(field, index).to_token_stream() - } -} - -fn build_binding_ident(field: &FieldIr<'_>, index: usize) -> syn::Ident { - match &field.member { - crate::context::FieldMember::Named(name) => (*name).clone(), - crate::context::FieldMember::Unnamed(_) => format_ident!("__recallable_field_{index}"), - } -} - -fn build_variant_from_expr(variant: &VariantIr<'_>) -> TokenStream2 { - let kept_fields: Vec<_> = variant - .fields - .iter() - .enumerate() - .filter(|(_, field)| !field.strategy.is_skip()) - .collect(); - - if kept_fields.is_empty() { - return quote! {}; - } - - match variant.shape { - VariantShape::Named => { - let inits = kept_fields.into_iter().map(|(index, field)| { - let member = &field.member; - let binding = build_binding_ident(field, index); - let value = build_from_binding_expr(field, &binding); - quote! { #member: #value } - }); - quote! { { #(#inits),* } } - } - VariantShape::Unnamed => { - let values = kept_fields.into_iter().map(|(index, field)| { - let binding = build_binding_ident(field, index); - build_from_binding_expr(field, &binding) - }); - quote! { ( #(#values),* ) } - } - VariantShape::Unit => quote! {}, - } -} - -fn build_unnamed_from_body(ir: &StructIr) -> TokenStream2 { - let values = ir - .memento_fields() - .map(build_from_expr) - .chain(ir.has_synthetic_marker().then(build_marker_init)); - - quote! { Self(#(#values),*) } -} - -fn build_unit_from_body(ir: &StructIr) -> TokenStream2 { - if ir.has_synthetic_marker() { - quote! { Self { _recallable_marker: ::core::marker::PhantomData } } - } else { - quote! { Self } - } -} - -fn build_marker_init() -> TokenStream2 { - quote! { ::core::marker::PhantomData } -} - -fn build_named_marker_init() -> TokenStream2 { - quote! { _recallable_marker: ::core::marker::PhantomData } -} - -fn build_from_expr(field: &FieldIr) -> TokenStream2 { - let member = &field.member; - match &field.strategy { - FieldStrategy::StoreAsSelf => quote! { value.#member }, - FieldStrategy::StoreAsMemento => { - quote! { ::core::convert::From::from(value.#member) } - } - FieldStrategy::Skip => unreachable!("memento_fields() filters skipped fields"), - } -} - -fn build_from_binding_expr(field: &FieldIr<'_>, binding: &syn::Ident) -> TokenStream2 { - match &field.strategy { - FieldStrategy::StoreAsSelf => quote! { #binding }, - FieldStrategy::StoreAsMemento => quote! { ::core::convert::From::from(#binding) }, - FieldStrategy::Skip => unreachable!("filtered above"), - } -} - -fn build_from_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { - let bounds = collect_from_bounds(ir, env); - ir.extend_where_clause(bounds) -} - -fn collect_from_bounds(ir: &StructIr, env: &CodegenEnv) -> Vec { - let recallable_trait = &env.recallable_trait; - let mut bounds: Vec<_> = ir - .recallable_params() - .flat_map(|ty| -> [WherePredicate; 2] { - [ - syn::parse_quote! { #ty: #recallable_trait }, - syn::parse_quote! { #ty::Memento: ::core::convert::From<#ty> }, - ] - }) - .collect(); - bounds.extend(collect_shared_memento_bounds(ir, env)); - bounds.extend(ir.whole_type_from_bounds(recallable_trait)); - bounds -} - -fn build_enum_from_where_clause(ir: &EnumIr, env: &CodegenEnv) -> Option { - let bounds = collect_enum_from_bounds(ir, env); - ir.extend_where_clause(bounds) -} - -fn collect_enum_from_bounds(ir: &EnumIr, env: &CodegenEnv) -> Vec { - let recallable_trait = &env.recallable_trait; - let mut bounds: Vec<_> = ir - .recallable_params() - .flat_map(|ty| -> [WherePredicate; 2] { - [ - syn::parse_quote! { #ty: #recallable_trait }, - syn::parse_quote! { #ty::Memento: ::core::convert::From<#ty> }, - ] - }) - .collect(); - bounds.extend(collect_shared_memento_bounds_for_enum(ir, env)); - bounds.extend(ir.whole_type_from_bounds(recallable_trait)); - bounds -} diff --git a/recallable-macro/src/context/memento_enum.rs b/recallable-macro/src/context/memento_enum.rs deleted file mode 100644 index 0f66e72..0000000 --- a/recallable-macro/src/context/memento_enum.rs +++ /dev/null @@ -1,109 +0,0 @@ -use std::collections::HashSet; - -use proc_macro2::TokenStream as TokenStream2; -use quote::quote; -use syn::{Ident, WhereClause, WherePredicate}; - -use crate::context::{ - CodegenEnv, EnumIr, FieldMember, FieldStrategy, SERDE_ENABLED, VariantIr, VariantShape, - collect_recall_like_bounds_for_enum, is_generic_type_param, -}; - -#[must_use] -pub(crate) fn gen_memento_enum(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { - let derives = ir.memento_trait_spec().derive_attr(); - let visibility = ir.visibility(); - let memento_name = ir.memento_name(); - let memento_generics = ir.memento_decl_generics(); - let where_clause = build_memento_where_clause(ir, env); - let variants = ir - .variants() - .map(|variant| build_memento_variant(variant, &env.recallable_trait, ir.generic_type_param_idents())) - .chain(ir.synthetic_marker_type().into_iter().map(|marker_ty| build_marker_variant(&marker_ty))); - - quote! { - #derives - #visibility enum #memento_name #memento_generics #where_clause { - #(#variants),* - } - } -} - -fn build_memento_where_clause(ir: &EnumIr, env: &CodegenEnv) -> Option { - let mut where_clause = ir - .memento_where_clause() - .cloned() - .unwrap_or(syn::parse_quote! { where }); - let bounded_types = collect_memento_bounds(ir, env); - where_clause.predicates.extend(bounded_types); - - if where_clause.predicates.is_empty() { - None - } else { - Some(where_clause) - } -} - -fn collect_memento_bounds(ir: &EnumIr, env: &CodegenEnv) -> Vec { - collect_recall_like_bounds_for_enum(ir, env, &env.recallable_trait) -} - -fn build_memento_variant( - variant: &VariantIr<'_>, - recallable_trait: &TokenStream2, - generic_type_params: &HashSet<&Ident>, -) -> TokenStream2 { - let name = variant.name; - let fields: Vec<_> = variant - .fields - .iter() - .filter(|field| !field.strategy.is_skip()) - .map(|field| build_memento_field(field, recallable_trait, generic_type_params)) - .collect(); - - let shape = if fields.is_empty() { - VariantShape::Unit - } else { - variant.shape - }; - - match shape { - VariantShape::Named => quote! { #name { #(#fields),* } }, - VariantShape::Unnamed => quote! { #name(#(#fields),*) }, - VariantShape::Unit => quote! { #name }, - } -} - -fn build_memento_field( - field: &crate::context::FieldIr<'_>, - recallable_trait: &TokenStream2, - generic_type_params: &HashSet<&Ident>, -) -> TokenStream2 { - let ty = field.ty; - let field_ty = match &field.strategy { - FieldStrategy::StoreAsMemento => { - if is_generic_type_param(ty, generic_type_params) { - quote! { #ty::Memento } - } else { - quote! { <#ty as #recallable_trait>::Memento } - } - } - FieldStrategy::StoreAsSelf => quote! { #ty }, - FieldStrategy::Skip => unreachable!("filtered above"), - }; - - match &field.member { - FieldMember::Named(name) => quote! { #name: #field_ty }, - FieldMember::Unnamed(_) => quote! { #field_ty }, - } -} - -fn build_marker_variant(marker_ty: &TokenStream2) -> TokenStream2 { - let serde_attr = SERDE_ENABLED.then_some(quote! { #[serde(skip)] }); - - quote! { - #[doc(hidden)] - #serde_attr - __RecallableMarker(#marker_ty) - } -} diff --git a/recallable-macro/src/context/memento_struct.rs b/recallable-macro/src/context/memento_struct.rs deleted file mode 100644 index c69c571..0000000 --- a/recallable-macro/src/context/memento_struct.rs +++ /dev/null @@ -1,148 +0,0 @@ -use proc_macro2::TokenStream as TokenStream2; -use quote::quote; -use std::collections::HashSet; -use syn::{Ident, WhereClause, WherePredicate}; - -use crate::context::{ - CodegenEnv, FieldIr, FieldMember, FieldStrategy, SERDE_ENABLED, StructIr, StructShape, - collect_recall_like_bounds, is_generic_type_param, -}; - -#[must_use] -pub(crate) fn gen_memento_struct(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { - let derives = ir.memento_trait_spec().derive_attr(); - let visibility = ir.visibility(); - let memento_name = ir.memento_name(); - let memento_generics = ir.memento_decl_generics(); - let body = build_memento_body(ir, env); - - quote! { - #derives - #visibility struct #memento_name #memento_generics #body - } -} - -fn build_memento_body(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { - let shape = ir.generated_memento_shape(); - let where_clause = build_memento_where_clause(ir, env); - let fields = memento_fields_with_marker(ir, env, shape); - - match shape { - StructShape::Named => quote! { #where_clause { #(#fields),* } }, - StructShape::Unnamed => quote! { ( #(#fields),* ) #where_clause; }, - StructShape::Unit => quote! { #where_clause; }, - } -} - -fn build_memento_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { - let mut where_clause = ir - .memento_where_clause() - .cloned() - .unwrap_or(syn::parse_quote! { where }); - let bounded_types = collect_memento_bounds(ir, env); - where_clause.predicates.extend(bounded_types); - - if where_clause.predicates.is_empty() { - None - } else { - Some(where_clause) - } -} - -fn collect_memento_bounds(ir: &StructIr, env: &CodegenEnv) -> Vec { - collect_recall_like_bounds(ir, env, &env.recallable_trait) -} - -fn memento_fields_with_marker<'ir, 'input>( - ir: &'ir StructIr<'input>, - env: &'ir CodegenEnv, - shape: StructShape, -) -> impl Iterator + 'ir { - let recallable_trait = &env.recallable_trait; - - ir.memento_fields() - .map(|field| build_memento_field(field, recallable_trait, ir.generic_type_param_idents())) - .chain( - ir.synthetic_marker_type() - .into_iter() - .map(move |marker_ty| build_marker_field(&marker_ty, shape)), - ) -} - -fn build_marker_field(marker_ty: &TokenStream2, shape: StructShape) -> TokenStream2 { - let serde_attr = SERDE_ENABLED.then_some(quote! { #[serde(skip, default)] }); - - match shape { - StructShape::Named => quote! { - #serde_attr - _recallable_marker: #marker_ty - }, - StructShape::Unnamed => quote! { - #serde_attr - #marker_ty - }, - StructShape::Unit => unreachable!("unit mementos with synthetic markers become named"), - } -} - -fn build_memento_field( - field: &FieldIr, - recallable_trait: &TokenStream2, - generic_type_params: &HashSet<&Ident>, -) -> TokenStream2 { - let ty = field.ty; - let field_ty = match &field.strategy { - FieldStrategy::StoreAsMemento => { - if is_generic_type_param(ty, generic_type_params) { - quote! { #ty::Memento } - } else { - quote! { <#ty as #recallable_trait>::Memento } - } - } - FieldStrategy::StoreAsSelf => quote! { #ty }, - FieldStrategy::Skip => unreachable!("memento_fields() filters skipped fields"), - }; - match &field.member { - FieldMember::Named(name) => quote! { #name: #field_ty }, - FieldMember::Unnamed(_) => quote! { #field_ty }, - } -} - -#[cfg(test)] -mod tests { - use quote::{ToTokens, quote}; - use syn::parse_quote; - - use super::{CodegenEnv, StructIr, gen_memento_struct}; - - #[test] - fn generated_memento_visibility_matches_companion_struct() { - let env = CodegenEnv { - recallable_trait: quote!(::recallable::Recallable), - recall_trait: quote!(::recallable::Recall), - }; - - let restricted_input: syn::DeriveInput = parse_quote! { - pub(crate) struct Example { - value: u32, - } - }; - let restricted_ir = StructIr::analyze(&restricted_input).unwrap(); - let restricted_memento: syn::ItemStruct = - syn::parse2(gen_memento_struct(&restricted_ir, &env)).unwrap(); - assert_eq!( - restricted_memento.vis.to_token_stream().to_string(), - quote!(pub(crate)).to_string() - ); - - let private_input: syn::DeriveInput = parse_quote! { - struct PrivateExample { - value: u32, - } - }; - let private_ir = StructIr::analyze(&private_input).unwrap(); - let private_memento: syn::ItemStruct = - syn::parse2(gen_memento_struct(&private_ir, &env)).unwrap(); - assert!(matches!(private_memento.vis, syn::Visibility::Inherited)); - } -} diff --git a/recallable-macro/src/context/recall_impl.rs b/recallable-macro/src/context/recall_impl.rs deleted file mode 100644 index efe3c85..0000000 --- a/recallable-macro/src/context/recall_impl.rs +++ /dev/null @@ -1,113 +0,0 @@ -use proc_macro2::TokenStream as TokenStream2; -use quote::quote; -use syn::WherePredicate; - -use crate::context::{ - CodegenEnv, EnumIr, FieldIr, FieldMember, FieldStrategy, ItemIr, StructIr, - collect_recall_like_bounds, collect_recall_like_bounds_for_enum, -}; - -#[must_use] -pub(crate) fn gen_recall_impl(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { - match ir { - ItemIr::Struct(ir) => gen_struct_recall_impl(ir, env), - ItemIr::Enum(ir) => gen_enum_recall_impl(ir, env), - } -} - -fn gen_struct_recall_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { - let recall_trait = &env.recall_trait; - let impl_generics = ir.impl_generics(); - let where_clause = build_recall_where_clause(ir, env); - let struct_type = ir.struct_type(); - let recall_method = build_recall_method(ir, recall_trait); - - quote! { - impl #impl_generics #recall_trait - for #struct_type - #where_clause { - #recall_method - } - } -} - -fn gen_enum_recall_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { - let recall_trait = &env.recall_trait; - let impl_generics = ir.impl_generics(); - let enum_type = ir.enum_type(); - let where_clause = ir.extend_where_clause(collect_recall_like_bounds_for_enum( - ir, - env, - recall_trait, - )); - - quote! { - impl #impl_generics #recall_trait - for #enum_type - #where_clause { - #[inline] - fn recall(&mut self, memento: Self::Memento) { - *self = Self::__recallable_rebuild_from_memento(memento); - } - } - } -} - -fn build_recall_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { - let extra_bounds = collect_recall_bounds(ir, env); - ir.extend_where_clause(extra_bounds) -} - -fn collect_recall_bounds(ir: &StructIr, env: &CodegenEnv) -> Vec { - collect_recall_like_bounds(ir, env, &env.recall_trait) -} - -fn build_recall_method(ir: &StructIr, recall_trait: &TokenStream2) -> TokenStream2 { - let mut memento_fields = ir.memento_fields().peekable(); - let recall_param_name = build_recall_param_name(memento_fields.peek().is_some()); - let statements = memento_fields.map(|field| build_recall_statement(field, recall_trait)); - - quote! { - #[inline] - fn recall(&mut self, #recall_param_name: Self::Memento) { - #(#statements)* - } - } -} - -fn build_recall_param_name(has_memento_fields: bool) -> TokenStream2 { - if has_memento_fields { - quote! { memento } - } else { - quote! { _memento } - } -} - -fn build_recall_statement(field: &FieldIr, recall_trait: &TokenStream2) -> TokenStream2 { - let member = &field.member; - let memento_member = build_memento_member(field); - - match &field.strategy { - FieldStrategy::StoreAsSelf => { - quote! { self.#member = memento.#memento_member; } - } - FieldStrategy::StoreAsMemento => { - quote! { #recall_trait::recall(&mut self.#member, memento.#memento_member); } - } - FieldStrategy::Skip => unreachable!("memento_fields() filters skipped fields"), - } -} - -fn build_memento_member(field: &FieldIr) -> TokenStream2 { - let member = &field.member; - match member { - FieldMember::Named(name) => quote! { #name }, - FieldMember::Unnamed(_) => { - let memento_index = field - .memento_index - .expect("memento_fields() guarantees memento_index is Some"); - let idx = syn::Index::from(memento_index); - quote! { #idx } - } - } -} diff --git a/recallable-macro/src/context/recallable_impl.rs b/recallable-macro/src/context/recallable_impl.rs deleted file mode 100644 index 2975588..0000000 --- a/recallable-macro/src/context/recallable_impl.rs +++ /dev/null @@ -1,160 +0,0 @@ -use proc_macro2::TokenStream as TokenStream2; -use quote::{ToTokens, format_ident, quote}; -use syn::WherePredicate; - -use crate::context::{ - CodegenEnv, EnumIr, EnumRecallMode, FieldIr, FieldMember, ItemIr, StructIr, VariantIr, - VariantShape, collect_recall_like_bounds, collect_recall_like_bounds_for_enum, -}; - -#[must_use] -pub(crate) fn gen_recallable_impl(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { - match ir { - ItemIr::Struct(ir) => gen_struct_recallable_impl(ir, env), - ItemIr::Enum(ir) => gen_enum_recallable_impl(ir, env), - } -} - -fn gen_struct_recallable_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { - let impl_generics = ir.impl_generics(); - let recallable_trait = &env.recallable_trait; - let struct_type = ir.struct_type(); - let where_clause = build_recallable_where_clause(ir, env); - let memento_type = ir.memento_type(); - - quote! { - impl #impl_generics #recallable_trait - for #struct_type - #where_clause { - type Memento = #memento_type; - } - } -} - -fn build_recallable_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { - let extra_bounds = collect_recallable_bounds(ir, env); - ir.extend_where_clause(extra_bounds) -} - -fn collect_recallable_bounds(ir: &StructIr, env: &CodegenEnv) -> Vec { - collect_recall_like_bounds(ir, env, &env.recallable_trait) -} - -fn gen_enum_recallable_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { - let impl_generics = ir.impl_generics(); - let recallable_trait = &env.recallable_trait; - let enum_type = ir.enum_type(); - let where_clause = build_enum_recallable_where_clause(ir, env); - let memento_type = ir.memento_type(); - let rebuild_helper = gen_enum_rebuild_helper(ir, env); - - quote! { - impl #impl_generics #recallable_trait - for #enum_type - #where_clause { - type Memento = #memento_type; - } - - #rebuild_helper - } -} - -fn build_enum_recallable_where_clause( - ir: &EnumIr, - env: &CodegenEnv, -) -> Option { - let extra_bounds = collect_enum_recallable_bounds(ir, env); - ir.extend_where_clause(extra_bounds) -} - -fn collect_enum_recallable_bounds(ir: &EnumIr, env: &CodegenEnv) -> Vec { - collect_recall_like_bounds_for_enum(ir, env, &env.recallable_trait) -} - -fn gen_enum_rebuild_helper(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { - if !matches!(ir.recall_mode(), EnumRecallMode::AssignmentOnly) { - return quote! {}; - } - - let impl_generics = ir.impl_generics(); - let enum_type = ir.enum_type(); - let recallable_trait = &env.recallable_trait; - let memento_name = ir.memento_name(); - let where_clause = build_enum_recallable_where_clause(ir, env); - let arms = ir.variants().map(|variant| { - let variant_name = variant.name; - let pattern = build_variant_memento_pattern(variant); - let expr = build_variant_rebuild_expr(variant, ir.name()); - quote! { #memento_name::#variant_name #pattern => #expr } - }); - let marker_arm = ir.synthetic_marker_type().map(|_| { - quote! { #memento_name::__RecallableMarker(_) => unreachable!("marker variant is never constructed"), } - }); - - quote! { - impl #impl_generics #enum_type #where_clause { - #[inline] - fn __recallable_rebuild_from_memento( - memento: <#enum_type as #recallable_trait>::Memento, - ) -> Self { - match memento { - #(#arms,)* - #marker_arm - } - } - } - } -} - -fn build_variant_memento_pattern(variant: &VariantIr<'_>) -> TokenStream2 { - match variant.shape { - VariantShape::Named => { - let bindings = variant - .fields - .iter() - .enumerate() - .map(|(index, field)| build_binding_ident(field, index).to_token_stream()); - quote! { { #(#bindings),* } } - } - VariantShape::Unnamed => { - let bindings = variant - .fields - .iter() - .enumerate() - .map(|(index, field)| build_binding_ident(field, index).to_token_stream()); - quote! { ( #(#bindings),* ) } - } - VariantShape::Unit => quote! {}, - } -} - -fn build_variant_rebuild_expr(variant: &VariantIr<'_>, enum_name: &syn::Ident) -> TokenStream2 { - let variant_name = variant.name; - - match variant.shape { - VariantShape::Named => { - let inits = variant.fields.iter().enumerate().map(|(index, field)| { - let member = &field.member; - let binding = build_binding_ident(field, index); - quote! { #member: #binding } - }); - quote! { #enum_name::#variant_name { #(#inits),* } } - } - VariantShape::Unnamed => { - let values = variant - .fields - .iter() - .enumerate() - .map(|(index, field)| build_binding_ident(field, index).to_token_stream()); - quote! { #enum_name::#variant_name(#(#values),*) } - } - VariantShape::Unit => quote! { #enum_name::#variant_name }, - } -} - -fn build_binding_ident(field: &FieldIr<'_>, index: usize) -> syn::Ident { - match &field.member { - FieldMember::Named(name) => (*name).clone(), - FieldMember::Unnamed(_) => format_ident!("__recallable_field_{index}"), - } -} From 3542f48fcbd6d8a837a273f2576a92817f56cebf Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 04:30:00 +0800 Subject: [PATCH 10/45] refactor(macro): clarify derive boundaries --- GUIDE.md | 30 ++++++-- README.md | 22 ++++-- recallable-macro/src/context.rs | 70 +++++++++++++++---- .../src/context/from_impl/enums.rs | 6 +- recallable-macro/src/context/from_impl/mod.rs | 2 +- .../src/context/from_impl/structs.rs | 5 +- recallable-macro/src/context/internal.rs | 12 ---- .../src/context/internal/enums/ir.rs | 49 +++++++------ .../src/context/internal/enums/mod.rs | 6 +- .../src/context/internal/shared/generics.rs | 2 +- .../src/context/internal/shared/lifetime.rs | 12 +++- .../src/context/internal/structs/ir.rs | 2 +- recallable-macro/src/context/memento/enums.rs | 25 +++++-- recallable-macro/src/context/memento/mod.rs | 2 +- .../src/context/memento/structs.rs | 7 +- .../src/context/recall_impl/enums.rs | 12 ++-- .../src/context/recall_impl/mod.rs | 2 +- .../src/context/recall_impl/structs.rs | 5 +- .../src/context/recallable_impl/enums.rs | 54 ++++++++++---- .../src/context/recallable_impl/mod.rs | 2 +- .../src/context/recallable_impl/structs.rs | 3 +- recallable-macro/src/lib.rs | 22 +++--- recallable-macro/src/model_macro.rs | 18 +---- recallable/src/lib.rs | 8 ++- .../ui/derive_fail_enum_recall_nested.stderr | 2 +- .../ui/derive_fail_enum_recall_skip.stderr | 2 +- .../model_fail_enum_recallable_variant.stderr | 2 +- .../ui/model_fail_enum_skip_variant.stderr | 2 +- 28 files changed, 245 insertions(+), 141 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index 1f98206..7e6a111 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -160,7 +160,7 @@ Base dependency: ```toml [dependencies] -recallable = "0.1.0" +recallable = "0.2.0" ``` MSRV is Rust 1.88 with edition 2024. @@ -182,7 +182,7 @@ Example dependency sets: ```toml [dependencies] # Readable std example -recallable = "0.1.0" +recallable = "0.2.0" serde = { version = "1", features = ["derive"] } serde_json = "1" ``` @@ -190,7 +190,7 @@ serde_json = "1" ```toml [dependencies] # no_std + serde example -recallable = { version = "0.1.0", default-features = false, features = ["serde"] } +recallable = { version = "0.2.0", default-features = false, features = ["serde"] } serde = { version = "1", default-features = false, features = ["derive"] } postcard = { version = "1", default-features = false, features = ["heapless"] } heapless = { version = "0.9.2", default-features = false } @@ -255,6 +255,12 @@ With the default `serde` feature enabled, it also injects: - `#[derive(serde::Serialize)]` on the source struct - `#[serde(skip)]` on fields marked `#[recallable(skip)]` +Enum support is intentionally split: + +- assignment-only enums can use `#[recallable_model]` directly +- enums with nested `#[recallable]` or `#[recallable(skip)]` fields should + derive `Recallable` and implement `Recall` or `TryRecall` manually + Example: ```rust @@ -356,6 +362,13 @@ Important distinction: - `#[recallable_model]` mutates source-side serde behavior for the common case - direct `#[derive(Recallable, Recall)]` does not +Direct derives are also the split point for complex enums: + +- `#[derive(Recallable)]` supports enum-shaped mementos under the normal field rules +- `#[derive(Recall)]` works only for assignment-only enums +- enums with nested `#[recallable]` or skipped variant fields should derive + `Recallable` and implement `Recall` or `TryRecall` manually + If you use direct derives and want the source struct to serialize in the same shape as the generated memento, you must add the serde derives and `#[serde(skip)]` attributes yourself. @@ -476,7 +489,7 @@ the generated memento. ```toml [dependencies] -recallable = { version = "0.1.0", features = ["impl_from"] } +recallable = { version = "0.2.0", features = ["impl_from"] } ``` ```rust @@ -541,7 +554,7 @@ serde support by default: ```toml [dependencies] -recallable = { version = "0.1.0", default-features = false } +recallable = { version = "0.2.0", default-features = false } ``` Then define the memento and recall behavior manually: @@ -589,6 +602,8 @@ The derive macros support more than just simple named structs. - enums for `Recallable` - enums for `Recall` and `recallable_model` only when every variant field is assignment-only +- complex enums should derive `Recallable` only and supply manual `Recall` or + `TryRecall` ### Supported generic forms @@ -649,6 +664,8 @@ Behavior: - injects `Recallable` and `Recall` - injects `serde::Serialize` when the `serde` feature is enabled - injects `#[serde(skip)]` onto fields marked `#[recallable(skip)]` +- rejects complex enums where generated `Recall` would be ambiguous; those + should derive `Recallable` and implement `Recall` or `TryRecall` manually ### `#[derive(Recallable)]` @@ -657,6 +674,7 @@ Generates: - the companion memento type - the `Recallable` implementation - `From` for the memento when `impl_from` is enabled +- enum-shaped mementos for enums, even when `Recall` must stay manual ### `#[derive(Recall)]` @@ -725,6 +743,8 @@ pub trait TryRecall: Recallable { - `#[derive(Recallable)]` supports enums under the normal field rules - `#[derive(Recall)]` and `#[recallable_model]` support enums only for assignment-only variants +- complex enums should derive `Recallable` and implement `Recall` or + `TryRecall` manually - Borrowed non-skipped state fields are rejected - `#[recallable]` is path-only and does not accept tuple/reference/slice/function syntax directly diff --git a/README.md b/README.md index 4873115..9bf5266 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ input. - `Recall`: applies that memento infallibly - `TryRecall`: applies it fallibly with validation - `#[derive(Recallable)]`: generates the companion memento type -- `#[derive(Recall)]`: generates recall logic -- `#[recallable_model]`: convenience attribute for the common model path; with `serde` enabled it also removes extra - derive boilerplate +- `#[derive(Recall)]`: generates recall logic for structs and assignment-only enums +- `#[recallable_model]`: convenience attribute for the common struct or assignment-only enum path; with + `serde` enabled it also removes extra derive boilerplate The crate intentionally does not force one universal memento shape for container-like field types. A field type can choose whole-value replacement, @@ -97,6 +97,12 @@ For `no_std + serde` deployments, prefer a `no_std`-compatible format such as - `#[derive(serde::Serialize)]` when the default `serde` feature is enabled - `#[serde(skip)]` for fields marked `#[recallable(skip)]` +For enums, `#[recallable_model]` is intentionally narrower than `#[derive(Recallable)]`: + +- assignment-only enums are supported directly +- enums with nested `#[recallable]` or `#[recallable(skip)]` fields should derive + `Recallable` and implement `Recall` or `TryRecall` manually + ## Features - `serde` (default): enables macro-generated serde support; generated mementos derive @@ -113,7 +119,7 @@ Example dependency sets: ```toml [dependencies] # Readable std example -recallable = "0.1" +recallable = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" ``` @@ -121,7 +127,7 @@ serde_json = "1" ```toml [dependencies] # no_std + serde example -recallable = { version = "0.1", default-features = false, features = ["serde"] } +recallable = { version = "0.2", default-features = false, features = ["serde"] } serde = { version = "1", default-features = false, features = ["derive"] } postcard = { version = "1", default-features = false, features = ["heapless"] } heapless = { version = "0.9.2", default-features = false } @@ -130,7 +136,7 @@ heapless = { version = "0.9.2", default-features = false } ```toml [dependencies] # In-memory snapshots -recallable = { version = "0.1", features = ["impl_from"] } +recallable = { version = "0.2", features = ["impl_from"] } ``` ## Two Common Workflows @@ -183,7 +189,7 @@ default: ```toml [dependencies] -recallable = { version = "0.1", default-features = false } +recallable = { version = "0.2", default-features = false } ``` Define the memento type and recall behavior manually: @@ -227,6 +233,8 @@ impl Recall for EngineState { - `#[derive(Recallable)]` supports enums under the normal field rules - `#[derive(Recall)]` and `#[recallable_model]` support enums only when every variant field is assignment-only +- Enums with nested `#[recallable]` or `#[recallable(skip)]` fields should + derive `Recallable` and implement `Recall` or `TryRecall` manually - Borrowed state fields are rejected unless they are skipped - `#[recallable]` is path-only: it supports type parameters, path types, and associated types, but not tuple/reference/slice/function syntax directly diff --git a/recallable-macro/src/context.rs b/recallable-macro/src/context.rs index 8992b69..d97bdd6 100644 --- a/recallable-macro/src/context.rs +++ b/recallable-macro/src/context.rs @@ -1,6 +1,7 @@ -//! # Struct IR and Code Generation +//! # Derive Backend Facade //! -//! Semantic analysis and support logic live in the nested `internal` module. +//! Semantic analysis and item-kind-specific support logic live in the nested +//! `internal` module. //! //! Code generation remains split into free functions in submodules: //! - `gen_memento_type` — companion memento struct or enum definition @@ -14,20 +15,42 @@ mod memento; mod recall_impl; mod recallable_impl; +use syn::DeriveInput; + +use self::internal::shared::ItemIr; + pub(super) use from_impl::gen_from_impl; -pub(super) use internal::{ - CodegenEnv, EnumIr, EnumRecallMode, FieldIr, FieldMember, FieldStrategy, ItemIr, StructIr, - StructShape, VariantIr, VariantShape, collect_recall_like_bounds, - collect_recall_like_bounds_for_enum, collect_shared_memento_bounds, - collect_shared_memento_bounds_for_enum, crate_path, has_recallable_skip_attr, - is_generic_type_param, -}; +pub(super) use internal::shared::{CodegenEnv, crate_path, has_recallable_skip_attr}; pub(super) use recall_impl::gen_recall_impl; pub(super) use recallable_impl::gen_recallable_impl; pub(super) const SERDE_ENABLED: bool = cfg!(feature = "serde"); pub(super) const IMPL_FROM_ENABLED: bool = cfg!(feature = "impl_from"); +pub(super) fn analyze_item(input: &DeriveInput) -> syn::Result> { + ItemIr::analyze(input) +} + +pub(super) fn analyze_recall_input(input: &DeriveInput) -> syn::Result> { + let ir = analyze_item(input)?; + + if let ItemIr::Enum(enum_ir) = &ir { + enum_ir.ensure_recall_derive_allowed()?; + } + + Ok(ir) +} + +pub(super) fn analyze_model_input(input: &DeriveInput) -> syn::Result> { + let ir = analyze_item(input)?; + + if let ItemIr::Enum(enum_ir) = &ir { + enum_ir.ensure_model_derive_allowed()?; + } + + Ok(ir) +} + pub(crate) fn gen_memento_type(ir: &ItemIr, env: &CodegenEnv) -> proc_macro2::TokenStream { memento::gen_memento_type(ir, env) } @@ -37,11 +60,15 @@ mod tests { use quote::ToTokens; use syn::parse_quote; - use super::{CodegenEnv, ItemIr, gen_memento_type}; + use super::{CodegenEnv, analyze_item, analyze_recall_input, gen_memento_type}; #[test] fn split_internal_reexports_cover_both_item_kinds() { - use crate::context::internal::{enums::EnumIr, shared::CodegenEnv, structs::StructIr}; + use crate::context::internal::{ + enums::EnumIr, + shared::{CodegenEnv, ItemIr}, + structs::StructIr, + }; use syn::parse_quote; let struct_input: syn::DeriveInput = parse_quote! { @@ -62,13 +89,30 @@ mod tests { assert_eq!(struct_ir.memento_name().to_string(), "ExampleMemento"); assert_eq!(enum_ir.memento_name().to_string(), "ChoiceMemento"); assert_eq!( - crate::context::memento::gen_memento_type(&crate::context::ItemIr::Struct(struct_ir), &env) + crate::context::memento::gen_memento_type(&ItemIr::Struct(struct_ir), &env) .to_string() .contains("ExampleMemento"), true ); } + #[test] + fn helper_name_and_manual_only_guidance_manual_only_error() { + let input: syn::DeriveInput = parse_quote! { + enum Example { + Value(#[recallable] Inner), + } + }; + + let error = analyze_recall_input(&input).unwrap_err(); + + assert!( + error + .to_string() + .contains("derive `Recallable` and implement `Recall` or `TryRecall` manually") + ); + } + #[test] fn facade_reexports_support_analysis_and_codegen() { let input: syn::DeriveInput = parse_quote! { @@ -78,7 +122,7 @@ mod tests { } }; - let ir = ItemIr::analyze(&input).unwrap(); + let ir = analyze_item(&input).unwrap(); let env = CodegenEnv::resolve(); let memento: syn::ItemStruct = syn::parse2(gen_memento_type(&ir, &env)).unwrap(); diff --git a/recallable-macro/src/context/from_impl/enums.rs b/recallable-macro/src/context/from_impl/enums.rs index 41b4491..34a18da 100644 --- a/recallable-macro/src/context/from_impl/enums.rs +++ b/recallable-macro/src/context/from_impl/enums.rs @@ -2,10 +2,10 @@ use proc_macro2::TokenStream as TokenStream2; use quote::{ToTokens, format_ident, quote}; use syn::WherePredicate; -use crate::context::{ - CodegenEnv, EnumIr, FieldIr, FieldMember, FieldStrategy, VariantIr, VariantShape, - collect_shared_memento_bounds_for_enum, +use crate::context::internal::enums::{ + EnumIr, VariantIr, VariantShape, collect_shared_memento_bounds_for_enum, }; +use crate::context::internal::shared::{CodegenEnv, FieldIr, FieldMember, FieldStrategy}; #[must_use] pub(crate) fn gen_enum_from_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { diff --git a/recallable-macro/src/context/from_impl/mod.rs b/recallable-macro/src/context/from_impl/mod.rs index f4f4480..ad1bb3b 100644 --- a/recallable-macro/src/context/from_impl/mod.rs +++ b/recallable-macro/src/context/from_impl/mod.rs @@ -3,7 +3,7 @@ mod structs; use proc_macro2::TokenStream as TokenStream2; -use crate::context::{CodegenEnv, ItemIr}; +use crate::context::internal::shared::{CodegenEnv, ItemIr}; #[must_use] pub(crate) fn gen_from_impl(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { diff --git a/recallable-macro/src/context/from_impl/structs.rs b/recallable-macro/src/context/from_impl/structs.rs index e9f8168..c6c23e6 100644 --- a/recallable-macro/src/context/from_impl/structs.rs +++ b/recallable-macro/src/context/from_impl/structs.rs @@ -2,9 +2,8 @@ use proc_macro2::TokenStream as TokenStream2; use quote::quote; use syn::WherePredicate; -use crate::context::{ - CodegenEnv, FieldIr, FieldStrategy, StructIr, StructShape, collect_shared_memento_bounds, -}; +use crate::context::internal::shared::{CodegenEnv, FieldIr, FieldStrategy}; +use crate::context::internal::structs::{StructIr, StructShape, collect_shared_memento_bounds}; #[must_use] pub(crate) fn gen_struct_from_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { diff --git a/recallable-macro/src/context/internal.rs b/recallable-macro/src/context/internal.rs index 1fc493f..a3ede98 100644 --- a/recallable-macro/src/context/internal.rs +++ b/recallable-macro/src/context/internal.rs @@ -3,15 +3,3 @@ pub(crate) mod enums; pub(crate) mod shared; pub(crate) mod structs; - -pub(crate) use enums::{ - EnumIr, EnumRecallMode, VariantIr, VariantShape, collect_recall_like_bounds_for_enum, - collect_shared_memento_bounds_for_enum, -}; -pub(crate) use shared::{ - CodegenEnv, FieldIr, FieldMember, FieldStrategy, ItemIr, crate_path, - has_recallable_skip_attr, is_generic_type_param, -}; -pub(crate) use structs::{ - StructIr, StructShape, collect_recall_like_bounds, collect_shared_memento_bounds, -}; diff --git a/recallable-macro/src/context/internal/enums/ir.rs b/recallable-macro/src/context/internal/enums/ir.rs index 5e48de0..85680c6 100644 --- a/recallable-macro/src/context/internal/enums/ir.rs +++ b/recallable-macro/src/context/internal/enums/ir.rs @@ -10,7 +10,7 @@ use crate::context::SERDE_ENABLED; use crate::context::internal::shared::bounds::MementoTraitSpec; use crate::context::internal::shared::fields::{FieldIr, FieldStrategy, collect_field_irs}; use crate::context::internal::shared::generics::{ - GenericParamPlan, GenericParamLookup, collect_variant_marker_param_indices, + GenericParamLookup, GenericParamPlan, collect_variant_marker_param_indices, is_generic_type_param, marker_component, plan_memento_generics, }; use crate::context::internal::shared::item::has_skip_memento_default_derives; @@ -46,11 +46,10 @@ pub(crate) struct EnumIr<'a> { skip_memento_default_derives: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum EnumRecallMode { - AssignmentOnly, - ManualOnly, -} +const ENUM_RECALL_MANUAL_ONLY_ERROR: &str = "enum `Recall` derive requires assignment-only variant fields; derive `Recallable` and \ + implement `Recall` or `TryRecall` manually"; +const ENUM_MODEL_MANUAL_ONLY_ERROR: &str = "`#[recallable_model]` on enums requires assignment-only variants; complex enums should \ + derive `Recallable` and implement `Recall` or `TryRecall` manually"; fn extract_enum_variants( input: &DeriveInput, @@ -230,29 +229,33 @@ impl<'a> EnumIr<'a> { self.variants.iter() } - pub(crate) fn recall_mode(&self) -> EnumRecallMode { - if self - .variants + fn manual_only_field(&self) -> Option<&FieldIr<'a>> { + self.variants .iter() .flat_map(|variant| variant.fields.iter()) - .any(|field| !matches!(field.strategy, FieldStrategy::StoreAsSelf)) - { - EnumRecallMode::ManualOnly - } else { - EnumRecallMode::AssignmentOnly - } + .find(|field| !matches!(field.strategy, FieldStrategy::StoreAsSelf)) } - pub(crate) fn ensure_recall_derivable(&self) -> syn::Result<()> { - if let Some(field) = self - .variants - .iter() - .flat_map(|variant| variant.fields.iter()) - .find(|field| !matches!(field.strategy, FieldStrategy::StoreAsSelf)) - { + pub(crate) fn supports_derived_recall(&self) -> bool { + self.manual_only_field().is_none() + } + + pub(crate) fn ensure_recall_derive_allowed(&self) -> syn::Result<()> { + if let Some(field) = self.manual_only_field() { return Err(syn::Error::new_spanned( field.source, - "enum `Recall` derive only supports assignment-only variant fields", + ENUM_RECALL_MANUAL_ONLY_ERROR, + )); + } + + Ok(()) + } + + pub(crate) fn ensure_model_derive_allowed(&self) -> syn::Result<()> { + if self.manual_only_field().is_some() { + return Err(syn::Error::new_spanned( + self.name, + ENUM_MODEL_MANUAL_ONLY_ERROR, )); } diff --git a/recallable-macro/src/context/internal/enums/mod.rs b/recallable-macro/src/context/internal/enums/mod.rs index e3fccea..b96b884 100644 --- a/recallable-macro/src/context/internal/enums/mod.rs +++ b/recallable-macro/src/context/internal/enums/mod.rs @@ -1,5 +1,7 @@ mod bounds; mod ir; -pub(crate) use bounds::{collect_recall_like_bounds_for_enum, collect_shared_memento_bounds_for_enum}; -pub(crate) use ir::{EnumIr, EnumRecallMode, VariantIr, VariantShape}; +pub(crate) use bounds::{ + collect_recall_like_bounds_for_enum, collect_shared_memento_bounds_for_enum, +}; +pub(crate) use ir::{EnumIr, VariantIr, VariantShape}; diff --git a/recallable-macro/src/context/internal/shared/generics.rs b/recallable-macro/src/context/internal/shared/generics.rs index 8e7d6cb..dcc29de 100644 --- a/recallable-macro/src/context/internal/shared/generics.rs +++ b/recallable-macro/src/context/internal/shared/generics.rs @@ -384,7 +384,7 @@ mod tests { use quote::{ToTokens, quote}; use syn::parse_quote; - use crate::context::StructIr; + use crate::context::internal::structs::StructIr; #[test] fn memento_generics_preserve_retained_bounds_defaults_and_where_clauses() { diff --git a/recallable-macro/src/context/internal/shared/lifetime.rs b/recallable-macro/src/context/internal/shared/lifetime.rs index 1b30695..7e2d5b6 100644 --- a/recallable-macro/src/context/internal/shared/lifetime.rs +++ b/recallable-macro/src/context/internal/shared/lifetime.rs @@ -92,10 +92,16 @@ mod tests { fn phantom_data_detection_accepts_common_path_variants() { assert!(is_phantom_data(&parse_quote!(PhantomData))); assert!(is_phantom_data(&parse_quote!(marker::PhantomData))); - assert!(is_phantom_data(&parse_quote!(core::marker::PhantomData))); - assert!(is_phantom_data(&parse_quote!(::core::marker::PhantomData))); + assert!(is_phantom_data(&parse_quote!( + core::marker::PhantomData + ))); + assert!(is_phantom_data(&parse_quote!( + ::core::marker::PhantomData + ))); assert!(is_phantom_data(&parse_quote!(std::marker::PhantomData))); - assert!(is_phantom_data(&parse_quote!(::std::marker::PhantomData))); + assert!(is_phantom_data(&parse_quote!( + ::std::marker::PhantomData + ))); } #[test] diff --git a/recallable-macro/src/context/internal/structs/ir.rs b/recallable-macro/src/context/internal/structs/ir.rs index 112d5fd..3ce292c 100644 --- a/recallable-macro/src/context/internal/structs/ir.rs +++ b/recallable-macro/src/context/internal/structs/ir.rs @@ -11,7 +11,7 @@ use crate::context::SERDE_ENABLED; use crate::context::internal::shared::bounds::MementoTraitSpec; use crate::context::internal::shared::fields::{FieldIr, FieldStrategy, collect_field_irs}; use crate::context::internal::shared::generics::{ - GenericParamPlan, GenericParamLookup, collect_marker_param_indices, is_generic_type_param, + GenericParamLookup, GenericParamPlan, collect_marker_param_indices, is_generic_type_param, marker_component, plan_memento_generics, }; use crate::context::internal::shared::item::has_skip_memento_default_derives; diff --git a/recallable-macro/src/context/memento/enums.rs b/recallable-macro/src/context/memento/enums.rs index 0f66e72..4e634ab 100644 --- a/recallable-macro/src/context/memento/enums.rs +++ b/recallable-macro/src/context/memento/enums.rs @@ -4,9 +4,12 @@ use proc_macro2::TokenStream as TokenStream2; use quote::quote; use syn::{Ident, WhereClause, WherePredicate}; -use crate::context::{ - CodegenEnv, EnumIr, FieldMember, FieldStrategy, SERDE_ENABLED, VariantIr, VariantShape, - collect_recall_like_bounds_for_enum, is_generic_type_param, +use crate::context::SERDE_ENABLED; +use crate::context::internal::enums::{ + EnumIr, VariantIr, VariantShape, collect_recall_like_bounds_for_enum, +}; +use crate::context::internal::shared::{ + CodegenEnv, FieldIr, FieldMember, FieldStrategy, is_generic_type_param, }; #[must_use] @@ -18,8 +21,18 @@ pub(crate) fn gen_memento_enum(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { let where_clause = build_memento_where_clause(ir, env); let variants = ir .variants() - .map(|variant| build_memento_variant(variant, &env.recallable_trait, ir.generic_type_param_idents())) - .chain(ir.synthetic_marker_type().into_iter().map(|marker_ty| build_marker_variant(&marker_ty))); + .map(|variant| { + build_memento_variant( + variant, + &env.recallable_trait, + ir.generic_type_param_idents(), + ) + }) + .chain( + ir.synthetic_marker_type() + .into_iter() + .map(|marker_ty| build_marker_variant(&marker_ty)), + ); quote! { #derives @@ -75,7 +88,7 @@ fn build_memento_variant( } fn build_memento_field( - field: &crate::context::FieldIr<'_>, + field: &FieldIr<'_>, recallable_trait: &TokenStream2, generic_type_params: &HashSet<&Ident>, ) -> TokenStream2 { diff --git a/recallable-macro/src/context/memento/mod.rs b/recallable-macro/src/context/memento/mod.rs index 80e19f8..36173e8 100644 --- a/recallable-macro/src/context/memento/mod.rs +++ b/recallable-macro/src/context/memento/mod.rs @@ -3,7 +3,7 @@ mod structs; use proc_macro2::TokenStream as TokenStream2; -use crate::context::{CodegenEnv, ItemIr}; +use crate::context::internal::shared::{CodegenEnv, ItemIr}; #[must_use] pub(crate) fn gen_memento_type(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { diff --git a/recallable-macro/src/context/memento/structs.rs b/recallable-macro/src/context/memento/structs.rs index c69c571..bba5e35 100644 --- a/recallable-macro/src/context/memento/structs.rs +++ b/recallable-macro/src/context/memento/structs.rs @@ -3,10 +3,11 @@ use quote::quote; use std::collections::HashSet; use syn::{Ident, WhereClause, WherePredicate}; -use crate::context::{ - CodegenEnv, FieldIr, FieldMember, FieldStrategy, SERDE_ENABLED, StructIr, StructShape, - collect_recall_like_bounds, is_generic_type_param, +use crate::context::SERDE_ENABLED; +use crate::context::internal::shared::{ + CodegenEnv, FieldIr, FieldMember, FieldStrategy, is_generic_type_param, }; +use crate::context::internal::structs::{StructIr, StructShape, collect_recall_like_bounds}; #[must_use] pub(crate) fn gen_memento_struct(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { diff --git a/recallable-macro/src/context/recall_impl/enums.rs b/recallable-macro/src/context/recall_impl/enums.rs index 22242b2..5109b99 100644 --- a/recallable-macro/src/context/recall_impl/enums.rs +++ b/recallable-macro/src/context/recall_impl/enums.rs @@ -1,18 +1,16 @@ use proc_macro2::TokenStream as TokenStream2; use quote::quote; -use crate::context::{CodegenEnv, EnumIr, collect_recall_like_bounds_for_enum}; +use crate::context::internal::enums::{EnumIr, collect_recall_like_bounds_for_enum}; +use crate::context::internal::shared::CodegenEnv; #[must_use] pub(crate) fn gen_enum_recall_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { let recall_trait = &env.recall_trait; let impl_generics = ir.impl_generics(); let enum_type = ir.enum_type(); - let where_clause = ir.extend_where_clause(collect_recall_like_bounds_for_enum( - ir, - env, - recall_trait, - )); + let where_clause = + ir.extend_where_clause(collect_recall_like_bounds_for_enum(ir, env, recall_trait)); quote! { impl #impl_generics #recall_trait @@ -20,7 +18,7 @@ pub(crate) fn gen_enum_recall_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream #where_clause { #[inline] fn recall(&mut self, memento: Self::Memento) { - *self = Self::__recallable_rebuild_from_memento(memento); + *self = Self::__recallable_restore_from_memento(memento); } } } diff --git a/recallable-macro/src/context/recall_impl/mod.rs b/recallable-macro/src/context/recall_impl/mod.rs index 5c74b99..99cbbbc 100644 --- a/recallable-macro/src/context/recall_impl/mod.rs +++ b/recallable-macro/src/context/recall_impl/mod.rs @@ -3,7 +3,7 @@ mod structs; use proc_macro2::TokenStream as TokenStream2; -use crate::context::{CodegenEnv, ItemIr}; +use crate::context::internal::shared::{CodegenEnv, ItemIr}; #[must_use] pub(crate) fn gen_recall_impl(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { diff --git a/recallable-macro/src/context/recall_impl/structs.rs b/recallable-macro/src/context/recall_impl/structs.rs index abbb71d..4427670 100644 --- a/recallable-macro/src/context/recall_impl/structs.rs +++ b/recallable-macro/src/context/recall_impl/structs.rs @@ -2,9 +2,8 @@ use proc_macro2::TokenStream as TokenStream2; use quote::quote; use syn::WherePredicate; -use crate::context::{ - CodegenEnv, FieldIr, FieldMember, FieldStrategy, StructIr, collect_recall_like_bounds, -}; +use crate::context::internal::shared::{CodegenEnv, FieldIr, FieldMember, FieldStrategy}; +use crate::context::internal::structs::{StructIr, collect_recall_like_bounds}; #[must_use] pub(crate) fn gen_struct_recall_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { diff --git a/recallable-macro/src/context/recallable_impl/enums.rs b/recallable-macro/src/context/recallable_impl/enums.rs index ad0c008..4f40f56 100644 --- a/recallable-macro/src/context/recallable_impl/enums.rs +++ b/recallable-macro/src/context/recallable_impl/enums.rs @@ -2,10 +2,10 @@ use proc_macro2::TokenStream as TokenStream2; use quote::{ToTokens, format_ident, quote}; use syn::WherePredicate; -use crate::context::{ - CodegenEnv, EnumIr, EnumRecallMode, FieldIr, FieldMember, VariantIr, VariantShape, - collect_recall_like_bounds_for_enum, +use crate::context::internal::enums::{ + EnumIr, VariantIr, VariantShape, collect_recall_like_bounds_for_enum, }; +use crate::context::internal::shared::{CodegenEnv, FieldIr, FieldMember}; #[must_use] pub(crate) fn gen_enum_recallable_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { @@ -14,7 +14,7 @@ pub(crate) fn gen_enum_recallable_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenSt let enum_type = ir.enum_type(); let where_clause = build_enum_recallable_where_clause(ir, env); let memento_type = ir.memento_type(); - let rebuild_helper = gen_enum_rebuild_helper(ir, env); + let restore_helper = gen_enum_restore_helper(ir, env); quote! { impl #impl_generics #recallable_trait @@ -23,14 +23,11 @@ pub(crate) fn gen_enum_recallable_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenSt type Memento = #memento_type; } - #rebuild_helper + #restore_helper } } -fn build_enum_recallable_where_clause( - ir: &EnumIr, - env: &CodegenEnv, -) -> Option { +fn build_enum_recallable_where_clause(ir: &EnumIr, env: &CodegenEnv) -> Option { let extra_bounds = collect_enum_recallable_bounds(ir, env); ir.extend_where_clause(extra_bounds) } @@ -39,8 +36,8 @@ fn collect_enum_recallable_bounds(ir: &EnumIr, env: &CodegenEnv) -> Vec TokenStream2 { - if !matches!(ir.recall_mode(), EnumRecallMode::AssignmentOnly) { +fn gen_enum_restore_helper(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { + if !ir.supports_derived_recall() { return quote! {}; } @@ -52,7 +49,7 @@ fn gen_enum_rebuild_helper(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { let arms = ir.variants().map(|variant| { let variant_name = variant.name; let pattern = build_variant_memento_pattern(variant); - let expr = build_variant_rebuild_expr(variant, ir.name()); + let expr = build_variant_restore_expr(variant, ir.name()); quote! { #memento_name::#variant_name #pattern => #expr } }); let marker_arm = ir.synthetic_marker_type().map(|_| { @@ -62,7 +59,7 @@ fn gen_enum_rebuild_helper(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { quote! { impl #impl_generics #enum_type #where_clause { #[inline] - fn __recallable_rebuild_from_memento( + fn __recallable_restore_from_memento( memento: <#enum_type as #recallable_trait>::Memento, ) -> Self { match memento { @@ -96,7 +93,7 @@ fn build_variant_memento_pattern(variant: &VariantIr<'_>) -> TokenStream2 { } } -fn build_variant_rebuild_expr(variant: &VariantIr<'_>, enum_name: &syn::Ident) -> TokenStream2 { +fn build_variant_restore_expr(variant: &VariantIr<'_>, enum_name: &syn::Ident) -> TokenStream2 { let variant_name = variant.name; match variant.shape { @@ -126,3 +123,32 @@ fn build_binding_ident(field: &FieldIr<'_>, index: usize) -> syn::Ident { FieldMember::Unnamed(_) => format_ident!("__recallable_field_{index}"), } } + +#[cfg(test)] +mod tests { + use quote::quote; + use syn::parse_quote; + + use super::gen_enum_recallable_impl; + use crate::context::internal::enums::EnumIr; + use crate::context::internal::shared::CodegenEnv; + + #[test] + fn helper_name_and_manual_only_guidance_helper_name() { + let input: syn::DeriveInput = parse_quote! { + enum Example { + Value(u32), + } + }; + let ir = EnumIr::analyze(&input).unwrap(); + let env = CodegenEnv { + recallable_trait: quote!(::recallable::Recallable), + recall_trait: quote!(::recallable::Recall), + }; + + let tokens = gen_enum_recallable_impl(&ir, &env).to_string(); + + assert!(tokens.contains("__recallable_restore_from_memento")); + assert!(!tokens.contains("__recallable_rebuild_from_memento")); + } +} diff --git a/recallable-macro/src/context/recallable_impl/mod.rs b/recallable-macro/src/context/recallable_impl/mod.rs index b4c8456..c67b191 100644 --- a/recallable-macro/src/context/recallable_impl/mod.rs +++ b/recallable-macro/src/context/recallable_impl/mod.rs @@ -3,7 +3,7 @@ mod structs; use proc_macro2::TokenStream as TokenStream2; -use crate::context::{CodegenEnv, ItemIr}; +use crate::context::internal::shared::{CodegenEnv, ItemIr}; #[must_use] pub(crate) fn gen_recallable_impl(ir: &ItemIr, env: &CodegenEnv) -> TokenStream2 { diff --git a/recallable-macro/src/context/recallable_impl/structs.rs b/recallable-macro/src/context/recallable_impl/structs.rs index f49c177..902f1b9 100644 --- a/recallable-macro/src/context/recallable_impl/structs.rs +++ b/recallable-macro/src/context/recallable_impl/structs.rs @@ -2,7 +2,8 @@ use proc_macro2::TokenStream as TokenStream2; use quote::quote; use syn::WherePredicate; -use crate::context::{CodegenEnv, StructIr, collect_recall_like_bounds}; +use crate::context::internal::shared::CodegenEnv; +use crate::context::internal::structs::{StructIr, collect_recall_like_bounds}; #[must_use] pub(crate) fn gen_struct_recallable_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { diff --git a/recallable-macro/src/lib.rs b/recallable-macro/src/lib.rs index 173586b..343dfb6 100644 --- a/recallable-macro/src/lib.rs +++ b/recallable-macro/src/lib.rs @@ -7,7 +7,8 @@ //! - `#[recallable_model]`: injects `Recallable`/`Recall` derives for structs and //! assignment-only enums; with the `serde` Cargo feature enabled for this macro //! crate it also adds `serde::Serialize` and applies `#[serde(skip)]` to fields -//! marked `#[recallable(skip)]`. +//! marked `#[recallable(skip)]`. Complex enums should derive `Recallable` and +//! implement `Recall` or `TryRecall` manually. //! //! - `#[derive(Recallable)]`: generates an internal companion memento type, exposes //! it as `::Memento`, and emits the `Recallable` impl; with the @@ -37,6 +38,8 @@ mod model_macro; /// - For fields annotated with `#[recallable(skip)]`, it injects `#[serde(skip)]` /// to keep serde output aligned with recall behavior. /// - This attribute itself takes no arguments. +/// - Complex enums with nested `#[recallable]` or skipped fields are rejected so +/// the caller can keep `Recall` or `TryRecall` explicit. /// /// This macro preserves the original struct shape and only mutates attributes. /// @@ -55,11 +58,13 @@ pub fn recallable_model(attr: TokenStream, item: TokenStream) -> TokenStream { /// Supports enums by generating an enum-shaped memento with matching variants. /// For enums, `#[derive(Recall)]` and `#[recallable_model]` are available only /// when every variant field is assignment-only. +/// Complex enums can still derive `Recallable` alone and provide manual +/// `Recall` or `TryRecall` implementations. /// /// The generated memento type: -/// - mirrors the original struct shape (named/tuple/unit), +/// - mirrors the original item shape (struct or enum), /// - includes fields unless marked with `#[recallable(skip)]`, -/// - uses the same visibility as the input struct, +/// - uses the same visibility as the input item, /// - keeps all generated fields private by omitting field-level visibility modifiers, /// - also derives `serde::Deserialize` when the `serde` feature is enabled for the /// macro crate. @@ -91,7 +96,7 @@ pub fn recallable_model(attr: TokenStream, item: TokenStream) -> TokenStream { #[proc_macro_derive(Recallable, attributes(recallable))] pub fn derive_recallable(input: TokenStream) -> TokenStream { let input: DeriveInput = parse_macro_input!(input as DeriveInput); - let ir = match context::ItemIr::analyze(&input) { + let ir = match context::analyze_item(&input) { Ok(ir) => ir, Err(e) => return e.to_compile_error().into(), }; @@ -134,17 +139,14 @@ pub fn derive_recallable(input: TokenStream) -> TokenStream { /// For `#[recallable]` fields, replace/merge behavior comes from the field type's own /// `Recall` implementation. /// Enums are supported only when every variant field is assignment-only. +/// For supported enums, the generated implementation restores the target variant +/// from the memento directly. pub fn derive_recall(input: TokenStream) -> TokenStream { let input: DeriveInput = parse_macro_input!(input as DeriveInput); - let ir = match context::ItemIr::analyze(&input) { + let ir = match context::analyze_recall_input(&input) { Ok(ir) => ir, Err(e) => return e.to_compile_error().into(), }; - if let context::ItemIr::Enum(enum_ir) = &ir - && let Err(e) = enum_ir.ensure_recall_derivable() - { - return e.to_compile_error().into(); - } let env = context::CodegenEnv::resolve(); let recall_impl = context::gen_recall_impl(&ir, &env); diff --git a/recallable-macro/src/model_macro.rs b/recallable-macro/src/model_macro.rs index ab4dafc..983bc92 100644 --- a/recallable-macro/src/model_macro.rs +++ b/recallable-macro/src/model_macro.rs @@ -4,7 +4,7 @@ use proc_macro2::TokenStream as TokenStream2; use quote::{ToTokens, quote}; use syn::{Fields, Item, ItemEnum, ItemStruct, parse_quote}; -use crate::context::{self, EnumRecallMode, SERDE_ENABLED, crate_path, has_recallable_skip_attr}; +use crate::context::{self, SERDE_ENABLED, crate_path, has_recallable_skip_attr}; enum ModelItem { Struct(ItemStruct), @@ -60,21 +60,9 @@ pub(super) fn expand(attr: TokenStream, item: TokenStream) -> TokenStream { Err(e) => return e.to_compile_error().into(), }, }; - let ir = match context::ItemIr::analyze(&derive_input) { - Ok(ir) => ir, - Err(e) => return e.to_compile_error().into(), - }; - if let context::ItemIr::Enum(enum_ir) = &ir - && enum_ir.recall_mode() == EnumRecallMode::ManualOnly - { - return syn::Error::new_spanned( - &derive_input.ident, - "`#[recallable_model]` on enums requires assignment-only variants because it injects `Recall`", - ) - .to_compile_error() - .into(); + if let Err(e) = context::analyze_model_input(&derive_input) { + return e.to_compile_error().into(); } - if SERDE_ENABLED && let Err(e) = check_no_serialize_derive(model_item.attrs()) { return e.to_compile_error().into(); } diff --git a/recallable/src/lib.rs b/recallable/src/lib.rs index 707d360..70c8085 100644 --- a/recallable/src/lib.rs +++ b/recallable/src/lib.rs @@ -1,6 +1,6 @@ //! # Recallable //! -//! A crate for handling partial updates to data structures. +//! A crate for memento-based recall on live data structures. //! //! This crate provides the [`Recallable`], [`Recall`], and [`TryRecall`] traits, along with //! derive macros for `Recallable` and `Recall`, and an attribute macro `recallable_model` @@ -31,6 +31,8 @@ extern crate self as recallable; /// Adds `#[derive(Recallable, Recall)]` automatically. When the `serde` feature is enabled, /// also derives `serde::Serialize` on the struct and injects `#[serde(skip)]` on fields /// marked with `#[recallable(skip)]`. +/// Complex enums with nested `#[recallable]` or `#[recallable(skip)]` fields should +/// derive [`Recallable`] and implement [`Recall`] or [`TryRecall`] manually. /// /// Lifetime parameters are supported only when the generated memento can stay owned: /// non-skipped fields may not borrow one of the struct's lifetimes. Skipped borrowed @@ -71,6 +73,8 @@ pub use recallable_macro::recallable_model; /// Supports enums by generating an enum-shaped memento with matching variants. /// For enums, `#[derive(Recall)]` and `#[recallable_model]` are available only /// when every variant field is assignment-only. +/// Enums with nested `#[recallable]` or `#[recallable(skip)]` fields can still +/// derive [`Recallable`] and provide manual [`Recall`] or [`TryRecall`] behavior. /// /// The memento struct mirrors the original but replaces `#[recallable]`-annotated fields /// with their `::Memento` type and omits `#[recallable(skip)]` fields. @@ -130,6 +134,8 @@ pub use recallable_macro::Recallable; /// Enums are supported only when every variant field is assignment-only. /// Enums with nested `#[recallable]` or skipped fields should derive /// [`Recallable`] and implement [`Recall`] or [`TryRecall`] manually. +/// For supported enums, the derived implementation restores the target variant +/// from the memento directly. /// /// Lifetime parameters are supported only when the generated memento can stay owned: /// non-skipped fields may not borrow one of the struct's lifetimes. Skipped borrowed diff --git a/recallable/tests/ui/derive_fail_enum_recall_nested.stderr b/recallable/tests/ui/derive_fail_enum_recall_nested.stderr index 0eca273..5be4d9e 100644 --- a/recallable/tests/ui/derive_fail_enum_recall_nested.stderr +++ b/recallable/tests/ui/derive_fail_enum_recall_nested.stderr @@ -1,4 +1,4 @@ -error: enum `Recall` derive only supports assignment-only variant fields +error: enum `Recall` derive requires assignment-only variant fields; derive `Recallable` and implement `Recall` or `TryRecall` manually --> tests/ui/derive_fail_enum_recall_nested.rs:11:9 | 11 | / #[recallable] diff --git a/recallable/tests/ui/derive_fail_enum_recall_skip.stderr b/recallable/tests/ui/derive_fail_enum_recall_skip.stderr index 5deed84..037054f 100644 --- a/recallable/tests/ui/derive_fail_enum_recall_skip.stderr +++ b/recallable/tests/ui/derive_fail_enum_recall_skip.stderr @@ -1,4 +1,4 @@ -error: enum `Recall` derive only supports assignment-only variant fields +error: enum `Recall` derive requires assignment-only variant fields; derive `Recallable` and implement `Recall` or `TryRecall` manually --> tests/ui/derive_fail_enum_recall_skip.rs:6:9 | 6 | / #[recallable(skip)] diff --git a/recallable/tests/ui/model_fail_enum_recallable_variant.stderr b/recallable/tests/ui/model_fail_enum_recallable_variant.stderr index 788b95c..80f6e98 100644 --- a/recallable/tests/ui/model_fail_enum_recallable_variant.stderr +++ b/recallable/tests/ui/model_fail_enum_recallable_variant.stderr @@ -1,4 +1,4 @@ -error: `#[recallable_model]` on enums requires assignment-only variants because it injects `Recall` +error: `#[recallable_model]` on enums requires assignment-only variants; complex enums should derive `Recallable` and implement `Recall` or `TryRecall` manually --> tests/ui/model_fail_enum_recallable_variant.rs:9:6 | 9 | enum InvalidModelNestedEnum { diff --git a/recallable/tests/ui/model_fail_enum_skip_variant.stderr b/recallable/tests/ui/model_fail_enum_skip_variant.stderr index e8bb005..5f85d9b 100644 --- a/recallable/tests/ui/model_fail_enum_skip_variant.stderr +++ b/recallable/tests/ui/model_fail_enum_skip_variant.stderr @@ -1,4 +1,4 @@ -error: `#[recallable_model]` on enums requires assignment-only variants because it injects `Recall` +error: `#[recallable_model]` on enums requires assignment-only variants; complex enums should derive `Recallable` and implement `Recall` or `TryRecall` manually --> tests/ui/model_fail_enum_skip_variant.rs:4:6 | 4 | enum InvalidModelSkippedEnum { From 2a219b8730c063dbf0fbd1e7081498986d324096 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 04:34:59 +0800 Subject: [PATCH 11/45] refactor(macro): normalize flat module layout --- recallable-macro/src/context/{from_impl/mod.rs => from_impl.rs} | 0 recallable-macro/src/context/internal/{enums/mod.rs => enums.rs} | 0 .../src/context/internal/{shared/mod.rs => shared.rs} | 0 .../src/context/internal/{structs/mod.rs => structs.rs} | 0 recallable-macro/src/context/{memento/mod.rs => memento.rs} | 0 .../src/context/{recall_impl/mod.rs => recall_impl.rs} | 0 .../src/context/{recallable_impl/mod.rs => recallable_impl.rs} | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename recallable-macro/src/context/{from_impl/mod.rs => from_impl.rs} (100%) rename recallable-macro/src/context/internal/{enums/mod.rs => enums.rs} (100%) rename recallable-macro/src/context/internal/{shared/mod.rs => shared.rs} (100%) rename recallable-macro/src/context/internal/{structs/mod.rs => structs.rs} (100%) rename recallable-macro/src/context/{memento/mod.rs => memento.rs} (100%) rename recallable-macro/src/context/{recall_impl/mod.rs => recall_impl.rs} (100%) rename recallable-macro/src/context/{recallable_impl/mod.rs => recallable_impl.rs} (100%) diff --git a/recallable-macro/src/context/from_impl/mod.rs b/recallable-macro/src/context/from_impl.rs similarity index 100% rename from recallable-macro/src/context/from_impl/mod.rs rename to recallable-macro/src/context/from_impl.rs diff --git a/recallable-macro/src/context/internal/enums/mod.rs b/recallable-macro/src/context/internal/enums.rs similarity index 100% rename from recallable-macro/src/context/internal/enums/mod.rs rename to recallable-macro/src/context/internal/enums.rs diff --git a/recallable-macro/src/context/internal/shared/mod.rs b/recallable-macro/src/context/internal/shared.rs similarity index 100% rename from recallable-macro/src/context/internal/shared/mod.rs rename to recallable-macro/src/context/internal/shared.rs diff --git a/recallable-macro/src/context/internal/structs/mod.rs b/recallable-macro/src/context/internal/structs.rs similarity index 100% rename from recallable-macro/src/context/internal/structs/mod.rs rename to recallable-macro/src/context/internal/structs.rs diff --git a/recallable-macro/src/context/memento/mod.rs b/recallable-macro/src/context/memento.rs similarity index 100% rename from recallable-macro/src/context/memento/mod.rs rename to recallable-macro/src/context/memento.rs diff --git a/recallable-macro/src/context/recall_impl/mod.rs b/recallable-macro/src/context/recall_impl.rs similarity index 100% rename from recallable-macro/src/context/recall_impl/mod.rs rename to recallable-macro/src/context/recall_impl.rs diff --git a/recallable-macro/src/context/recallable_impl/mod.rs b/recallable-macro/src/context/recallable_impl.rs similarity index 100% rename from recallable-macro/src/context/recallable_impl/mod.rs rename to recallable-macro/src/context/recallable_impl.rs From 5dd3f2fde22d757d95330c7ef2e80b4987b28b87 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 12:52:07 +0800 Subject: [PATCH 12/45] refactor(macro): simplify code --- recallable-macro/src/context.rs | 9 +- recallable-macro/src/model_macro.rs | 157 ++++++++++++++-------------- 2 files changed, 85 insertions(+), 81 deletions(-) diff --git a/recallable-macro/src/context.rs b/recallable-macro/src/context.rs index d97bdd6..8459f5b 100644 --- a/recallable-macro/src/context.rs +++ b/recallable-macro/src/context.rs @@ -41,14 +41,14 @@ pub(super) fn analyze_recall_input(input: &DeriveInput) -> syn::Result syn::Result> { +pub(super) fn analyze_model_input(input: &DeriveInput) -> syn::Result<()> { let ir = analyze_item(input)?; if let ItemIr::Enum(enum_ir) = &ir { enum_ir.ensure_model_derive_allowed()?; } - Ok(ir) + Ok(()) } pub(crate) fn gen_memento_type(ir: &ItemIr, env: &CodegenEnv) -> proc_macro2::TokenStream { @@ -88,11 +88,10 @@ mod tests { assert_eq!(struct_ir.memento_name().to_string(), "ExampleMemento"); assert_eq!(enum_ir.memento_name().to_string(), "ChoiceMemento"); - assert_eq!( + assert!( crate::context::memento::gen_memento_type(&ItemIr::Struct(struct_ir), &env) .to_string() - .contains("ExampleMemento"), - true + .contains("ExampleMemento") ); } diff --git a/recallable-macro/src/model_macro.rs b/recallable-macro/src/model_macro.rs index 983bc92..88f11f1 100644 --- a/recallable-macro/src/model_macro.rs +++ b/recallable-macro/src/model_macro.rs @@ -1,42 +1,15 @@ use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; -use quote::{ToTokens, quote}; +use quote::ToTokens; use syn::{Fields, Item, ItemEnum, ItemStruct, parse_quote}; use crate::context::{self, SERDE_ENABLED, crate_path, has_recallable_skip_attr}; -enum ModelItem { - Struct(ItemStruct), - Enum(ItemEnum), -} - -impl ModelItem { - fn attrs(&self) -> &[syn::Attribute] { - match self { - Self::Struct(item) => &item.attrs, - Self::Enum(item) => &item.attrs, - } - } - - fn attrs_mut(&mut self) -> &mut Vec { - match self { - Self::Struct(item) => &mut item.attrs, - Self::Enum(item) => &mut item.attrs, - } - } - - fn add_serde_skip_attrs(&mut self) { - match self { - Self::Struct(item) => add_serde_skip_attrs_to_fields(&mut item.fields), - Self::Enum(item) => { - for variant in &mut item.variants { - add_serde_skip_attrs_to_fields(&mut variant.fields); - } - } - } - } -} +const DERIVE: &str = "derive"; +const SERIALIZE: &str = "Serialize"; +const SERDE: &str = "serde"; +const SERDE_DERIVE: &str = "serde_derive"; #[must_use] pub(super) fn expand(attr: TokenStream, item: TokenStream) -> TokenStream { @@ -45,20 +18,13 @@ pub(super) fn expand(attr: TokenStream, item: TokenStream) -> TokenStream { return err.to_compile_error().into(); } - let crate_path = crate_path(); let mut model_item = match parse_model_item(item) { Ok(item) => item, Err(e) => return e.to_compile_error().into(), }; - let derive_input: syn::DeriveInput = match &model_item { - ModelItem::Struct(item) => match syn::parse2(item.to_token_stream()) { - Ok(input) => input, - Err(e) => return e.to_compile_error().into(), - }, - ModelItem::Enum(item) => match syn::parse2(item.to_token_stream()) { - Ok(input) => input, - Err(e) => return e.to_compile_error().into(), - }, + let derive_input: syn::DeriveInput = match model_item.parse() { + Ok(input) => input, + Err(e) => return e.to_compile_error().into(), }; if let Err(e) = context::analyze_model_input(&derive_input) { return e.to_compile_error().into(); @@ -67,18 +33,13 @@ pub(super) fn expand(attr: TokenStream, item: TokenStream) -> TokenStream { return e.to_compile_error().into(); } - let derives = build_model_derive_attr(&crate_path); - - model_item.attrs_mut().push(derives); + model_item.add_derives(); if SERDE_ENABLED { model_item.add_serde_skip_attrs(); } - match model_item { - ModelItem::Struct(item) => quote! { #item }.into(), - ModelItem::Enum(item) => quote! { #item }.into(), - } + model_item.item_tokenstream().into() } fn validate_model_attr(attr: &TokenStream2) -> syn::Result<()> { @@ -131,37 +92,81 @@ fn add_serde_skip_attrs_to_fields(fields: &mut Fields) { /// Called only when `SERDE_ENABLED` is true, before `#[recallable_model]` /// injects its own `::serde::Serialize` derive. fn check_no_serialize_derive(attrs: &[syn::Attribute]) -> syn::Result<()> { - for attr in attrs { - if !attr.path().is_ident("derive") { - continue; + attrs + .iter() + .filter(|attr| attr.path().is_ident(DERIVE)) + .try_for_each(|attr| { + attr.parse_nested_meta(|meta| { + if is_serde_serialize_path(&meta.path) { + Err(meta.error( + "`#[recallable_model]` already derives `serde::Serialize` when the \ + `serde` feature is enabled — remove the manual `Serialize` derive", + )) + } else { + Ok(()) + } + }) + }) +} + +fn is_serde_serialize_path(path: &syn::Path) -> bool { + // Attribute macros cannot resolve imported names, so keep treating a bare + // `Serialize` derive as serde-shaped for the common `use serde::Serialize;` case. + path.is_ident("Serialize") || { + let mut segments = path.segments.iter(); + matches!( + (segments.next(), segments.next(), segments.next()), + (Some(first), Some(second), None) + if (first.ident == SERDE || first.ident == SERDE_DERIVE) + && second.ident == SERIALIZE + ) + } +} + +enum ModelItem { + Struct(ItemStruct), + Enum(ItemEnum), +} + +impl ModelItem { + fn attrs(&self) -> &[syn::Attribute] { + match self { + Self::Struct(item) => &item.attrs, + Self::Enum(item) => &item.attrs, } - attr.parse_nested_meta(|meta| { - if is_serde_serialize_path(&meta.path) { - return Err(meta.error( - "`#[recallable_model]` already derives `serde::Serialize` when the \ - `serde` feature is enabled — remove the manual `Serialize` derive", - )); + } + + fn add_derives(&mut self) { + let crate_path = crate_path(); + let derives = build_model_derive_attr(&crate_path); + let attrs = match self { + Self::Struct(item) => &mut item.attrs, + Self::Enum(item) => &mut item.attrs, + }; + attrs.push(derives); + } + + fn add_serde_skip_attrs(&mut self) { + match self { + Self::Struct(item) => add_serde_skip_attrs_to_fields(&mut item.fields), + Self::Enum(item) => { + for variant in &mut item.variants { + add_serde_skip_attrs_to_fields(&mut variant.fields); + } } - Ok(()) - })?; + } } - Ok(()) -} -fn is_serde_serialize_path(path: &syn::Path) -> bool { - if path.is_ident("Serialize") { - // Attribute macros cannot resolve imported names, so keep treating a bare - // `Serialize` derive as serde-shaped for the common `use serde::Serialize;` case. - return true; - } - - let mut segments = path.segments.iter(); - matches!( - (segments.next(), segments.next(), segments.next()), - (Some(first), Some(second), None) - if (first.ident == "serde" || first.ident == "serde_derive") - && second.ident == "Serialize" - ) + fn item_tokenstream(&self) -> TokenStream2 { + match self { + ModelItem::Struct(item) => item.to_token_stream(), + ModelItem::Enum(item) => item.to_token_stream(), + } + } + + fn parse(&self) -> syn::Result { + syn::parse2(self.item_tokenstream()) + } } #[cfg(test)] From 2a7442bb413c6daeee417811e8379a6956099000 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 12:52:55 +0800 Subject: [PATCH 13/45] test: eliminate warnings from tests --- recallable/tests/enum_recall.rs | 1 + recallable/tests/enum_recallable.rs | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/recallable/tests/enum_recall.rs b/recallable/tests/enum_recall.rs index 8320956..2fdf84d 100644 --- a/recallable/tests/enum_recall.rs +++ b/recallable/tests/enum_recall.rs @@ -4,6 +4,7 @@ use recallable::{Recall, Recallable}; #[derive(Clone, Debug, PartialEq, recallable::Recallable, recallable::Recall)] enum AssignmentOnlyEnum { + #[allow(dead_code)] Idle, Loading(T), Ready { diff --git a/recallable/tests/enum_recallable.rs b/recallable/tests/enum_recallable.rs index 15c45e7..d2ee962 100644 --- a/recallable/tests/enum_recallable.rs +++ b/recallable/tests/enum_recallable.rs @@ -8,6 +8,7 @@ struct GenericInner { value: T, } +#[allow(dead_code)] #[derive(Clone, Debug, PartialEq, recallable::Recallable)] enum NestedEnum { Idle, @@ -17,6 +18,7 @@ enum NestedEnum { }, } +#[allow(dead_code)] #[derive(Clone, Debug, PartialEq, recallable::Recallable)] enum SkippedEnum<'a> { Idle, @@ -27,6 +29,7 @@ enum SkippedEnum<'a> { }, } +#[allow(dead_code)] #[derive(Clone, Debug, PartialEq, recallable::Recallable)] enum PhantomEnum<'a, T> { Idle, @@ -36,6 +39,7 @@ enum PhantomEnum<'a, T> { }, } +#[allow(dead_code)] #[derive(Clone, Debug, PartialEq, recallable::Recallable)] enum BoundDependentEnum, U> { Value { @@ -45,6 +49,7 @@ enum BoundDependentEnum, U> { }, } +#[allow(dead_code)] #[derive(Clone, Debug, PartialEq, recallable::Recallable)] enum SkippedGenericEnum { Value(T), From 21aafb0401ccd6d171c0aaf7a9bcef399ab39fa3 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 13:12:44 +0800 Subject: [PATCH 14/45] fix(macro): remove warning-producing derive output --- .../src/context/from_impl/enums.rs | 9 ++++-- recallable-macro/src/context/memento/enums.rs | 1 + .../src/context/memento/structs.rs | 1 + recallable/tests/enum_recall.rs | 2 ++ recallable/tests/macro_expansion_failures.rs | 1 + .../tests/ui/derive_pass_enum_no_warnings.rs | 29 +++++++++++++++++++ 6 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 recallable/tests/ui/derive_pass_enum_no_warnings.rs diff --git a/recallable-macro/src/context/from_impl/enums.rs b/recallable-macro/src/context/from_impl/enums.rs index 34a18da..7b22cc4 100644 --- a/recallable-macro/src/context/from_impl/enums.rs +++ b/recallable-macro/src/context/from_impl/enums.rs @@ -57,9 +57,12 @@ fn build_variant_source_pattern(variant: &VariantIr<'_>) -> TokenStream2 { match variant.shape { VariantShape::Named => { let patterns = variant.fields.iter().enumerate().map(|(index, field)| { - let member = &field.member; - let binding = build_binding_pattern(field, index); - quote! { #member: #binding } + if field.strategy.is_skip() { + let member = &field.member; + quote! { #member: _ } + } else { + build_binding_ident(field, index).to_token_stream() + } }); quote! { { #(#patterns),* } } } diff --git a/recallable-macro/src/context/memento/enums.rs b/recallable-macro/src/context/memento/enums.rs index 4e634ab..da4187d 100644 --- a/recallable-macro/src/context/memento/enums.rs +++ b/recallable-macro/src/context/memento/enums.rs @@ -35,6 +35,7 @@ pub(crate) fn gen_memento_enum(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { ); quote! { + #[allow(dead_code)] #derives #visibility enum #memento_name #memento_generics #where_clause { #(#variants),* diff --git a/recallable-macro/src/context/memento/structs.rs b/recallable-macro/src/context/memento/structs.rs index bba5e35..4fee1e7 100644 --- a/recallable-macro/src/context/memento/structs.rs +++ b/recallable-macro/src/context/memento/structs.rs @@ -18,6 +18,7 @@ pub(crate) fn gen_memento_struct(ir: &StructIr, env: &CodegenEnv) -> TokenStream let body = build_memento_body(ir, env); quote! { + #[allow(dead_code)] #derives #visibility struct #memento_name #memento_generics #body } diff --git a/recallable/tests/enum_recall.rs b/recallable/tests/enum_recall.rs index 2fdf84d..e73fe86 100644 --- a/recallable/tests/enum_recall.rs +++ b/recallable/tests/enum_recall.rs @@ -1,3 +1,5 @@ +#![deny(dead_code)] + use core::marker::PhantomData; use recallable::{Recall, Recallable}; diff --git a/recallable/tests/macro_expansion_failures.rs b/recallable/tests/macro_expansion_failures.rs index 98f4e64..97a4fb9 100644 --- a/recallable/tests/macro_expansion_failures.rs +++ b/recallable/tests/macro_expansion_failures.rs @@ -18,6 +18,7 @@ fn derive_macro_reports_expected_failures() { tests.compile_fail("tests/ui/model_fail_enum_recallable_variant.rs"); tests.compile_fail("tests/ui/model_fail_enum_skip_variant.rs"); tests.pass("tests/ui/derive_pass_skip_memento_default_derives.rs"); + tests.pass("tests/ui/derive_pass_enum_no_warnings.rs"); #[cfg(feature = "serde")] { tests.compile_fail("tests/ui/model_fail_duplicate_serialize.rs"); diff --git a/recallable/tests/ui/derive_pass_enum_no_warnings.rs b/recallable/tests/ui/derive_pass_enum_no_warnings.rs new file mode 100644 index 0000000..6b0efd0 --- /dev/null +++ b/recallable/tests/ui/derive_pass_enum_no_warnings.rs @@ -0,0 +1,29 @@ +#![deny(warnings)] + +use core::marker::PhantomData; + +use recallable::{Recall, Recallable}; + +#[derive(Clone, Debug, PartialEq, recallable::Recallable, recallable::Recall)] +enum Example { + Idle, + Loading(T), + Ready { + value: T, + marker: PhantomData<[(); N]>, + }, +} + +fn main() { + type Memento = as Recallable>::Memento; + + let _ = Example::::Idle; + + let mut state = Example::::Loading(1); + state.recall(Memento::Ready { + value: 2, + marker: PhantomData, + }); + + let _ = state; +} From d442df096555d0b36c1d9e1b873dfcccbf8bfb1c Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 13:36:14 +0800 Subject: [PATCH 15/45] refactor(macro): append impl_from output directly --- recallable-macro/src/lib.rs | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/recallable-macro/src/lib.rs b/recallable-macro/src/lib.rs index 343dfb6..d9171ab 100644 --- a/recallable-macro/src/lib.rs +++ b/recallable-macro/src/lib.rs @@ -104,27 +104,23 @@ pub fn derive_recallable(input: TokenStream) -> TokenStream { let memento_struct = context::gen_memento_type(&ir, &env); let recallable_impl = context::gen_recallable_impl(&ir, &env); - let from_impl = if context::IMPL_FROM_ENABLED { - let from_impl = context::gen_from_impl(&ir, &env); - quote! { - #[automatically_derived] - #from_impl - } - } else { - quote! {} - }; - let output = quote! { + let mut output = quote! { const _: () = { #[automatically_derived] #memento_struct #[automatically_derived] #recallable_impl - - #from_impl }; }; + if context::IMPL_FROM_ENABLED { + let from_impl = context::gen_from_impl(&ir, &env); + output.extend(quote! { + #[automatically_derived] + #from_impl + }) + } output.into() } From e79aa30105bec88b2336090a56f39158e64880bf Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 17:37:48 +0800 Subject: [PATCH 16/45] refactor: remove `&` on values that are `Copy` --- recallable-macro/src/context/from_impl/enums.rs | 2 +- recallable-macro/src/context/from_impl/structs.rs | 2 +- recallable-macro/src/context/memento/enums.rs | 2 +- recallable-macro/src/context/memento/structs.rs | 2 +- recallable-macro/src/context/recall_impl/structs.rs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/recallable-macro/src/context/from_impl/enums.rs b/recallable-macro/src/context/from_impl/enums.rs index 7b22cc4..179aaaf 100644 --- a/recallable-macro/src/context/from_impl/enums.rs +++ b/recallable-macro/src/context/from_impl/enums.rs @@ -127,7 +127,7 @@ fn build_variant_from_expr(variant: &VariantIr<'_>) -> TokenStream2 { } fn build_from_binding_expr(field: &FieldIr<'_>, binding: &syn::Ident) -> TokenStream2 { - match &field.strategy { + match field.strategy { FieldStrategy::StoreAsSelf => quote! { #binding }, FieldStrategy::StoreAsMemento => quote! { ::core::convert::From::from(#binding) }, FieldStrategy::Skip => unreachable!("filtered above"), diff --git a/recallable-macro/src/context/from_impl/structs.rs b/recallable-macro/src/context/from_impl/structs.rs index c6c23e6..6705bc3 100644 --- a/recallable-macro/src/context/from_impl/structs.rs +++ b/recallable-macro/src/context/from_impl/structs.rs @@ -84,7 +84,7 @@ fn build_named_marker_init() -> TokenStream2 { fn build_from_expr(field: &FieldIr) -> TokenStream2 { let member = &field.member; - match &field.strategy { + match field.strategy { FieldStrategy::StoreAsSelf => quote! { value.#member }, FieldStrategy::StoreAsMemento => { quote! { ::core::convert::From::from(value.#member) } diff --git a/recallable-macro/src/context/memento/enums.rs b/recallable-macro/src/context/memento/enums.rs index da4187d..84920df 100644 --- a/recallable-macro/src/context/memento/enums.rs +++ b/recallable-macro/src/context/memento/enums.rs @@ -94,7 +94,7 @@ fn build_memento_field( generic_type_params: &HashSet<&Ident>, ) -> TokenStream2 { let ty = field.ty; - let field_ty = match &field.strategy { + let field_ty = match field.strategy { FieldStrategy::StoreAsMemento => { if is_generic_type_param(ty, generic_type_params) { quote! { #ty::Memento } diff --git a/recallable-macro/src/context/memento/structs.rs b/recallable-macro/src/context/memento/structs.rs index 4fee1e7..1fddeb4 100644 --- a/recallable-macro/src/context/memento/structs.rs +++ b/recallable-macro/src/context/memento/structs.rs @@ -93,7 +93,7 @@ fn build_memento_field( generic_type_params: &HashSet<&Ident>, ) -> TokenStream2 { let ty = field.ty; - let field_ty = match &field.strategy { + let field_ty = match field.strategy { FieldStrategy::StoreAsMemento => { if is_generic_type_param(ty, generic_type_params) { quote! { #ty::Memento } diff --git a/recallable-macro/src/context/recall_impl/structs.rs b/recallable-macro/src/context/recall_impl/structs.rs index 4427670..99ae028 100644 --- a/recallable-macro/src/context/recall_impl/structs.rs +++ b/recallable-macro/src/context/recall_impl/structs.rs @@ -56,7 +56,7 @@ fn build_recall_statement(field: &FieldIr, recall_trait: &TokenStream2) -> Token let member = &field.member; let memento_member = build_memento_member(field); - match &field.strategy { + match field.strategy { FieldStrategy::StoreAsSelf => { quote! { self.#member = memento.#memento_member; } } From c6f34f6ed9d8c09264bbfe1fdb895a516eda0d70 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 17:41:35 +0800 Subject: [PATCH 17/45] refactor: make some function return iterator instead of vectors --- .../src/context/internal/enums/bounds.rs | 6 +++-- .../src/context/internal/enums/ir.rs | 25 +++++++++++-------- .../src/context/internal/structs/bounds.rs | 7 +++--- .../src/context/internal/structs/ir.rs | 25 +++++++++++-------- 4 files changed, 38 insertions(+), 25 deletions(-) diff --git a/recallable-macro/src/context/internal/enums/bounds.rs b/recallable-macro/src/context/internal/enums/bounds.rs index 7c94080..4f59b8f 100644 --- a/recallable-macro/src/context/internal/enums/bounds.rs +++ b/recallable-macro/src/context/internal/enums/bounds.rs @@ -29,7 +29,9 @@ fn collect_shared_memento_bounds_with_spec_for_enum( let recallable_trait = &env.recallable_trait; let memento_trait_bounds = memento_trait_spec.common_bound_tokens(); - let mut bounds = ir.recallable_memento_bounds(&memento_trait_bounds); + let mut bounds = ir + .recallable_memento_bounds(&memento_trait_bounds) + .collect::>(); bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &memento_trait_bounds)); if let Some(deserialize_owned) = memento_trait_spec.serde_nested_bound() { bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &deserialize_owned)); @@ -48,7 +50,7 @@ fn collect_recall_like_bounds_with_spec_for_enum( collect_shared_memento_bounds_with_spec_for_enum(ir, env, memento_trait_spec); let shared_param_bound_count = ir.recallable_params().count(); - let mut bounds = ir.recallable_bounds(direct_bound); + let mut bounds = ir.recallable_bounds(direct_bound).collect::>(); bounds.extend( shared_memento_bounds .iter() diff --git a/recallable-macro/src/context/internal/enums/ir.rs b/recallable-macro/src/context/internal/enums/ir.rs index 85680c6..37f058a 100644 --- a/recallable-macro/src/context/internal/enums/ir.rs +++ b/recallable-macro/src/context/internal/enums/ir.rs @@ -262,16 +262,20 @@ impl<'a> EnumIr<'a> { Ok(()) } - pub(crate) fn recallable_bounds(&self, bound: &TokenStream2) -> Vec { + pub(crate) fn recallable_bounds( + &self, + bound: &TokenStream2, + ) -> impl Iterator { self.recallable_params() - .map(|ty| syn::parse_quote! { #ty: #bound }) - .collect() + .map(move |ty| syn::parse_quote! { #ty: #bound }) } - pub(crate) fn recallable_memento_bounds(&self, bound: &TokenStream2) -> Vec { + pub(crate) fn recallable_memento_bounds( + &self, + bound: &TokenStream2, + ) -> impl Iterator { self.recallable_params() - .map(|ty| syn::parse_quote! { #ty::Memento: #bound }) - .collect() + .map(move |ty| syn::parse_quote! { #ty::Memento: #bound }) } fn whole_type_bound_targets(&self) -> impl Iterator { @@ -291,11 +295,12 @@ impl<'a> EnumIr<'a> { }) } - #[must_use] - pub(crate) fn whole_type_bounds(&self, bound: &TokenStream2) -> Vec { + pub(crate) fn whole_type_bounds<'b>( + &'b self, + bound: &'b TokenStream2, + ) -> impl Iterator + 'b { self.whole_type_bound_targets() - .map(|ty| syn::parse_quote! { #ty: #bound }) - .collect() + .map(move |ty| syn::parse_quote! { #ty: #bound }) } pub(crate) fn whole_type_memento_bounds( diff --git a/recallable-macro/src/context/internal/structs/bounds.rs b/recallable-macro/src/context/internal/structs/bounds.rs index a945bc9..e2466d3 100644 --- a/recallable-macro/src/context/internal/structs/bounds.rs +++ b/recallable-macro/src/context/internal/structs/bounds.rs @@ -29,7 +29,9 @@ fn collect_shared_memento_bounds_with_spec( let recallable_trait = &env.recallable_trait; let memento_trait_bounds = memento_trait_spec.common_bound_tokens(); - let mut bounds = ir.recallable_memento_bounds(&memento_trait_bounds); + let mut bounds = ir + .recallable_memento_bounds(&memento_trait_bounds) + .collect::>(); bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &memento_trait_bounds)); if let Some(deserialize_owned) = memento_trait_spec.serde_nested_bound() { bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &deserialize_owned)); @@ -48,7 +50,7 @@ fn collect_recall_like_bounds_with_spec( collect_shared_memento_bounds_with_spec(ir, env, memento_trait_spec); let shared_param_bound_count = ir.recallable_params().count(); - let mut bounds = ir.recallable_bounds(direct_bound); + let mut bounds = ir.recallable_bounds(direct_bound).collect::>(); bounds.extend( shared_memento_bounds .iter() @@ -160,7 +162,6 @@ mod tests { let whole_type_bounds: Vec<_> = ir .whole_type_bounds(&env.recallable_trait) - .into_iter() .map(|predicate| predicate.to_token_stream().to_string()) .collect(); assert_eq!( diff --git a/recallable-macro/src/context/internal/structs/ir.rs b/recallable-macro/src/context/internal/structs/ir.rs index 3ce292c..093ab6e 100644 --- a/recallable-macro/src/context/internal/structs/ir.rs +++ b/recallable-macro/src/context/internal/structs/ir.rs @@ -208,16 +208,20 @@ impl<'a> StructIr<'a> { self.fields.iter().filter(|field| !field.strategy.is_skip()) } - pub(crate) fn recallable_bounds(&self, bound: &TokenStream2) -> Vec { + pub(crate) fn recallable_bounds( + &self, + bound: &TokenStream2, + ) -> impl Iterator { self.recallable_params() - .map(|ty| syn::parse_quote! { #ty: #bound }) - .collect() + .map(move |ty| syn::parse_quote! { #ty: #bound }) } - pub(crate) fn recallable_memento_bounds(&self, bound: &TokenStream2) -> Vec { + pub(crate) fn recallable_memento_bounds( + &self, + bound: &TokenStream2, + ) -> impl Iterator { self.recallable_params() - .map(|ty| syn::parse_quote! { #ty::Memento: #bound }) - .collect() + .map(move |ty| syn::parse_quote! { #ty::Memento: #bound }) } fn whole_type_bound_targets(&self) -> impl Iterator { @@ -236,11 +240,12 @@ impl<'a> StructIr<'a> { }) } - #[must_use] - pub(crate) fn whole_type_bounds(&self, bound: &TokenStream2) -> Vec { + pub(crate) fn whole_type_bounds<'b>( + &'b self, + bound: &'b TokenStream2, + ) -> impl Iterator + 'b { self.whole_type_bound_targets() - .map(|ty| syn::parse_quote! { #ty: #bound }) - .collect() + .map(move |ty| syn::parse_quote! { #ty: #bound }) } pub(crate) fn whole_type_memento_bounds( From ab33f18d08658399e6766df0a9e56e757d982d61 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 18:26:24 +0800 Subject: [PATCH 18/45] refactor(macro): deduplicate build_binding_ident helper Moved the duplicated `build_binding_ident` function from `from_impl/enums.rs` and `recallable_impl/enums.rs` into `internal/enums/ir.rs` and exported it via `internal/enums.rs` to keep the code DRY. Co-Authored-By: Claude Opus 4.6 (1M context) --- recallable-macro/src/context/from_impl/enums.rs | 13 +++---------- recallable-macro/src/context/internal/enums.rs | 2 +- recallable-macro/src/context/internal/enums/ir.rs | 10 +++++++++- .../src/context/recallable_impl/enums.rs | 13 +++---------- 4 files changed, 16 insertions(+), 22 deletions(-) diff --git a/recallable-macro/src/context/from_impl/enums.rs b/recallable-macro/src/context/from_impl/enums.rs index 179aaaf..e8f792c 100644 --- a/recallable-macro/src/context/from_impl/enums.rs +++ b/recallable-macro/src/context/from_impl/enums.rs @@ -1,11 +1,11 @@ use proc_macro2::TokenStream as TokenStream2; -use quote::{ToTokens, format_ident, quote}; +use quote::{ToTokens, quote}; use syn::WherePredicate; use crate::context::internal::enums::{ - EnumIr, VariantIr, VariantShape, collect_shared_memento_bounds_for_enum, + EnumIr, VariantIr, VariantShape, build_binding_ident, collect_shared_memento_bounds_for_enum, }; -use crate::context::internal::shared::{CodegenEnv, FieldIr, FieldMember, FieldStrategy}; +use crate::context::internal::shared::{CodegenEnv, FieldIr, FieldStrategy}; #[must_use] pub(crate) fn gen_enum_from_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { @@ -86,13 +86,6 @@ fn build_binding_pattern(field: &FieldIr<'_>, index: usize) -> TokenStream2 { } } -fn build_binding_ident(field: &FieldIr<'_>, index: usize) -> syn::Ident { - match &field.member { - FieldMember::Named(name) => (*name).clone(), - FieldMember::Unnamed(_) => format_ident!("__recallable_field_{index}"), - } -} - fn build_variant_from_expr(variant: &VariantIr<'_>) -> TokenStream2 { let kept_fields: Vec<_> = variant .fields diff --git a/recallable-macro/src/context/internal/enums.rs b/recallable-macro/src/context/internal/enums.rs index b96b884..72b8c2e 100644 --- a/recallable-macro/src/context/internal/enums.rs +++ b/recallable-macro/src/context/internal/enums.rs @@ -4,4 +4,4 @@ mod ir; pub(crate) use bounds::{ collect_recall_like_bounds_for_enum, collect_shared_memento_bounds_for_enum, }; -pub(crate) use ir::{EnumIr, VariantIr, VariantShape}; +pub(crate) use ir::{EnumIr, VariantIr, VariantShape, build_binding_ident}; diff --git a/recallable-macro/src/context/internal/enums/ir.rs b/recallable-macro/src/context/internal/enums/ir.rs index 37f058a..e87e48b 100644 --- a/recallable-macro/src/context/internal/enums/ir.rs +++ b/recallable-macro/src/context/internal/enums/ir.rs @@ -1,12 +1,13 @@ use std::collections::HashSet; use proc_macro2::TokenStream as TokenStream2; -use quote::quote; +use quote::{format_ident, quote}; use syn::{ DeriveInput, Generics, Ident, ImplGenerics, Type, Visibility, WhereClause, WherePredicate, }; use crate::context::SERDE_ENABLED; +use crate::context::internal::shared::FieldMember; use crate::context::internal::shared::bounds::MementoTraitSpec; use crate::context::internal::shared::fields::{FieldIr, FieldStrategy, collect_field_irs}; use crate::context::internal::shared::generics::{ @@ -46,6 +47,13 @@ pub(crate) struct EnumIr<'a> { skip_memento_default_derives: bool, } +pub(crate) fn build_binding_ident(field: &FieldIr<'_>, index: usize) -> syn::Ident { + match &field.member { + FieldMember::Named(name) => (*name).clone(), + FieldMember::Unnamed(_) => format_ident!("__recallable_field_{index}"), + } +} + const ENUM_RECALL_MANUAL_ONLY_ERROR: &str = "enum `Recall` derive requires assignment-only variant fields; derive `Recallable` and \ implement `Recall` or `TryRecall` manually"; const ENUM_MODEL_MANUAL_ONLY_ERROR: &str = "`#[recallable_model]` on enums requires assignment-only variants; complex enums should \ diff --git a/recallable-macro/src/context/recallable_impl/enums.rs b/recallable-macro/src/context/recallable_impl/enums.rs index 4f40f56..f76af6a 100644 --- a/recallable-macro/src/context/recallable_impl/enums.rs +++ b/recallable-macro/src/context/recallable_impl/enums.rs @@ -1,11 +1,11 @@ use proc_macro2::TokenStream as TokenStream2; -use quote::{ToTokens, format_ident, quote}; +use quote::{ToTokens, quote}; use syn::WherePredicate; use crate::context::internal::enums::{ - EnumIr, VariantIr, VariantShape, collect_recall_like_bounds_for_enum, + EnumIr, VariantIr, VariantShape, build_binding_ident, collect_recall_like_bounds_for_enum, }; -use crate::context::internal::shared::{CodegenEnv, FieldIr, FieldMember}; +use crate::context::internal::shared::CodegenEnv; #[must_use] pub(crate) fn gen_enum_recallable_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { @@ -117,13 +117,6 @@ fn build_variant_restore_expr(variant: &VariantIr<'_>, enum_name: &syn::Ident) - } } -fn build_binding_ident(field: &FieldIr<'_>, index: usize) -> syn::Ident { - match &field.member { - FieldMember::Named(name) => (*name).clone(), - FieldMember::Unnamed(_) => format_ident!("__recallable_field_{index}"), - } -} - #[cfg(test)] mod tests { use quote::quote; From 1fd36c4f04b37b2337e4c4a1b876a0340a768fa0 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 18:46:09 +0800 Subject: [PATCH 19/45] refactor(macro): optimize ModelItem::parse Replaced inefficient `TokenStream` roundtripping in `ModelItem::parse` with direct conversion to `syn::DeriveInput` using `Into`. Co-Authored-By: Claude Opus 4.6 (1M context) --- recallable-macro/src/model_macro.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/recallable-macro/src/model_macro.rs b/recallable-macro/src/model_macro.rs index 88f11f1..8ed43ad 100644 --- a/recallable-macro/src/model_macro.rs +++ b/recallable-macro/src/model_macro.rs @@ -165,7 +165,10 @@ impl ModelItem { } fn parse(&self) -> syn::Result { - syn::parse2(self.item_tokenstream()) + Ok(match self { + ModelItem::Struct(item) => item.clone().into(), + ModelItem::Enum(item) => item.clone().into(), + }) } } From fe9dc4e84b6dbe0184a8cd1de8ff68089d8a85f8 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 19:04:26 +0800 Subject: [PATCH 20/45] fix(macro): restore impl_from codegen --- recallable-macro/src/lib.rs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/recallable-macro/src/lib.rs b/recallable-macro/src/lib.rs index d9171ab..b4bdaee 100644 --- a/recallable-macro/src/lib.rs +++ b/recallable-macro/src/lib.rs @@ -105,22 +105,27 @@ pub fn derive_recallable(input: TokenStream) -> TokenStream { let memento_struct = context::gen_memento_type(&ir, &env); let recallable_impl = context::gen_recallable_impl(&ir, &env); - let mut output = quote! { + let from_impl = if context::IMPL_FROM_ENABLED { + let from_impl = context::gen_from_impl(&ir, &env); + quote! { + #[automatically_derived] + #from_impl + } + } else { + quote! {} + }; + + let output = quote! { const _: () = { #[automatically_derived] #memento_struct #[automatically_derived] #recallable_impl + + #from_impl }; }; - if context::IMPL_FROM_ENABLED { - let from_impl = context::gen_from_impl(&ir, &env); - output.extend(quote! { - #[automatically_derived] - #from_impl - }) - } output.into() } From e474afc1b67ea038e4f1b2f47a7b9c6904133005 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 19:14:07 +0800 Subject: [PATCH 21/45] refactor(macro): share item codegen helpers --- .../src/context/from_impl/enums.rs | 8 +- .../src/context/from_impl/structs.rs | 10 +- .../src/context/internal/enums/bounds.rs | 37 +-- .../src/context/internal/enums/ir.rs | 156 +++---------- .../src/context/internal/shared.rs | 7 +- .../src/context/internal/shared/bounds.rs | 55 +++++ .../src/context/internal/shared/codegen.rs | 220 ++++++++++++++++++ .../src/context/internal/shared/generics.rs | 1 + .../src/context/internal/structs/bounds.rs | 39 +--- .../src/context/internal/structs/ir.rs | 151 ++---------- recallable-macro/src/context/memento/enums.rs | 20 +- .../src/context/memento/structs.rs | 19 +- .../src/context/recall_impl/enums.rs | 2 +- .../src/context/recall_impl/structs.rs | 4 +- .../src/context/recallable_impl/enums.rs | 2 +- .../src/context/recallable_impl/structs.rs | 2 +- 16 files changed, 362 insertions(+), 371 deletions(-) create mode 100644 recallable-macro/src/context/internal/shared/codegen.rs diff --git a/recallable-macro/src/context/from_impl/enums.rs b/recallable-macro/src/context/from_impl/enums.rs index e8f792c..75c2c85 100644 --- a/recallable-macro/src/context/from_impl/enums.rs +++ b/recallable-macro/src/context/from_impl/enums.rs @@ -5,7 +5,7 @@ use syn::WherePredicate; use crate::context::internal::enums::{ EnumIr, VariantIr, VariantShape, build_binding_ident, collect_shared_memento_bounds_for_enum, }; -use crate::context::internal::shared::{CodegenEnv, FieldIr, FieldStrategy}; +use crate::context::internal::shared::{CodegenEnv, CodegenItemIr, FieldIr, build_from_value_expr}; #[must_use] pub(crate) fn gen_enum_from_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { @@ -120,11 +120,7 @@ fn build_variant_from_expr(variant: &VariantIr<'_>) -> TokenStream2 { } fn build_from_binding_expr(field: &FieldIr<'_>, binding: &syn::Ident) -> TokenStream2 { - match field.strategy { - FieldStrategy::StoreAsSelf => quote! { #binding }, - FieldStrategy::StoreAsMemento => quote! { ::core::convert::From::from(#binding) }, - FieldStrategy::Skip => unreachable!("filtered above"), - } + build_from_value_expr(quote! { #binding }, field.strategy) } fn build_enum_from_where_clause(ir: &EnumIr, env: &CodegenEnv) -> Option { diff --git a/recallable-macro/src/context/from_impl/structs.rs b/recallable-macro/src/context/from_impl/structs.rs index 6705bc3..278696a 100644 --- a/recallable-macro/src/context/from_impl/structs.rs +++ b/recallable-macro/src/context/from_impl/structs.rs @@ -2,7 +2,7 @@ use proc_macro2::TokenStream as TokenStream2; use quote::quote; use syn::WherePredicate; -use crate::context::internal::shared::{CodegenEnv, FieldIr, FieldStrategy}; +use crate::context::internal::shared::{CodegenEnv, CodegenItemIr, FieldIr, build_from_value_expr}; use crate::context::internal::structs::{StructIr, StructShape, collect_shared_memento_bounds}; #[must_use] @@ -84,13 +84,7 @@ fn build_named_marker_init() -> TokenStream2 { fn build_from_expr(field: &FieldIr) -> TokenStream2 { let member = &field.member; - match field.strategy { - FieldStrategy::StoreAsSelf => quote! { value.#member }, - FieldStrategy::StoreAsMemento => { - quote! { ::core::convert::From::from(value.#member) } - } - FieldStrategy::Skip => unreachable!("memento_fields() filters skipped fields"), - } + build_from_value_expr(quote! { value.#member }, field.strategy) } fn build_from_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { diff --git a/recallable-macro/src/context/internal/enums/bounds.rs b/recallable-macro/src/context/internal/enums/bounds.rs index 4f59b8f..b38b7a9 100644 --- a/recallable-macro/src/context/internal/enums/bounds.rs +++ b/recallable-macro/src/context/internal/enums/bounds.rs @@ -2,7 +2,10 @@ use proc_macro2::TokenStream as TokenStream2; use syn::WherePredicate; use crate::context::internal::enums::EnumIr; -use crate::context::internal::shared::{CodegenEnv, MementoTraitSpec}; +use crate::context::internal::shared::{ + CodegenEnv, MementoTraitSpec, collect_recall_like_bounds as collect_shared_recall_like_bounds, + collect_shared_memento_bounds as collect_common_memento_bounds, +}; #[must_use] pub(crate) fn collect_shared_memento_bounds_for_enum( @@ -26,18 +29,7 @@ fn collect_shared_memento_bounds_with_spec_for_enum( env: &CodegenEnv, memento_trait_spec: &MementoTraitSpec, ) -> Vec { - let recallable_trait = &env.recallable_trait; - let memento_trait_bounds = memento_trait_spec.common_bound_tokens(); - - let mut bounds = ir - .recallable_memento_bounds(&memento_trait_bounds) - .collect::>(); - bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &memento_trait_bounds)); - if let Some(deserialize_owned) = memento_trait_spec.serde_nested_bound() { - bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &deserialize_owned)); - } - - bounds + collect_common_memento_bounds(ir, env, memento_trait_spec) } fn collect_recall_like_bounds_with_spec_for_enum( @@ -46,22 +38,5 @@ fn collect_recall_like_bounds_with_spec_for_enum( direct_bound: &TokenStream2, memento_trait_spec: &MementoTraitSpec, ) -> Vec { - let shared_memento_bounds = - collect_shared_memento_bounds_with_spec_for_enum(ir, env, memento_trait_spec); - let shared_param_bound_count = ir.recallable_params().count(); - - let mut bounds = ir.recallable_bounds(direct_bound).collect::>(); - bounds.extend( - shared_memento_bounds - .iter() - .take(shared_param_bound_count) - .cloned(), - ); - bounds.extend(ir.whole_type_bounds(direct_bound)); - bounds.extend( - shared_memento_bounds - .into_iter() - .skip(shared_param_bound_count), - ); - bounds + collect_shared_recall_like_bounds(ir, env, direct_bound, memento_trait_spec) } diff --git a/recallable-macro/src/context/internal/enums/ir.rs b/recallable-macro/src/context/internal/enums/ir.rs index e87e48b..bd7ee0a 100644 --- a/recallable-macro/src/context/internal/enums/ir.rs +++ b/recallable-macro/src/context/internal/enums/ir.rs @@ -2,17 +2,16 @@ use std::collections::HashSet; use proc_macro2::TokenStream as TokenStream2; use quote::{format_ident, quote}; -use syn::{ - DeriveInput, Generics, Ident, ImplGenerics, Type, Visibility, WhereClause, WherePredicate, -}; +use syn::{DeriveInput, Generics, Ident, ImplGenerics, Visibility, WhereClause}; use crate::context::SERDE_ENABLED; use crate::context::internal::shared::FieldMember; use crate::context::internal::shared::bounds::MementoTraitSpec; +use crate::context::internal::shared::codegen::CodegenItemIr; use crate::context::internal::shared::fields::{FieldIr, FieldStrategy, collect_field_irs}; use crate::context::internal::shared::generics::{ GenericParamLookup, GenericParamPlan, collect_variant_marker_param_indices, - is_generic_type_param, marker_component, plan_memento_generics, + plan_memento_generics, }; use crate::context::internal::shared::item::has_skip_memento_default_derives; use crate::context::internal::shared::lifetime::{ @@ -174,65 +173,10 @@ impl<'a> EnumIr<'a> { MementoTraitSpec::new(SERDE_ENABLED, self.skip_memento_default_derives) } - #[must_use] - pub(crate) fn memento_decl_generics(&self) -> TokenStream2 { - let mut params = self - .generic_params - .iter() - .filter(|plan| plan.is_retained()) - .map(GenericParamPlan::decl_param) - .peekable(); - - if params.peek().is_none() { - quote! {} - } else { - quote! { <#(#params),*> } - } - } - pub(crate) const fn memento_where_clause(&self) -> Option<&WhereClause> { self.memento_where_clause.as_ref() } - #[must_use] - pub(crate) fn synthetic_marker_type(&self) -> Option { - if self.marker_param_indices.is_empty() { - return None; - } - - let components = self - .marker_param_indices - .iter() - .map(|&index| marker_component(self.generic_params[index].param)); - - Some(quote! { - ::core::marker::PhantomData<(#(#components,)*)> - }) - } - - pub(crate) fn recallable_params(&self) -> impl Iterator { - self.generic_params - .iter() - .filter_map(GenericParamPlan::recallable_ident) - } - - #[must_use] - pub(crate) fn memento_type(&self) -> TokenStream2 { - let name = &self.memento_name; - let mut args = self - .generic_params - .iter() - .filter(|plan| plan.is_retained()) - .map(GenericParamPlan::type_arg) - .peekable(); - - if args.peek().is_none() { - quote! { #name } - } else { - quote! { #name<#(#args),*> } - } - } - pub(crate) fn variants(&self) -> impl Iterator> { self.variants.iter() } @@ -269,84 +213,44 @@ impl<'a> EnumIr<'a> { Ok(()) } +} - pub(crate) fn recallable_bounds( - &self, - bound: &TokenStream2, - ) -> impl Iterator { - self.recallable_params() - .map(move |ty| syn::parse_quote! { #ty: #bound }) +impl<'a> CodegenItemIr<'a> for EnumIr<'a> { + type Fields<'b> + = std::iter::FlatMap< + std::slice::Iter<'b, VariantIr<'a>>, + std::slice::Iter<'b, FieldIr<'a>>, + fn(&'b VariantIr<'a>) -> std::slice::Iter<'b, FieldIr<'a>>, + > + where + Self: 'b, + 'a: 'b; + + fn generics(&self) -> &'a Generics { + self.generics } - pub(crate) fn recallable_memento_bounds( - &self, - bound: &TokenStream2, - ) -> impl Iterator { - self.recallable_params() - .map(move |ty| syn::parse_quote! { #ty::Memento: #bound }) + fn memento_name(&self) -> &Ident { + &self.memento_name } - fn whole_type_bound_targets(&self) -> impl Iterator { - let mut seen = HashSet::new(); - - self.variants - .iter() - .flat_map(|variant| variant.fields.iter()) - .filter_map(move |field| match field.strategy { - FieldStrategy::StoreAsMemento - if !is_generic_type_param(field.ty, &self.generic_type_param_idents) - && seen.insert(field.ty) => - { - Some(field.ty) - } - _ => None, - }) + fn generic_type_param_idents(&self) -> &HashSet<&'a Ident> { + &self.generic_type_param_idents } - pub(crate) fn whole_type_bounds<'b>( - &'b self, - bound: &'b TokenStream2, - ) -> impl Iterator + 'b { - self.whole_type_bound_targets() - .map(move |ty| syn::parse_quote! { #ty: #bound }) + fn generic_params(&self) -> &[GenericParamPlan<'a>] { + &self.generic_params } - pub(crate) fn whole_type_memento_bounds( - &self, - recallable_trait: &TokenStream2, - bound: &TokenStream2, - ) -> impl Iterator { - self.whole_type_bound_targets() - .map(move |ty| syn::parse_quote! { <#ty as #recallable_trait>::Memento: #bound }) + fn marker_param_indices(&self) -> &[usize] { + &self.marker_param_indices } - pub(crate) fn whole_type_from_bounds( - &self, - recallable_trait: &TokenStream2, - ) -> impl Iterator { - self.whole_type_bound_targets().flat_map(move |ty| { - [ - syn::parse_quote! { #ty: #recallable_trait }, - syn::parse_quote! { <#ty as #recallable_trait>::Memento: ::core::convert::From<#ty> }, - ] - }) + fn all_fields(&self) -> Self::Fields<'_> { + self.variants.iter().flat_map(variant_fields) } +} - pub(crate) fn extend_where_clause( - &self, - extra: impl IntoIterator, - ) -> Option { - let mut where_clause = self.generics.where_clause.clone(); - let mut extra_iter = extra.into_iter().peekable(); - if extra_iter.peek().is_none() { - where_clause - } else { - where_clause - .get_or_insert(syn::parse_quote! { where }) - .predicates - .extend(extra_iter); - - where_clause - } - } +fn variant_fields<'a, 'b>(variant: &'b VariantIr<'a>) -> std::slice::Iter<'b, FieldIr<'a>> { + variant.fields.iter() } diff --git a/recallable-macro/src/context/internal/shared.rs b/recallable-macro/src/context/internal/shared.rs index fd09fc4..f5a6832 100644 --- a/recallable-macro/src/context/internal/shared.rs +++ b/recallable-macro/src/context/internal/shared.rs @@ -1,4 +1,5 @@ pub(crate) mod bounds; +pub(crate) mod codegen; pub(crate) mod env; pub(crate) mod fields; pub(crate) mod generics; @@ -6,9 +7,11 @@ pub(crate) mod item; pub(crate) mod lifetime; pub(crate) mod util; -pub(crate) use bounds::MementoTraitSpec; +pub(crate) use bounds::{ + MementoTraitSpec, collect_recall_like_bounds, collect_shared_memento_bounds, +}; +pub(crate) use codegen::{CodegenItemIr, build_from_value_expr, build_memento_field_tokens}; pub(crate) use env::CodegenEnv; pub(crate) use fields::{FieldIr, FieldMember, FieldStrategy, has_recallable_skip_attr}; -pub(crate) use generics::is_generic_type_param; pub(crate) use item::ItemIr; pub(crate) use util::crate_path; diff --git a/recallable-macro/src/context/internal/shared/bounds.rs b/recallable-macro/src/context/internal/shared/bounds.rs index 4fb60b8..c5a80ce 100644 --- a/recallable-macro/src/context/internal/shared/bounds.rs +++ b/recallable-macro/src/context/internal/shared/bounds.rs @@ -1,5 +1,8 @@ use proc_macro2::TokenStream as TokenStream2; use quote::quote; +use syn::WherePredicate; + +use super::{CodegenEnv, CodegenItemIr}; #[derive(Debug)] pub(crate) struct MementoTraitSpec { @@ -60,6 +63,58 @@ impl MementoTraitSpec { } } +#[must_use] +pub(crate) fn collect_shared_memento_bounds<'a, T>( + ir: &T, + env: &CodegenEnv, + memento_trait_spec: &MementoTraitSpec, +) -> Vec +where + T: CodegenItemIr<'a>, +{ + let recallable_trait = &env.recallable_trait; + let memento_trait_bounds = memento_trait_spec.common_bound_tokens(); + + let mut bounds = ir + .recallable_memento_bounds(&memento_trait_bounds) + .collect::>(); + bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &memento_trait_bounds)); + if let Some(deserialize_owned) = memento_trait_spec.serde_nested_bound() { + bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &deserialize_owned)); + } + + bounds +} + +#[must_use] +pub(crate) fn collect_recall_like_bounds<'a, T>( + ir: &T, + env: &CodegenEnv, + direct_bound: &TokenStream2, + memento_trait_spec: &MementoTraitSpec, +) -> Vec +where + T: CodegenItemIr<'a>, +{ + let shared_memento_bounds = collect_shared_memento_bounds(ir, env, memento_trait_spec); + let shared_param_bound_count = ir.recallable_params().count(); + + let mut bounds = ir.recallable_bounds(direct_bound).collect::>(); + bounds.extend( + shared_memento_bounds + .iter() + .take(shared_param_bound_count) + .cloned(), + ); + bounds.extend(ir.whole_type_bounds(direct_bound)); + bounds.extend( + shared_memento_bounds + .into_iter() + .skip(shared_param_bound_count), + ); + bounds +} + #[cfg(test)] mod tests { use super::MementoTraitSpec; diff --git a/recallable-macro/src/context/internal/shared/codegen.rs b/recallable-macro/src/context/internal/shared/codegen.rs new file mode 100644 index 0000000..e7a0106 --- /dev/null +++ b/recallable-macro/src/context/internal/shared/codegen.rs @@ -0,0 +1,220 @@ +use std::collections::HashSet; + +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::{Generics, Ident, Type, WhereClause, WherePredicate}; + +use super::fields::{FieldIr, FieldMember, FieldStrategy}; +use super::generics::{GenericParamPlan, is_generic_type_param, marker_component}; + +pub(crate) trait CodegenItemIr<'a> { + type Fields<'b>: Iterator> + where + Self: 'b, + 'a: 'b; + + fn generics(&self) -> &'a Generics; + fn memento_name(&self) -> &Ident; + fn generic_type_param_idents(&self) -> &HashSet<&'a Ident>; + fn generic_params(&self) -> &[GenericParamPlan<'a>]; + fn marker_param_indices(&self) -> &[usize]; + fn all_fields(&self) -> Self::Fields<'_>; + + #[must_use] + fn memento_decl_generics(&self) -> TokenStream2 { + let mut params = self + .generic_params() + .iter() + .filter(|plan| plan.is_retained()) + .map(GenericParamPlan::decl_param) + .peekable(); + + if params.peek().is_none() { + quote! {} + } else { + quote! { <#(#params),*> } + } + } + + #[must_use] + fn memento_type(&self) -> TokenStream2 { + let name = self.memento_name(); + let mut args = self + .generic_params() + .iter() + .filter(|plan| plan.is_retained()) + .map(GenericParamPlan::type_arg) + .peekable(); + + if args.peek().is_none() { + quote! { #name } + } else { + quote! { #name<#(#args),*> } + } + } + + #[must_use] + fn synthetic_marker_type(&self) -> Option { + let marker_param_indices = self.marker_param_indices(); + if marker_param_indices.is_empty() { + return None; + } + + let generic_params = self.generic_params(); + let components = marker_param_indices + .iter() + .map(|&index| marker_component(generic_params[index].param)); + + Some(quote! { + ::core::marker::PhantomData<(#(#components,)*)> + }) + } + + fn recallable_params<'b>(&'b self) -> impl Iterator + 'b + where + 'a: 'b, + { + self.generic_params() + .iter() + .filter_map(GenericParamPlan::recallable_ident) + } + + fn recallable_bounds<'b>( + &'b self, + bound: &'b TokenStream2, + ) -> impl Iterator + 'b + where + 'a: 'b, + { + self.recallable_params() + .map(move |ty| syn::parse_quote! { #ty: #bound }) + } + + fn recallable_memento_bounds<'b>( + &'b self, + bound: &'b TokenStream2, + ) -> impl Iterator + 'b + where + 'a: 'b, + { + self.recallable_params() + .map(move |ty| syn::parse_quote! { #ty::Memento: #bound }) + } + + fn whole_type_bound_targets<'b>(&'b self) -> impl Iterator + 'b + where + 'a: 'b, + { + let generic_type_param_idents = self.generic_type_param_idents(); + let mut seen = HashSet::new(); + + self.all_fields() + .filter_map(move |field| match field.strategy { + FieldStrategy::StoreAsMemento + if !is_generic_type_param(field.ty, generic_type_param_idents) + && seen.insert(field.ty) => + { + Some(field.ty) + } + _ => None, + }) + } + + fn whole_type_bounds<'b>( + &'b self, + bound: &'b TokenStream2, + ) -> impl Iterator + 'b + where + 'a: 'b, + { + self.whole_type_bound_targets() + .map(move |ty| syn::parse_quote! { #ty: #bound }) + } + + fn whole_type_memento_bounds<'b>( + &'b self, + recallable_trait: &'b TokenStream2, + bound: &'b TokenStream2, + ) -> impl Iterator + 'b + where + 'a: 'b, + { + self.whole_type_bound_targets() + .map(move |ty| syn::parse_quote! { <#ty as #recallable_trait>::Memento: #bound }) + } + + fn whole_type_from_bounds<'b>( + &'b self, + recallable_trait: &'b TokenStream2, + ) -> impl Iterator + 'b + where + 'a: 'b, + { + self.whole_type_bound_targets().flat_map(move |ty| { + [ + syn::parse_quote! { #ty: #recallable_trait }, + syn::parse_quote! { <#ty as #recallable_trait>::Memento: ::core::convert::From<#ty> }, + ] + }) + } + + fn extend_where_clause( + &self, + extra: impl IntoIterator, + ) -> Option { + let mut where_clause = self.generics().where_clause.clone(); + let mut extra_iter = extra.into_iter().peekable(); + if extra_iter.peek().is_none() { + where_clause + } else { + where_clause + .get_or_insert(syn::parse_quote! { where }) + .predicates + .extend(extra_iter); + + where_clause + } + } +} + +#[must_use] +pub(crate) fn build_memento_field_ty( + field: &FieldIr<'_>, + recallable_trait: &TokenStream2, + generic_type_params: &HashSet<&Ident>, +) -> TokenStream2 { + let ty = field.ty; + match field.strategy { + FieldStrategy::StoreAsMemento => { + if is_generic_type_param(ty, generic_type_params) { + quote! { #ty::Memento } + } else { + quote! { <#ty as #recallable_trait>::Memento } + } + } + FieldStrategy::StoreAsSelf => quote! { #ty }, + FieldStrategy::Skip => unreachable!("memento_fields() filters skipped fields"), + } +} + +#[must_use] +pub(crate) fn build_memento_field_tokens( + field: &FieldIr<'_>, + recallable_trait: &TokenStream2, + generic_type_params: &HashSet<&Ident>, +) -> TokenStream2 { + let field_ty = build_memento_field_ty(field, recallable_trait, generic_type_params); + match &field.member { + FieldMember::Named(name) => quote! { #name: #field_ty }, + FieldMember::Unnamed(_) => quote! { #field_ty }, + } +} + +#[must_use] +pub(crate) fn build_from_value_expr(expr: TokenStream2, strategy: FieldStrategy) -> TokenStream2 { + match strategy { + FieldStrategy::StoreAsSelf => expr, + FieldStrategy::StoreAsMemento => quote! { ::core::convert::From::from(#expr) }, + FieldStrategy::Skip => unreachable!("memento_fields() filters skipped fields"), + } +} diff --git a/recallable-macro/src/context/internal/shared/generics.rs b/recallable-macro/src/context/internal/shared/generics.rs index dcc29de..4e6b1e8 100644 --- a/recallable-macro/src/context/internal/shared/generics.rs +++ b/recallable-macro/src/context/internal/shared/generics.rs @@ -384,6 +384,7 @@ mod tests { use quote::{ToTokens, quote}; use syn::parse_quote; + use crate::context::internal::shared::CodegenItemIr; use crate::context::internal::structs::StructIr; #[test] diff --git a/recallable-macro/src/context/internal/structs/bounds.rs b/recallable-macro/src/context/internal/structs/bounds.rs index e2466d3..e78bb8a 100644 --- a/recallable-macro/src/context/internal/structs/bounds.rs +++ b/recallable-macro/src/context/internal/structs/bounds.rs @@ -1,7 +1,10 @@ use proc_macro2::TokenStream as TokenStream2; use syn::WherePredicate; -use crate::context::internal::shared::{CodegenEnv, MementoTraitSpec}; +use crate::context::internal::shared::{ + CodegenEnv, MementoTraitSpec, collect_recall_like_bounds as collect_shared_recall_like_bounds, + collect_shared_memento_bounds as collect_common_memento_bounds, +}; use crate::context::internal::structs::StructIr; #[must_use] @@ -26,18 +29,7 @@ fn collect_shared_memento_bounds_with_spec( env: &CodegenEnv, memento_trait_spec: &MementoTraitSpec, ) -> Vec { - let recallable_trait = &env.recallable_trait; - let memento_trait_bounds = memento_trait_spec.common_bound_tokens(); - - let mut bounds = ir - .recallable_memento_bounds(&memento_trait_bounds) - .collect::>(); - bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &memento_trait_bounds)); - if let Some(deserialize_owned) = memento_trait_spec.serde_nested_bound() { - bounds.extend(ir.whole_type_memento_bounds(recallable_trait, &deserialize_owned)); - } - - bounds + collect_common_memento_bounds(ir, env, memento_trait_spec) } fn collect_recall_like_bounds_with_spec( @@ -46,24 +38,7 @@ fn collect_recall_like_bounds_with_spec( direct_bound: &TokenStream2, memento_trait_spec: &MementoTraitSpec, ) -> Vec { - let shared_memento_bounds = - collect_shared_memento_bounds_with_spec(ir, env, memento_trait_spec); - let shared_param_bound_count = ir.recallable_params().count(); - - let mut bounds = ir.recallable_bounds(direct_bound).collect::>(); - bounds.extend( - shared_memento_bounds - .iter() - .take(shared_param_bound_count) - .cloned(), - ); - bounds.extend(ir.whole_type_bounds(direct_bound)); - bounds.extend( - shared_memento_bounds - .into_iter() - .skip(shared_param_bound_count), - ); - bounds + collect_shared_recall_like_bounds(ir, env, direct_bound, memento_trait_spec) } #[cfg(test)] @@ -71,6 +46,8 @@ mod tests { use quote::{ToTokens, quote}; use syn::parse_quote; + use crate::context::internal::shared::CodegenItemIr; + use super::{ CodegenEnv, MementoTraitSpec, StructIr, collect_recall_like_bounds_with_spec, collect_shared_memento_bounds_with_spec, diff --git a/recallable-macro/src/context/internal/structs/ir.rs b/recallable-macro/src/context/internal/structs/ir.rs index 093ab6e..4643fa5 100644 --- a/recallable-macro/src/context/internal/structs/ir.rs +++ b/recallable-macro/src/context/internal/structs/ir.rs @@ -2,17 +2,14 @@ use std::collections::HashSet; use proc_macro2::TokenStream as TokenStream2; use quote::quote; -use syn::{ - DeriveInput, Fields, Generics, Ident, ImplGenerics, Type, Visibility, WhereClause, - WherePredicate, -}; +use syn::{DeriveInput, Fields, Generics, Ident, ImplGenerics, Visibility, WhereClause}; use crate::context::SERDE_ENABLED; use crate::context::internal::shared::bounds::MementoTraitSpec; -use crate::context::internal::shared::fields::{FieldIr, FieldStrategy, collect_field_irs}; +use crate::context::internal::shared::codegen::CodegenItemIr; +use crate::context::internal::shared::fields::{FieldIr, collect_field_irs}; use crate::context::internal::shared::generics::{ - GenericParamLookup, GenericParamPlan, collect_marker_param_indices, is_generic_type_param, - marker_component, plan_memento_generics, + GenericParamLookup, GenericParamPlan, collect_marker_param_indices, plan_memento_generics, }; use crate::context::internal::shared::item::has_skip_memento_default_derives; use crate::context::internal::shared::lifetime::{ @@ -131,22 +128,6 @@ impl<'a> StructIr<'a> { MementoTraitSpec::new(SERDE_ENABLED, self.skip_memento_default_derives) } - #[must_use] - pub(crate) fn memento_decl_generics(&self) -> TokenStream2 { - let mut params = self - .generic_params - .iter() - .filter(|plan| plan.is_retained()) - .map(GenericParamPlan::decl_param) - .peekable(); - - if params.peek().is_none() { - quote! {} - } else { - quote! { <#(#params),*> } - } - } - pub(crate) const fn memento_where_clause(&self) -> Option<&WhereClause> { self.memento_where_clause.as_ref() } @@ -165,125 +146,39 @@ impl<'a> StructIr<'a> { !self.marker_param_indices.is_empty() } - #[must_use] - pub(crate) fn synthetic_marker_type(&self) -> Option { - if self.marker_param_indices.is_empty() { - return None; - } - - let components = self - .marker_param_indices - .iter() - .map(|&index| marker_component(self.generic_params[index].param)); - - Some(quote! { - ::core::marker::PhantomData<(#(#components,)*)> - }) - } - - pub(crate) fn recallable_params(&self) -> impl Iterator { - self.generic_params - .iter() - .filter_map(GenericParamPlan::recallable_ident) - } - - #[must_use] - pub(crate) fn memento_type(&self) -> TokenStream2 { - let name = &self.memento_name; - let mut args = self - .generic_params - .iter() - .filter(|plan| plan.is_retained()) - .map(GenericParamPlan::type_arg) - .peekable(); - - if args.peek().is_none() { - quote! { #name } - } else { - quote! { #name<#(#args),*> } - } - } - pub(crate) fn memento_fields(&self) -> impl Iterator> { self.fields.iter().filter(|field| !field.strategy.is_skip()) } +} - pub(crate) fn recallable_bounds( - &self, - bound: &TokenStream2, - ) -> impl Iterator { - self.recallable_params() - .map(move |ty| syn::parse_quote! { #ty: #bound }) - } +impl<'a> CodegenItemIr<'a> for StructIr<'a> { + type Fields<'b> + = std::slice::Iter<'b, FieldIr<'a>> + where + Self: 'b, + 'a: 'b; - pub(crate) fn recallable_memento_bounds( - &self, - bound: &TokenStream2, - ) -> impl Iterator { - self.recallable_params() - .map(move |ty| syn::parse_quote! { #ty::Memento: #bound }) + fn generics(&self) -> &'a Generics { + self.generics } - fn whole_type_bound_targets(&self) -> impl Iterator { - let mut seen = HashSet::new(); - - self.fields - .iter() - .filter_map(move |field| match field.strategy { - FieldStrategy::StoreAsMemento - if !is_generic_type_param(field.ty, &self.generic_type_param_idents) - && seen.insert(field.ty) => - { - Some(field.ty) - } - _ => None, - }) + fn memento_name(&self) -> &Ident { + &self.memento_name } - pub(crate) fn whole_type_bounds<'b>( - &'b self, - bound: &'b TokenStream2, - ) -> impl Iterator + 'b { - self.whole_type_bound_targets() - .map(move |ty| syn::parse_quote! { #ty: #bound }) + fn generic_type_param_idents(&self) -> &HashSet<&'a Ident> { + &self.generic_type_param_idents } - pub(crate) fn whole_type_memento_bounds( - &self, - recallable_trait: &TokenStream2, - bound: &TokenStream2, - ) -> impl Iterator { - self.whole_type_bound_targets() - .map(move |ty| syn::parse_quote! { <#ty as #recallable_trait>::Memento: #bound }) + fn generic_params(&self) -> &[GenericParamPlan<'a>] { + &self.generic_params } - pub(crate) fn whole_type_from_bounds( - &self, - recallable_trait: &TokenStream2, - ) -> impl Iterator { - self.whole_type_bound_targets().flat_map(move |ty| { - [ - syn::parse_quote! { #ty: #recallable_trait }, - syn::parse_quote! { <#ty as #recallable_trait>::Memento: ::core::convert::From<#ty> }, - ] - }) + fn marker_param_indices(&self) -> &[usize] { + &self.marker_param_indices } - pub(crate) fn extend_where_clause( - &self, - extra: impl IntoIterator, - ) -> Option { - let mut where_clause = self.generics.where_clause.clone(); - let mut extra_iter = extra.into_iter().peekable(); - if extra_iter.peek().is_none() { - where_clause - } else { - where_clause - .get_or_insert(syn::parse_quote! { where }) - .predicates - .extend(extra_iter); - - where_clause - } + fn all_fields(&self) -> Self::Fields<'_> { + self.fields.iter() } } diff --git a/recallable-macro/src/context/memento/enums.rs b/recallable-macro/src/context/memento/enums.rs index 84920df..06db152 100644 --- a/recallable-macro/src/context/memento/enums.rs +++ b/recallable-macro/src/context/memento/enums.rs @@ -9,7 +9,7 @@ use crate::context::internal::enums::{ EnumIr, VariantIr, VariantShape, collect_recall_like_bounds_for_enum, }; use crate::context::internal::shared::{ - CodegenEnv, FieldIr, FieldMember, FieldStrategy, is_generic_type_param, + CodegenEnv, CodegenItemIr, FieldIr, build_memento_field_tokens, }; #[must_use] @@ -93,23 +93,7 @@ fn build_memento_field( recallable_trait: &TokenStream2, generic_type_params: &HashSet<&Ident>, ) -> TokenStream2 { - let ty = field.ty; - let field_ty = match field.strategy { - FieldStrategy::StoreAsMemento => { - if is_generic_type_param(ty, generic_type_params) { - quote! { #ty::Memento } - } else { - quote! { <#ty as #recallable_trait>::Memento } - } - } - FieldStrategy::StoreAsSelf => quote! { #ty }, - FieldStrategy::Skip => unreachable!("filtered above"), - }; - - match &field.member { - FieldMember::Named(name) => quote! { #name: #field_ty }, - FieldMember::Unnamed(_) => quote! { #field_ty }, - } + build_memento_field_tokens(field, recallable_trait, generic_type_params) } fn build_marker_variant(marker_ty: &TokenStream2) -> TokenStream2 { diff --git a/recallable-macro/src/context/memento/structs.rs b/recallable-macro/src/context/memento/structs.rs index 1fddeb4..b2c9228 100644 --- a/recallable-macro/src/context/memento/structs.rs +++ b/recallable-macro/src/context/memento/structs.rs @@ -5,7 +5,7 @@ use syn::{Ident, WhereClause, WherePredicate}; use crate::context::SERDE_ENABLED; use crate::context::internal::shared::{ - CodegenEnv, FieldIr, FieldMember, FieldStrategy, is_generic_type_param, + CodegenEnv, CodegenItemIr, FieldIr, build_memento_field_tokens, }; use crate::context::internal::structs::{StructIr, StructShape, collect_recall_like_bounds}; @@ -92,22 +92,7 @@ fn build_memento_field( recallable_trait: &TokenStream2, generic_type_params: &HashSet<&Ident>, ) -> TokenStream2 { - let ty = field.ty; - let field_ty = match field.strategy { - FieldStrategy::StoreAsMemento => { - if is_generic_type_param(ty, generic_type_params) { - quote! { #ty::Memento } - } else { - quote! { <#ty as #recallable_trait>::Memento } - } - } - FieldStrategy::StoreAsSelf => quote! { #ty }, - FieldStrategy::Skip => unreachable!("memento_fields() filters skipped fields"), - }; - match &field.member { - FieldMember::Named(name) => quote! { #name: #field_ty }, - FieldMember::Unnamed(_) => quote! { #field_ty }, - } + build_memento_field_tokens(field, recallable_trait, generic_type_params) } #[cfg(test)] diff --git a/recallable-macro/src/context/recall_impl/enums.rs b/recallable-macro/src/context/recall_impl/enums.rs index 5109b99..f23e74e 100644 --- a/recallable-macro/src/context/recall_impl/enums.rs +++ b/recallable-macro/src/context/recall_impl/enums.rs @@ -2,7 +2,7 @@ use proc_macro2::TokenStream as TokenStream2; use quote::quote; use crate::context::internal::enums::{EnumIr, collect_recall_like_bounds_for_enum}; -use crate::context::internal::shared::CodegenEnv; +use crate::context::internal::shared::{CodegenEnv, CodegenItemIr}; #[must_use] pub(crate) fn gen_enum_recall_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { diff --git a/recallable-macro/src/context/recall_impl/structs.rs b/recallable-macro/src/context/recall_impl/structs.rs index 99ae028..1aedca2 100644 --- a/recallable-macro/src/context/recall_impl/structs.rs +++ b/recallable-macro/src/context/recall_impl/structs.rs @@ -2,7 +2,9 @@ use proc_macro2::TokenStream as TokenStream2; use quote::quote; use syn::WherePredicate; -use crate::context::internal::shared::{CodegenEnv, FieldIr, FieldMember, FieldStrategy}; +use crate::context::internal::shared::{ + CodegenEnv, CodegenItemIr, FieldIr, FieldMember, FieldStrategy, +}; use crate::context::internal::structs::{StructIr, collect_recall_like_bounds}; #[must_use] diff --git a/recallable-macro/src/context/recallable_impl/enums.rs b/recallable-macro/src/context/recallable_impl/enums.rs index f76af6a..4ded21a 100644 --- a/recallable-macro/src/context/recallable_impl/enums.rs +++ b/recallable-macro/src/context/recallable_impl/enums.rs @@ -5,7 +5,7 @@ use syn::WherePredicate; use crate::context::internal::enums::{ EnumIr, VariantIr, VariantShape, build_binding_ident, collect_recall_like_bounds_for_enum, }; -use crate::context::internal::shared::CodegenEnv; +use crate::context::internal::shared::{CodegenEnv, CodegenItemIr}; #[must_use] pub(crate) fn gen_enum_recallable_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { diff --git a/recallable-macro/src/context/recallable_impl/structs.rs b/recallable-macro/src/context/recallable_impl/structs.rs index 902f1b9..6768465 100644 --- a/recallable-macro/src/context/recallable_impl/structs.rs +++ b/recallable-macro/src/context/recallable_impl/structs.rs @@ -2,7 +2,7 @@ use proc_macro2::TokenStream as TokenStream2; use quote::quote; use syn::WherePredicate; -use crate::context::internal::shared::CodegenEnv; +use crate::context::internal::shared::{CodegenEnv, CodegenItemIr}; use crate::context::internal::structs::{StructIr, collect_recall_like_bounds}; #[must_use] From b63ff87d6e6eb1d0a86e9bd243851aba486fe944 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 19:18:30 +0800 Subject: [PATCH 22/45] refactor(macro): reduce allocations in codegen --- .../src/context/from_impl/enums.rs | 48 +++++++------------ .../src/context/from_impl/structs.rs | 28 +++++------ .../src/context/internal/enums/ir.rs | 16 +++++++ recallable-macro/src/context/memento/enums.rs | 20 ++++---- .../src/context/recallable_impl/enums.rs | 22 ++------- recallable-macro/src/model_macro.rs | 41 +++++++++------- 6 files changed, 80 insertions(+), 95 deletions(-) diff --git a/recallable-macro/src/context/from_impl/enums.rs b/recallable-macro/src/context/from_impl/enums.rs index 75c2c85..e0ab249 100644 --- a/recallable-macro/src/context/from_impl/enums.rs +++ b/recallable-macro/src/context/from_impl/enums.rs @@ -56,7 +56,7 @@ fn build_enum_from_body(ir: &EnumIr) -> TokenStream2 { fn build_variant_source_pattern(variant: &VariantIr<'_>) -> TokenStream2 { match variant.shape { VariantShape::Named => { - let patterns = variant.fields.iter().enumerate().map(|(index, field)| { + let patterns = variant.indexed_fields().map(|(index, field)| { if field.strategy.is_skip() { let member = &field.member; quote! { #member: _ } @@ -68,9 +68,7 @@ fn build_variant_source_pattern(variant: &VariantIr<'_>) -> TokenStream2 { } VariantShape::Unnamed => { let patterns = variant - .fields - .iter() - .enumerate() + .indexed_fields() .map(|(index, field)| build_binding_pattern(field, index)); quote! { ( #(#patterns),* ) } } @@ -87,20 +85,14 @@ fn build_binding_pattern(field: &FieldIr<'_>, index: usize) -> TokenStream2 { } fn build_variant_from_expr(variant: &VariantIr<'_>) -> TokenStream2 { - let kept_fields: Vec<_> = variant - .fields - .iter() - .enumerate() - .filter(|(_, field)| !field.strategy.is_skip()) - .collect(); - - if kept_fields.is_empty() { + let mut kept_fields = variant.kept_fields().peekable(); + if kept_fields.peek().is_none() { return quote! {}; } match variant.shape { VariantShape::Named => { - let inits = kept_fields.into_iter().map(|(index, field)| { + let inits = kept_fields.map(|(index, field)| { let member = &field.member; let binding = build_binding_ident(field, index); let value = build_from_binding_expr(field, &binding); @@ -109,7 +101,7 @@ fn build_variant_from_expr(variant: &VariantIr<'_>) -> TokenStream2 { quote! { { #(#inits),* } } } VariantShape::Unnamed => { - let values = kept_fields.into_iter().map(|(index, field)| { + let values = kept_fields.map(|(index, field)| { let binding = build_binding_ident(field, index); build_from_binding_expr(field, &binding) }); @@ -124,22 +116,16 @@ fn build_from_binding_expr(field: &FieldIr<'_>, binding: &syn::Ident) -> TokenSt } fn build_enum_from_where_clause(ir: &EnumIr, env: &CodegenEnv) -> Option { - let bounds = collect_enum_from_bounds(ir, env); - ir.extend_where_clause(bounds) -} - -fn collect_enum_from_bounds(ir: &EnumIr, env: &CodegenEnv) -> Vec { let recallable_trait = &env.recallable_trait; - let mut bounds: Vec<_> = ir - .recallable_params() - .flat_map(|ty| -> [WherePredicate; 2] { - [ - syn::parse_quote! { #ty: #recallable_trait }, - syn::parse_quote! { #ty::Memento: ::core::convert::From<#ty> }, - ] - }) - .collect(); - bounds.extend(collect_shared_memento_bounds_for_enum(ir, env)); - bounds.extend(ir.whole_type_from_bounds(recallable_trait)); - bounds + ir.extend_where_clause( + ir.recallable_params() + .flat_map(|ty| -> [WherePredicate; 2] { + [ + syn::parse_quote! { #ty: #recallable_trait }, + syn::parse_quote! { #ty::Memento: ::core::convert::From<#ty> }, + ] + }) + .chain(collect_shared_memento_bounds_for_enum(ir, env).into_iter()) + .chain(ir.whole_type_from_bounds(recallable_trait)), + ) } diff --git a/recallable-macro/src/context/from_impl/structs.rs b/recallable-macro/src/context/from_impl/structs.rs index 278696a..7a6ce3c 100644 --- a/recallable-macro/src/context/from_impl/structs.rs +++ b/recallable-macro/src/context/from_impl/structs.rs @@ -88,22 +88,16 @@ fn build_from_expr(field: &FieldIr) -> TokenStream2 { } fn build_from_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option { - let bounds = collect_from_bounds(ir, env); - ir.extend_where_clause(bounds) -} - -fn collect_from_bounds(ir: &StructIr, env: &CodegenEnv) -> Vec { let recallable_trait = &env.recallable_trait; - let mut bounds: Vec<_> = ir - .recallable_params() - .flat_map(|ty| -> [WherePredicate; 2] { - [ - syn::parse_quote! { #ty: #recallable_trait }, - syn::parse_quote! { #ty::Memento: ::core::convert::From<#ty> }, - ] - }) - .collect(); - bounds.extend(collect_shared_memento_bounds(ir, env)); - bounds.extend(ir.whole_type_from_bounds(recallable_trait)); - bounds + ir.extend_where_clause( + ir.recallable_params() + .flat_map(|ty| -> [WherePredicate; 2] { + [ + syn::parse_quote! { #ty: #recallable_trait }, + syn::parse_quote! { #ty::Memento: ::core::convert::From<#ty> }, + ] + }) + .chain(collect_shared_memento_bounds(ir, env).into_iter()) + .chain(ir.whole_type_from_bounds(recallable_trait)), + ) } diff --git a/recallable-macro/src/context/internal/enums/ir.rs b/recallable-macro/src/context/internal/enums/ir.rs index bd7ee0a..10e6457 100644 --- a/recallable-macro/src/context/internal/enums/ir.rs +++ b/recallable-macro/src/context/internal/enums/ir.rs @@ -32,6 +32,22 @@ pub(crate) struct VariantIr<'a> { pub(crate) fields: Vec>, } +impl<'a> VariantIr<'a> { + pub(crate) fn indexed_fields(&self) -> impl Iterator)> { + self.fields.iter().enumerate() + } + + pub(crate) fn kept_fields(&self) -> impl Iterator)> { + self.indexed_fields() + .filter(|(_, field)| !field.strategy.is_skip()) + } + + pub(crate) fn bindings(&self) -> impl Iterator + '_ { + self.indexed_fields() + .map(|(index, field)| build_binding_ident(field, index)) + } +} + #[derive(Debug)] pub(crate) struct EnumIr<'a> { name: &'a Ident, diff --git a/recallable-macro/src/context/memento/enums.rs b/recallable-macro/src/context/memento/enums.rs index 06db152..544232a 100644 --- a/recallable-macro/src/context/memento/enums.rs +++ b/recallable-macro/src/context/memento/enums.rs @@ -68,20 +68,16 @@ fn build_memento_variant( generic_type_params: &HashSet<&Ident>, ) -> TokenStream2 { let name = variant.name; - let fields: Vec<_> = variant - .fields - .iter() - .filter(|field| !field.strategy.is_skip()) - .map(|field| build_memento_field(field, recallable_trait, generic_type_params)) - .collect(); + let mut fields = variant + .kept_fields() + .map(|(_, field)| build_memento_field(field, recallable_trait, generic_type_params)) + .peekable(); - let shape = if fields.is_empty() { - VariantShape::Unit - } else { - variant.shape - }; + if fields.peek().is_none() { + return quote! { #name }; + } - match shape { + match variant.shape { VariantShape::Named => quote! { #name { #(#fields),* } }, VariantShape::Unnamed => quote! { #name(#(#fields),*) }, VariantShape::Unit => quote! { #name }, diff --git a/recallable-macro/src/context/recallable_impl/enums.rs b/recallable-macro/src/context/recallable_impl/enums.rs index 4ded21a..31f888c 100644 --- a/recallable-macro/src/context/recallable_impl/enums.rs +++ b/recallable-macro/src/context/recallable_impl/enums.rs @@ -1,5 +1,5 @@ use proc_macro2::TokenStream as TokenStream2; -use quote::{ToTokens, quote}; +use quote::quote; use syn::WherePredicate; use crate::context::internal::enums::{ @@ -74,19 +74,11 @@ fn gen_enum_restore_helper(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { fn build_variant_memento_pattern(variant: &VariantIr<'_>) -> TokenStream2 { match variant.shape { VariantShape::Named => { - let bindings = variant - .fields - .iter() - .enumerate() - .map(|(index, field)| build_binding_ident(field, index).to_token_stream()); + let bindings = variant.bindings(); quote! { { #(#bindings),* } } } VariantShape::Unnamed => { - let bindings = variant - .fields - .iter() - .enumerate() - .map(|(index, field)| build_binding_ident(field, index).to_token_stream()); + let bindings = variant.bindings(); quote! { ( #(#bindings),* ) } } VariantShape::Unit => quote! {}, @@ -98,7 +90,7 @@ fn build_variant_restore_expr(variant: &VariantIr<'_>, enum_name: &syn::Ident) - match variant.shape { VariantShape::Named => { - let inits = variant.fields.iter().enumerate().map(|(index, field)| { + let inits = variant.indexed_fields().map(|(index, field)| { let member = &field.member; let binding = build_binding_ident(field, index); quote! { #member: #binding } @@ -106,11 +98,7 @@ fn build_variant_restore_expr(variant: &VariantIr<'_>, enum_name: &syn::Ident) - quote! { #enum_name::#variant_name { #(#inits),* } } } VariantShape::Unnamed => { - let values = variant - .fields - .iter() - .enumerate() - .map(|(index, field)| build_binding_ident(field, index).to_token_stream()); + let values = variant.bindings(); quote! { #enum_name::#variant_name(#(#values),*) } } VariantShape::Unit => quote! { #enum_name::#variant_name }, diff --git a/recallable-macro/src/model_macro.rs b/recallable-macro/src/model_macro.rs index 8ed43ad..bc7012c 100644 --- a/recallable-macro/src/model_macro.rs +++ b/recallable-macro/src/model_macro.rs @@ -79,11 +79,10 @@ fn parse_model_item(item: TokenStream) -> syn::Result { } fn add_serde_skip_attrs_to_fields(fields: &mut Fields) { - for field in fields.iter_mut() { - if has_recallable_skip_attr(field) { - field.attrs.push(parse_quote! { #[serde(skip)] }); - } - } + fields + .iter_mut() + .filter(|field| has_recallable_skip_attr(field)) + .for_each(|field| field.attrs.push(parse_quote! { #[serde(skip)] })); } /// Returns an error if any existing `#[derive(...)]` attribute on the struct @@ -136,27 +135,33 @@ impl ModelItem { } } - fn add_derives(&mut self) { - let crate_path = crate_path(); - let derives = build_model_derive_attr(&crate_path); - let attrs = match self { + fn attrs_mut(&mut self) -> &mut Vec { + match self { Self::Struct(item) => &mut item.attrs, Self::Enum(item) => &mut item.attrs, - }; - attrs.push(derives); + } } - fn add_serde_skip_attrs(&mut self) { + fn with_fields_mut(&mut self, mut apply: impl FnMut(&mut Fields)) { match self { - Self::Struct(item) => add_serde_skip_attrs_to_fields(&mut item.fields), - Self::Enum(item) => { - for variant in &mut item.variants { - add_serde_skip_attrs_to_fields(&mut variant.fields); - } - } + Self::Struct(item) => apply(&mut item.fields), + Self::Enum(item) => item + .variants + .iter_mut() + .for_each(|variant| apply(&mut variant.fields)), } } + fn add_derives(&mut self) { + let crate_path = crate_path(); + let derives = build_model_derive_attr(&crate_path); + self.attrs_mut().push(derives); + } + + fn add_serde_skip_attrs(&mut self) { + self.with_fields_mut(add_serde_skip_attrs_to_fields); + } + fn item_tokenstream(&self) -> TokenStream2 { match self { ModelItem::Struct(item) => item.to_token_stream(), From c0073adc454fa1e8521699adf60df479fa1451e5 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 19:42:25 +0800 Subject: [PATCH 23/45] refactor(macro): move #[automatically_derived] to individual codegen items Moved `#[automatically_derived]` out of the top-level output quote block in `lib.rs` and placed it directly on the implementations and types in their respective generation functions. Also simplified the optional `from_impl` codegen using `then_some` and removed redundant `.into_iter()` calls. Co-Authored-By: Claude Opus 4.6 (1M context) --- recallable-macro/src/context/from_impl/enums.rs | 3 ++- recallable-macro/src/context/from_impl/structs.rs | 3 ++- recallable-macro/src/context/memento/enums.rs | 1 + recallable-macro/src/context/memento/structs.rs | 1 + .../src/context/recallable_impl/enums.rs | 1 + .../src/context/recallable_impl/structs.rs | 1 + recallable-macro/src/lib.rs | 13 +------------ 7 files changed, 9 insertions(+), 14 deletions(-) diff --git a/recallable-macro/src/context/from_impl/enums.rs b/recallable-macro/src/context/from_impl/enums.rs index e0ab249..41476c8 100644 --- a/recallable-macro/src/context/from_impl/enums.rs +++ b/recallable-macro/src/context/from_impl/enums.rs @@ -16,6 +16,7 @@ pub(crate) fn gen_enum_from_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 let from_method = build_enum_from_method(ir); quote! { + #[automatically_derived] impl #impl_generics ::core::convert::From<#enum_type> for #memento_type #where_clause { @@ -125,7 +126,7 @@ fn build_enum_from_where_clause(ir: &EnumIr, env: &CodegenEnv) -> Option }, ] }) - .chain(collect_shared_memento_bounds_for_enum(ir, env).into_iter()) + .chain(collect_shared_memento_bounds_for_enum(ir, env)) .chain(ir.whole_type_from_bounds(recallable_trait)), ) } diff --git a/recallable-macro/src/context/from_impl/structs.rs b/recallable-macro/src/context/from_impl/structs.rs index 7a6ce3c..991c854 100644 --- a/recallable-macro/src/context/from_impl/structs.rs +++ b/recallable-macro/src/context/from_impl/structs.rs @@ -14,6 +14,7 @@ pub(crate) fn gen_struct_from_impl(ir: &StructIr, env: &CodegenEnv) -> TokenStre let from_method = build_from_method(ir); quote! { + #[automatically_derived] impl #impl_generics ::core::convert::From<#struct_type> for #memento_type #where_clause { @@ -97,7 +98,7 @@ fn build_from_where_clause(ir: &StructIr, env: &CodegenEnv) -> Option }, ] }) - .chain(collect_shared_memento_bounds(ir, env).into_iter()) + .chain(collect_shared_memento_bounds(ir, env)) .chain(ir.whole_type_from_bounds(recallable_trait)), ) } diff --git a/recallable-macro/src/context/memento/enums.rs b/recallable-macro/src/context/memento/enums.rs index 544232a..7d69960 100644 --- a/recallable-macro/src/context/memento/enums.rs +++ b/recallable-macro/src/context/memento/enums.rs @@ -35,6 +35,7 @@ pub(crate) fn gen_memento_enum(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { ); quote! { + #[automatically_derived] #[allow(dead_code)] #derives #visibility enum #memento_name #memento_generics #where_clause { diff --git a/recallable-macro/src/context/memento/structs.rs b/recallable-macro/src/context/memento/structs.rs index b2c9228..8c0010c 100644 --- a/recallable-macro/src/context/memento/structs.rs +++ b/recallable-macro/src/context/memento/structs.rs @@ -18,6 +18,7 @@ pub(crate) fn gen_memento_struct(ir: &StructIr, env: &CodegenEnv) -> TokenStream let body = build_memento_body(ir, env); quote! { + #[automatically_derived] #[allow(dead_code)] #derives #visibility struct #memento_name #memento_generics #body diff --git a/recallable-macro/src/context/recallable_impl/enums.rs b/recallable-macro/src/context/recallable_impl/enums.rs index 31f888c..2a5a345 100644 --- a/recallable-macro/src/context/recallable_impl/enums.rs +++ b/recallable-macro/src/context/recallable_impl/enums.rs @@ -17,6 +17,7 @@ pub(crate) fn gen_enum_recallable_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenSt let restore_helper = gen_enum_restore_helper(ir, env); quote! { + #[automatically_derived] impl #impl_generics #recallable_trait for #enum_type #where_clause { diff --git a/recallable-macro/src/context/recallable_impl/structs.rs b/recallable-macro/src/context/recallable_impl/structs.rs index 6768465..fa7ab05 100644 --- a/recallable-macro/src/context/recallable_impl/structs.rs +++ b/recallable-macro/src/context/recallable_impl/structs.rs @@ -14,6 +14,7 @@ pub(crate) fn gen_struct_recallable_impl(ir: &StructIr, env: &CodegenEnv) -> Tok let memento_type = ir.memento_type(); quote! { + #[automatically_derived] impl #impl_generics #recallable_trait for #struct_type #where_clause { diff --git a/recallable-macro/src/lib.rs b/recallable-macro/src/lib.rs index b4bdaee..20c23e1 100644 --- a/recallable-macro/src/lib.rs +++ b/recallable-macro/src/lib.rs @@ -104,23 +104,12 @@ pub fn derive_recallable(input: TokenStream) -> TokenStream { let memento_struct = context::gen_memento_type(&ir, &env); let recallable_impl = context::gen_recallable_impl(&ir, &env); - - let from_impl = if context::IMPL_FROM_ENABLED { - let from_impl = context::gen_from_impl(&ir, &env); - quote! { - #[automatically_derived] - #from_impl - } - } else { - quote! {} - }; + let from_impl = context::IMPL_FROM_ENABLED.then_some(context::gen_from_impl(&ir, &env)); let output = quote! { const _: () = { - #[automatically_derived] #memento_struct - #[automatically_derived] #recallable_impl #from_impl From 48fb06f9e729f1f5bb451d2b7701f9810fd26ac8 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 20:00:39 +0800 Subject: [PATCH 24/45] chore: enhance Makefile - Validate code examples - Run tests and validate code examples as regression --- Makefile | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 1c0fdd1..bcd73d6 100644 --- a/Makefile +++ b/Makefile @@ -2,39 +2,75 @@ CARGO ?= cargo TEST_ARGS ?= --workspace +EXAMPLE_ARGS ?= --package recallable CARGO_LLVM_COV ?= cargo llvm-cov COVERAGE_ARGS ?= --workspace COVERAGE_DIR ?= target/llvm-cov COVERAGE_HTML_INDEX := $(COVERAGE_DIR)/html/index.html BROWSER_OPEN ?= open -.PHONY: help test test-default test-no-default test-impl-from test-all-features coverage coverage-open +.PHONY: help regression test validate-examples \ + test-default test-no-default test-impl-from test-all-features \ + examples-default examples-no-default examples-impl-from examples-all-features \ + coverage coverage-open help: @printf '%s\n' \ 'Available targets:' \ - ' make test Run the full workspace test feature matrix' \ + ' make regression Run the full workspace test and example matrix' \ + ' make test Run the original workspace test feature matrix' \ + ' make validate-examples Run the example validation matrix' \ ' make test-default Run tests with default features' \ + ' make examples-default Run default-feature examples' \ ' make test-no-default Run tests with no default features' \ + ' make examples-no-default Run examples with no default features' \ ' make test-impl-from Run tests with impl_from enabled' \ + ' make examples-impl-from Run impl_from examples with no default features' \ ' make test-all-features Run tests with all features enabled' \ + ' make examples-all-features Check examples with all features enabled' \ ' make coverage Generate merged llvm-cov HTML and JSON reports' \ ' make coverage-open Generate coverage reports and open the HTML report' -test: test-default test-no-default test-impl-from test-all-features +regression: test validate-examples + +test: \ + test-default \ + test-no-default \ + test-impl-from \ + test-all-features + +validate-examples: \ + examples-default \ + examples-no-default \ + examples-impl-from \ + examples-all-features test-default: $(CARGO) test $(TEST_ARGS) +examples-default: + $(CARGO) run $(EXAMPLE_ARGS) --example basic_model + $(CARGO) run $(EXAMPLE_ARGS) --example nested_generic + $(CARGO) run $(EXAMPLE_ARGS) --example postcard_roundtrip + test-no-default: $(CARGO) test $(TEST_ARGS) --no-default-features +examples-no-default: + $(CARGO) run $(EXAMPLE_ARGS) --no-default-features --example manual_no_serde + test-impl-from: $(CARGO) test $(TEST_ARGS) --features impl_from +examples-impl-from: + $(CARGO) run $(EXAMPLE_ARGS) --no-default-features --features impl_from --example impl_from_roundtrip + test-all-features: $(CARGO) test $(TEST_ARGS) --all-features +examples-all-features: + $(CARGO) check $(EXAMPLE_ARGS) --examples --all-features + coverage: $(CARGO_LLVM_COV) clean $(COVERAGE_ARGS) $(CARGO_LLVM_COV) $(COVERAGE_ARGS) --no-default-features --tests --no-report From da445afceb1053d19152829027545541d3742cc3 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 20:25:56 +0800 Subject: [PATCH 25/45] fix(macro): auto-skip phantom data fields --- .../src/context/internal/enums/ir.rs | 18 ++++----- .../src/context/internal/shared/fields.rs | 31 ++++++++++++--- .../src/context/internal/structs/ir.rs | 2 +- .../src/context/recallable_impl/enums.rs | 39 +++++++++++++------ recallable/tests/basic.rs | 27 +++++++++++++ recallable/tests/enum_recall.rs | 1 - .../tests/ui/derive_pass_enum_no_warnings.rs | 1 - 7 files changed, 89 insertions(+), 30 deletions(-) diff --git a/recallable-macro/src/context/internal/enums/ir.rs b/recallable-macro/src/context/internal/enums/ir.rs index 10e6457..e785c6a 100644 --- a/recallable-macro/src/context/internal/enums/ir.rs +++ b/recallable-macro/src/context/internal/enums/ir.rs @@ -15,7 +15,7 @@ use crate::context::internal::shared::generics::{ }; use crate::context::internal::shared::item::has_skip_memento_default_derives; use crate::context::internal::shared::lifetime::{ - collect_struct_lifetimes, validate_no_borrowed_fields, + collect_struct_lifetimes, is_phantom_data, validate_no_borrowed_fields, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -42,8 +42,8 @@ impl<'a> VariantIr<'a> { .filter(|(_, field)| !field.strategy.is_skip()) } - pub(crate) fn bindings(&self) -> impl Iterator + '_ { - self.indexed_fields() + pub(crate) fn kept_bindings(&self) -> impl Iterator + '_ { + self.kept_fields() .map(|(index, field)| build_binding_ident(field, index)) } } @@ -89,7 +89,6 @@ fn extract_enum_variants( fn collect_variant_irs<'a>( variants: &'a syn::punctuated::Punctuated, - struct_lifetimes: &HashSet<&'a syn::Ident>, generic_lookup: &GenericParamLookup<'a>, ) -> syn::Result<( crate::context::internal::shared::generics::GenericUsage, @@ -99,8 +98,7 @@ fn collect_variant_irs<'a>( let mut variant_irs = Vec::with_capacity(variants.len()); for variant in variants { - let (variant_usage, fields) = - collect_field_irs(&variant.fields, struct_lifetimes, generic_lookup)?; + let (variant_usage, fields) = collect_field_irs(&variant.fields, generic_lookup)?; usage.retained.extend(variant_usage.retained); usage .recallable_type_params @@ -136,8 +134,7 @@ impl<'a> EnumIr<'a> { .type_params() .map(|param| ¶m.ident) .collect(); - let (usage, variant_irs) = - collect_variant_irs(variants, &struct_lifetimes, &generic_lookup)?; + let (usage, variant_irs) = collect_variant_irs(variants, &generic_lookup)?; let (generic_params, memento_where_clause) = plan_memento_generics(&input.generics, usage, &generic_lookup); let marker_param_indices = @@ -201,7 +198,10 @@ impl<'a> EnumIr<'a> { self.variants .iter() .flat_map(|variant| variant.fields.iter()) - .find(|field| !matches!(field.strategy, FieldStrategy::StoreAsSelf)) + .find(|field| { + !(matches!(field.strategy, FieldStrategy::StoreAsSelf) + || (field.strategy.is_skip() && is_phantom_data(field.ty))) + }) } pub(crate) fn supports_derived_recall(&self) -> bool { diff --git a/recallable-macro/src/context/internal/shared/fields.rs b/recallable-macro/src/context/internal/shared/fields.rs index 07548c8..ca212f6 100644 --- a/recallable-macro/src/context/internal/shared/fields.rs +++ b/recallable-macro/src/context/internal/shared/fields.rs @@ -1,5 +1,3 @@ -use std::collections::HashSet; - use proc_macro2::TokenStream as TokenStream2; use quote::ToTokens; use syn::{Field, Ident, Index, Meta, PathArguments, Type}; @@ -7,7 +5,7 @@ use syn::{Field, Ident, Index, Meta, PathArguments, Type}; use super::generics::{ BareTypeParam, GenericParamLookup, GenericUsage, collect_generic_dependencies_in_type, }; -use super::lifetime::{field_uses_struct_lifetime, is_phantom_data}; +use super::lifetime::is_phantom_data; use super::util::is_recallable_attr; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -125,7 +123,6 @@ fn classify_recallable_field_type( pub(crate) fn collect_field_irs<'a>( fields: &'a syn::Fields, - struct_lifetimes: &HashSet<&'a syn::Ident>, generic_lookup: &GenericParamLookup<'a>, ) -> syn::Result<(GenericUsage, Vec>)> { let mut usage = GenericUsage::default(); @@ -136,7 +133,7 @@ pub(crate) fn collect_field_irs<'a>( let member = field_member(field, index); let ty = &field.ty; - if is_phantom_data(ty) && field_uses_struct_lifetime(ty, struct_lifetimes) { + if is_phantom_data(ty) { field_irs.push(FieldIr { source: field, memento_index: None, @@ -193,7 +190,9 @@ pub(crate) fn has_recallable_skip_attr(field: &Field) -> bool { mod tests { use syn::parse_quote; - use super::{GenericParamLookup, classify_recallable_field_type}; + use super::{ + FieldStrategy, GenericParamLookup, classify_recallable_field_type, collect_field_irs, + }; #[test] fn recallable_type_classifier_accepts_any_path_type() { @@ -215,4 +214,24 @@ mod tests { Ok(None) )); } + + #[test] + fn phantom_data_fields_are_always_skipped() { + let input: syn::DeriveInput = parse_quote! { + struct Example { + marker: ::core::marker::PhantomData, + value: u8, + } + }; + let fields = match &input.data { + syn::Data::Struct(data) => &data.fields, + _ => unreachable!(), + }; + let lookup = GenericParamLookup::new(&input.generics); + + let (_, field_irs) = collect_field_irs(fields, &lookup).unwrap(); + + assert_eq!(field_irs[0].strategy, FieldStrategy::Skip); + assert_eq!(field_irs[1].strategy, FieldStrategy::StoreAsSelf); + } } diff --git a/recallable-macro/src/context/internal/structs/ir.rs b/recallable-macro/src/context/internal/structs/ir.rs index 4643fa5..8c2bae3 100644 --- a/recallable-macro/src/context/internal/structs/ir.rs +++ b/recallable-macro/src/context/internal/structs/ir.rs @@ -73,7 +73,7 @@ impl<'a> StructIr<'a> { .type_params() .map(|param| ¶m.ident) .collect(); - let (usage, field_irs) = collect_field_irs(fields, &struct_lifetimes, &generic_lookup)?; + let (usage, field_irs) = collect_field_irs(fields, &generic_lookup)?; let (generic_params, memento_where_clause) = plan_memento_generics(&input.generics, usage, &generic_lookup); let marker_param_indices = diff --git a/recallable-macro/src/context/recallable_impl/enums.rs b/recallable-macro/src/context/recallable_impl/enums.rs index 2a5a345..65fec83 100644 --- a/recallable-macro/src/context/recallable_impl/enums.rs +++ b/recallable-macro/src/context/recallable_impl/enums.rs @@ -5,7 +5,8 @@ use syn::WherePredicate; use crate::context::internal::enums::{ EnumIr, VariantIr, VariantShape, build_binding_ident, collect_recall_like_bounds_for_enum, }; -use crate::context::internal::shared::{CodegenEnv, CodegenItemIr}; +use crate::context::internal::shared::lifetime::is_phantom_data; +use crate::context::internal::shared::{CodegenEnv, CodegenItemIr, FieldIr, FieldStrategy}; #[must_use] pub(crate) fn gen_enum_recallable_impl(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { @@ -73,15 +74,14 @@ fn gen_enum_restore_helper(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { } fn build_variant_memento_pattern(variant: &VariantIr<'_>) -> TokenStream2 { + let mut bindings = variant.kept_bindings().peekable(); + if bindings.peek().is_none() { + return quote! {}; + } + match variant.shape { - VariantShape::Named => { - let bindings = variant.bindings(); - quote! { { #(#bindings),* } } - } - VariantShape::Unnamed => { - let bindings = variant.bindings(); - quote! { ( #(#bindings),* ) } - } + VariantShape::Named => quote! { { #(#bindings),* } }, + VariantShape::Unnamed => quote! { ( #(#bindings),* ) }, VariantShape::Unit => quote! {}, } } @@ -93,19 +93,34 @@ fn build_variant_restore_expr(variant: &VariantIr<'_>, enum_name: &syn::Ident) - VariantShape::Named => { let inits = variant.indexed_fields().map(|(index, field)| { let member = &field.member; - let binding = build_binding_ident(field, index); - quote! { #member: #binding } + let value = build_variant_restore_value(field, index); + quote! { #member: #value } }); quote! { #enum_name::#variant_name { #(#inits),* } } } VariantShape::Unnamed => { - let values = variant.bindings(); + let values = variant + .indexed_fields() + .map(|(index, field)| build_variant_restore_value(field, index)); quote! { #enum_name::#variant_name(#(#values),*) } } VariantShape::Unit => quote! { #enum_name::#variant_name }, } } +fn build_variant_restore_value(field: &FieldIr<'_>, index: usize) -> TokenStream2 { + match field.strategy { + FieldStrategy::StoreAsSelf => { + let binding = build_binding_ident(field, index); + quote! { #binding } + } + FieldStrategy::Skip if is_phantom_data(field.ty) => quote! { ::core::marker::PhantomData }, + FieldStrategy::StoreAsMemento | FieldStrategy::Skip => { + unreachable!("manual-only gating rejects non-phantom skipped and recallable fields") + } + } +} + #[cfg(test)] mod tests { use quote::quote; diff --git a/recallable/tests/basic.rs b/recallable/tests/basic.rs index 4ad0549..7d14347 100644 --- a/recallable/tests/basic.rs +++ b/recallable/tests/basic.rs @@ -65,6 +65,7 @@ fn test_try_recall_custom_error() { } mod phantom_lifetime { + use core::any::TypeId; use core::marker::PhantomData; use recallable::{Recall, Recallable}; @@ -87,6 +88,32 @@ mod phantom_lifetime { s.recall(memento); assert_eq!(s.value, 42); } + + #[derive(Recallable, Recall)] + struct PhantomType { + marker: PhantomData, + value: u8, + } + + type PhantomTypeLeft = as Recallable>::Memento; + type PhantomTypeRight = as Recallable>::Memento; + + #[test] + fn test_phantom_type_is_auto_skipped_from_memento() { + let mut s = PhantomType:: { + marker: PhantomData, + value: 10, + }; + let memento = PhantomTypeLeft { value: 42 }; + + s.recall(memento); + + assert_eq!(s.value, 42); + assert_eq!( + TypeId::of::(), + TypeId::of::() + ); + } } mod skipped_borrowed_field { diff --git a/recallable/tests/enum_recall.rs b/recallable/tests/enum_recall.rs index e73fe86..c7fb273 100644 --- a/recallable/tests/enum_recall.rs +++ b/recallable/tests/enum_recall.rs @@ -31,7 +31,6 @@ fn test_assignment_only_enum_recall_switches_to_named_variant() { state.recall(AssignmentOnlyMemento::Ready { bytes: [4, 5], version: 9, - marker: PhantomData, }); assert_eq!( state, diff --git a/recallable/tests/ui/derive_pass_enum_no_warnings.rs b/recallable/tests/ui/derive_pass_enum_no_warnings.rs index 6b0efd0..e767a05 100644 --- a/recallable/tests/ui/derive_pass_enum_no_warnings.rs +++ b/recallable/tests/ui/derive_pass_enum_no_warnings.rs @@ -22,7 +22,6 @@ fn main() { let mut state = Example::::Loading(1); state.recall(Memento::Ready { value: 2, - marker: PhantomData, }); let _ = state; From bd3063ca20fc70f30e29d3e041c89b538da8b9d2 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 20:32:09 +0800 Subject: [PATCH 26/45] chore: add .claude/ to .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index bc5e02c..7763c0c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ .vscode/ .worktrees/ +.claude/ + target/ fuzz/artifacts/ fuzz/coverage/ From 46c42d922731b1aa7cebb66a61b845dc80b5c693 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 20:33:34 +0800 Subject: [PATCH 27/45] docs: clarify skipped phantom enum recall support --- GUIDE.md | 10 ++++++---- README.md | 12 +++++++----- recallable-macro/src/lib.rs | 13 ++++++++----- recallable/src/lib.rs | 20 ++++++++++++-------- recallable/tests/enum_recall.rs | 25 +++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 22 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index 7e6a111..8fa16e9 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -258,8 +258,9 @@ With the default `serde` feature enabled, it also injects: Enum support is intentionally split: - assignment-only enums can use `#[recallable_model]` directly -- enums with nested `#[recallable]` or `#[recallable(skip)]` fields should - derive `Recallable` and implement `Recall` or `TryRecall` manually +- enums with skipped `PhantomData<_>` marker fields can also use it directly +- enums with nested `#[recallable]` or other `#[recallable(skip)]` fields + should derive `Recallable` and implement `Recall` or `TryRecall` manually Example: @@ -683,8 +684,9 @@ Generates the `Recall` implementation. Behavior: - struct fields are handled as before -- enum derives are supported only for assignment-only variants -- enums with nested `#[recallable]` or skipped fields should derive +- enum derives are supported only for assignment-only variants, plus skipped + `PhantomData<_>` marker fields +- enums with nested `#[recallable]` or other skipped fields should derive `Recallable` and implement `Recall` or `TryRecall` manually ### `#[recallable]` diff --git a/README.md b/README.md index 9bf5266..14d8cbb 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,9 @@ For `no_std + serde` deployments, prefer a `no_std`-compatible format such as For enums, `#[recallable_model]` is intentionally narrower than `#[derive(Recallable)]`: - assignment-only enums are supported directly -- enums with nested `#[recallable]` or `#[recallable(skip)]` fields should derive - `Recallable` and implement `Recall` or `TryRecall` manually +- enums with skipped `PhantomData<_>` marker fields are still supported directly +- enums with nested `#[recallable]` or other `#[recallable(skip)]` fields should + derive `Recallable` and implement `Recall` or `TryRecall` manually ## Features @@ -232,9 +233,10 @@ impl Recall for EngineState { - Derive macros support structs and enums - `#[derive(Recallable)]` supports enums under the normal field rules - `#[derive(Recall)]` and `#[recallable_model]` support enums only when every - variant field is assignment-only -- Enums with nested `#[recallable]` or `#[recallable(skip)]` fields should - derive `Recallable` and implement `Recall` or `TryRecall` manually + non-marker variant field is assignment-only +- Enums with skipped `PhantomData<_>` marker fields are still supported +- Enums with nested `#[recallable]` or other `#[recallable(skip)]` fields + should derive `Recallable` and implement `Recall` or `TryRecall` manually - Borrowed state fields are rejected unless they are skipped - `#[recallable]` is path-only: it supports type parameters, path types, and associated types, but not tuple/reference/slice/function syntax directly diff --git a/recallable-macro/src/lib.rs b/recallable-macro/src/lib.rs index 20c23e1..800f67d 100644 --- a/recallable-macro/src/lib.rs +++ b/recallable-macro/src/lib.rs @@ -7,7 +7,8 @@ //! - `#[recallable_model]`: injects `Recallable`/`Recall` derives for structs and //! assignment-only enums; with the `serde` Cargo feature enabled for this macro //! crate it also adds `serde::Serialize` and applies `#[serde(skip)]` to fields -//! marked `#[recallable(skip)]`. Complex enums should derive `Recallable` and +//! marked `#[recallable(skip)]`. Complex enums with nested `#[recallable]` +//! fields or non-marker skipped fields should derive `Recallable` and //! implement `Recall` or `TryRecall` manually. //! //! - `#[derive(Recallable)]`: generates an internal companion memento type, exposes @@ -16,7 +17,7 @@ //! //! - `#[derive(Recall)]`: generates the `Recall` implementation, recursively //! recalls struct fields annotated with `#[recallable]`, and supports enums only -//! when every variant field is assignment-only. +//! when every non-marker variant field is assignment-only. //! //! Feature flags are evaluated in the `recallable-macro` crate itself. See `context` //! for details about the generated memento struct and trait implementations. @@ -38,8 +39,9 @@ mod model_macro; /// - For fields annotated with `#[recallable(skip)]`, it injects `#[serde(skip)]` /// to keep serde output aligned with recall behavior. /// - This attribute itself takes no arguments. -/// - Complex enums with nested `#[recallable]` or skipped fields are rejected so -/// the caller can keep `Recall` or `TryRecall` explicit. +/// - Complex enums with nested `#[recallable]` fields or non-marker skipped +/// fields are rejected so the caller can keep `Recall` or `TryRecall` +/// explicit. /// /// This macro preserves the original struct shape and only mutates attributes. /// @@ -128,7 +130,8 @@ pub fn derive_recallable(input: TokenStream) -> TokenStream { /// /// For `#[recallable]` fields, replace/merge behavior comes from the field type's own /// `Recall` implementation. -/// Enums are supported only when every variant field is assignment-only. +/// Enums are supported only when every non-marker variant field is +/// assignment-only. /// For supported enums, the generated implementation restores the target variant /// from the memento directly. pub fn derive_recall(input: TokenStream) -> TokenStream { diff --git a/recallable/src/lib.rs b/recallable/src/lib.rs index 70c8085..f7436cc 100644 --- a/recallable/src/lib.rs +++ b/recallable/src/lib.rs @@ -31,8 +31,10 @@ extern crate self as recallable; /// Adds `#[derive(Recallable, Recall)]` automatically. When the `serde` feature is enabled, /// also derives `serde::Serialize` on the struct and injects `#[serde(skip)]` on fields /// marked with `#[recallable(skip)]`. -/// Complex enums with nested `#[recallable]` or `#[recallable(skip)]` fields should -/// derive [`Recallable`] and implement [`Recall`] or [`TryRecall`] manually. +/// Complex enums with nested `#[recallable]` fields or non-marker +/// `#[recallable(skip)]` fields should derive [`Recallable`] and implement +/// [`Recall`] or [`TryRecall`] manually. Skipped `PhantomData<_>` marker fields +/// remain supported on assignment-only enums. /// /// Lifetime parameters are supported only when the generated memento can stay owned: /// non-skipped fields may not borrow one of the struct's lifetimes. Skipped borrowed @@ -72,9 +74,10 @@ pub use recallable_macro::recallable_model; /// Supports structs directly. /// Supports enums by generating an enum-shaped memento with matching variants. /// For enums, `#[derive(Recall)]` and `#[recallable_model]` are available only -/// when every variant field is assignment-only. -/// Enums with nested `#[recallable]` or `#[recallable(skip)]` fields can still -/// derive [`Recallable`] and provide manual [`Recall`] or [`TryRecall`] behavior. +/// when every non-marker variant field is assignment-only. +/// Enums with nested `#[recallable]` fields or non-marker `#[recallable(skip)]` +/// fields can still derive [`Recallable`] and provide manual [`Recall`] or +/// [`TryRecall`] behavior. /// /// The memento struct mirrors the original but replaces `#[recallable]`-annotated fields /// with their `::Memento` type and omits `#[recallable(skip)]` fields. @@ -131,9 +134,10 @@ pub use recallable_macro::Recallable; /// Fields marked `#[recallable(skip)]` are left untouched. /// For `#[recallable]` fields, replace/merge behavior comes from the field type's own /// [`Recall`] implementation. -/// Enums are supported only when every variant field is assignment-only. -/// Enums with nested `#[recallable]` or skipped fields should derive -/// [`Recallable`] and implement [`Recall`] or [`TryRecall`] manually. +/// Enums are supported only when every non-marker variant field is +/// assignment-only. Skipped `PhantomData<_>` marker fields are allowed. +/// Enums with nested `#[recallable]` fields or other skipped fields should +/// derive [`Recallable`] and implement [`Recall`] or [`TryRecall`] manually. /// For supported enums, the derived implementation restores the target variant /// from the memento directly. /// diff --git a/recallable/tests/enum_recall.rs b/recallable/tests/enum_recall.rs index c7fb273..c23f409 100644 --- a/recallable/tests/enum_recall.rs +++ b/recallable/tests/enum_recall.rs @@ -18,6 +18,18 @@ enum AssignmentOnlyEnum { type AssignmentOnlyMemento = as Recallable>::Memento; +#[derive(Clone, Debug, PartialEq, recallable::Recallable, recallable::Recall)] +enum ExplicitSkippedPhantomEnum { + Idle, + Ready { + #[recallable(skip)] + marker: PhantomData, + value: u8, + }, +} + +type ExplicitSkippedPhantomMemento = as Recallable>::Memento; + #[test] fn test_assignment_only_enum_recall_switches_to_tuple_variant() { let mut state = AssignmentOnlyEnum::::Idle; @@ -41,3 +53,16 @@ fn test_assignment_only_enum_recall_switches_to_named_variant() { } ); } + +#[test] +fn test_assignment_only_enum_recall_accepts_explicit_skipped_phantom_field() { + let mut state = ExplicitSkippedPhantomEnum::::Idle; + state.recall(ExplicitSkippedPhantomMemento::Ready { value: 7 }); + assert_eq!( + state, + ExplicitSkippedPhantomEnum::Ready { + marker: PhantomData, + value: 7, + } + ); +} From 2a28e83fe964ba896b2a762a1e2bb048c25955d1 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 20:44:07 +0800 Subject: [PATCH 28/45] docs: explain hidden markers for retained generics --- GUIDE.md | 60 +++++++++++++++++++++++++++++++++++++++++++ README.md | 6 +++++ recallable/src/lib.rs | 27 +++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/GUIDE.md b/GUIDE.md index 8fa16e9..475a973 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -395,6 +395,66 @@ Generated mementos are intentionally somewhat opaque: This design pushes callers toward "construct or deserialize a memento, then apply it" instead of depending on widened field visibility. +### Skipped `PhantomData` and retained generics + +Most skipped fields simply disappear from the generated memento. The tricky case +is when a skipped `PhantomData<_>` is the only field mentioning a generic that +still must remain part of the memento type. + +```rust +use core::any::TypeId; +use core::marker::PhantomData; +use recallable::Recallable; + +#[derive(Recallable)] +struct BoundDependent, U> { + value: T, + #[recallable(skip)] + marker: PhantomData, +} + +type Left = as Recallable>::Memento; +type Right = as Recallable>::Memento; + +assert_ne!(TypeId::of::(), TypeId::of::()); +``` + +Why this needs a hidden marker: + +- the skipped field means there is no visible memento field of type `U` +- `U` still matters, because the retained generic `T` depends on it through + `T: From` +- the generated memento type therefore needs to keep `U` alive internally + +The derive handles that by synthesizing an internal `PhantomData` marker on the +generated memento. + +If a skipped generic is otherwise unused, the derive prunes it instead of +preserving it: + +```rust +use core::any::TypeId; +use core::marker::PhantomData; +use recallable::Recallable; + +#[derive(Recallable)] +enum SkippedGenericEnum { + Value(T), + Marker(#[recallable(skip)] PhantomData), +} + +type Left = as Recallable>::Memento; +type Right = as Recallable>::Memento; + +assert_eq!(TypeId::of::(), TypeId::of::()); +``` + +So the rule is: + +- if a skipped generic is no longer needed, the memento drops it +- if it is still needed by retained generics or bounds, the derive keeps it via + an internal hidden marker + ## Recursive fields and container-defined semantics Mark a field with `#[recallable]` when that field should use its own diff --git a/README.md b/README.md index 14d8cbb..9c19917 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,12 @@ For enums, `#[recallable_model]` is intentionally narrower than `#[derive(Recall - enums with nested `#[recallable]` or other `#[recallable(skip)]` fields should derive `Recallable` and implement `Recall` or `TryRecall` manually +Skipped `PhantomData<_>` can also affect generic retention on generated +mementos. When a skipped marker is the only field mentioning a generic that +still must remain part of the memento type, the derive keeps that generic alive +with an internal hidden marker. The user-facing examples for that corner case +live in the API docs and [GUIDE.md](GUIDE.md). + ## Features - `serde` (default): enables macro-generated serde support; generated mementos derive diff --git a/recallable/src/lib.rs b/recallable/src/lib.rs index f7436cc..deecf94 100644 --- a/recallable/src/lib.rs +++ b/recallable/src/lib.rs @@ -92,6 +92,33 @@ pub use recallable_macro::recallable_model; /// `From` for the memento type, which requires /// `::Memento: From` for each `#[recallable]` field. /// +/// When a skipped field would otherwise remove the last field-level mention of a +/// retained generic parameter, the derive keeps that generic alive with an +/// internal `PhantomData` marker on the generated memento. This matters for +/// cases like: +/// +/// ```rust +/// use core::any::TypeId; +/// use core::marker::PhantomData; +/// use recallable::Recallable; +/// use std::string::String; +/// +/// #[derive(Recallable)] +/// struct BoundDependent, U> { +/// value: T, +/// #[recallable(skip)] +/// marker: PhantomData, +/// } +/// +/// type Left = as Recallable>::Memento; +/// type Right = as Recallable>::Memento; +/// +/// assert_ne!(TypeId::of::(), TypeId::of::()); +/// ``` +/// +/// Here `U` is not a visible memento field, but it still matters because the +/// retained generic `T` depends on it through `T: From`. +/// /// Lifetime parameters are supported only when the generated memento can stay owned: /// non-skipped fields may not borrow one of the struct's lifetimes. Skipped borrowed /// fields and lifetime-only markers such as `PhantomData<&'a T>` are allowed. From 4024986761ce4e6d1900f8a5ec747a1f7292c7df Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 20:55:39 +0800 Subject: [PATCH 29/45] test: cover enum impl_from bare generic bounds --- recallable/tests/enum_recallable.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/recallable/tests/enum_recallable.rs b/recallable/tests/enum_recallable.rs index d2ee962..7db2ddc 100644 --- a/recallable/tests/enum_recallable.rs +++ b/recallable/tests/enum_recallable.rs @@ -18,6 +18,15 @@ enum NestedEnum { }, } +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, recallable::Recallable)] +enum BareGenericEnum { + Ready { + #[recallable] + inner: T, + }, +} + #[allow(dead_code)] #[derive(Clone, Debug, PartialEq, recallable::Recallable)] enum SkippedEnum<'a> { @@ -96,3 +105,13 @@ fn test_enum_from_impl_is_generated_for_recallable_only_enums() { let _: fn(NestedEnum) -> as Recallable>::Memento = ::core::convert::From::from; } + +#[cfg(feature = "impl_from")] +#[test] +fn test_enum_from_impl_supports_bare_generic_recallable_fields() { + let original = BareGenericEnum::Ready { + inner: GenericInner { value: 42_u32 }, + }; + + let _: > as Recallable>::Memento = original.into(); +} From de1e3a840cef7d2821e7442ae411b2e1fa9b2b4d Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 21:14:22 +0800 Subject: [PATCH 30/45] refactor(macro): simplify unit From emission --- .../src/context/from_impl/structs.rs | 10 +------ .../src/context/internal/structs/ir.rs | 26 +++++++++++++++---- recallable/tests/enum_recallable.rs | 18 +++++++++++++ 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/recallable-macro/src/context/from_impl/structs.rs b/recallable-macro/src/context/from_impl/structs.rs index 991c854..cca6b54 100644 --- a/recallable-macro/src/context/from_impl/structs.rs +++ b/recallable-macro/src/context/from_impl/structs.rs @@ -39,7 +39,7 @@ fn build_from_body(ir: &StructIr) -> TokenStream2 { match ir.shape() { StructShape::Named => build_named_from_body(ir), StructShape::Unnamed => build_unnamed_from_body(ir), - StructShape::Unit => build_unit_from_body(ir), + StructShape::Unit => quote! { Self }, } } @@ -67,14 +67,6 @@ fn build_unnamed_from_body(ir: &StructIr) -> TokenStream2 { quote! { Self(#(#values),*) } } -fn build_unit_from_body(ir: &StructIr) -> TokenStream2 { - if ir.has_synthetic_marker() { - quote! { Self { _recallable_marker: ::core::marker::PhantomData } } - } else { - quote! { Self } - } -} - fn build_marker_init() -> TokenStream2 { quote! { ::core::marker::PhantomData } } diff --git a/recallable-macro/src/context/internal/structs/ir.rs b/recallable-macro/src/context/internal/structs/ir.rs index 8c2bae3..3ab9b72 100644 --- a/recallable-macro/src/context/internal/structs/ir.rs +++ b/recallable-macro/src/context/internal/structs/ir.rs @@ -134,11 +134,7 @@ impl<'a> StructIr<'a> { #[must_use] pub(crate) fn generated_memento_shape(&self) -> StructShape { - if self.shape == StructShape::Unit && self.has_synthetic_marker() { - StructShape::Named - } else { - self.shape - } + self.shape } #[must_use] @@ -182,3 +178,23 @@ impl<'a> CodegenItemIr<'a> for StructIr<'a> { self.fields.iter() } } + +#[cfg(test)] +mod tests { + use syn::parse_quote; + + use super::{StructIr, StructShape}; + + #[test] + fn unit_structs_never_need_synthetic_markers() { + let input: syn::DeriveInput = parse_quote! { + struct Example<'a, T: From, U, const N: usize>; + }; + + let ir = StructIr::analyze(&input).unwrap(); + + assert_eq!(ir.shape(), StructShape::Unit); + assert_eq!(ir.generated_memento_shape(), StructShape::Unit); + assert!(!ir.has_synthetic_marker()); + } +} diff --git a/recallable/tests/enum_recallable.rs b/recallable/tests/enum_recallable.rs index 7db2ddc..16e1c3d 100644 --- a/recallable/tests/enum_recallable.rs +++ b/recallable/tests/enum_recallable.rs @@ -27,6 +27,13 @@ enum BareGenericEnum { }, } +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, recallable::Recallable, recallable::Recall)] +enum UnitVariantEnum { + Idle, + Value(u8), +} + #[allow(dead_code)] #[derive(Clone, Debug, PartialEq, recallable::Recallable)] enum SkippedEnum<'a> { @@ -115,3 +122,14 @@ fn test_enum_from_impl_supports_bare_generic_recallable_fields() { let _: > as Recallable>::Memento = original.into(); } + +#[cfg(feature = "impl_from")] +#[test] +fn test_enum_from_impl_preserves_unit_variants() { + let original = UnitVariantEnum::Idle; + let memento: ::Memento = original.clone().into(); + let mut target = UnitVariantEnum::Value(7); + + ::recall(&mut target, memento); + assert_eq!(target, original); +} From ec1be12fa4cbedf9e305bf50c36588a7e4b96161 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 23:27:48 +0800 Subject: [PATCH 31/45] refactor(macro): remove an unreachable branch --- recallable-macro/src/context/from_impl/enums.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/recallable-macro/src/context/from_impl/enums.rs b/recallable-macro/src/context/from_impl/enums.rs index 41476c8..4d2cae3 100644 --- a/recallable-macro/src/context/from_impl/enums.rs +++ b/recallable-macro/src/context/from_impl/enums.rs @@ -85,13 +85,11 @@ fn build_binding_pattern(field: &FieldIr<'_>, index: usize) -> TokenStream2 { } } -fn build_variant_from_expr(variant: &VariantIr<'_>) -> TokenStream2 { +fn build_variant_from_expr(variant: &VariantIr<'_>) -> Option { let mut kept_fields = variant.kept_fields().peekable(); - if kept_fields.peek().is_none() { - return quote! {}; - } - match variant.shape { + VariantShape::Unit => None, + _ if kept_fields.peek().is_none() => None, VariantShape::Named => { let inits = kept_fields.map(|(index, field)| { let member = &field.member; @@ -99,16 +97,15 @@ fn build_variant_from_expr(variant: &VariantIr<'_>) -> TokenStream2 { let value = build_from_binding_expr(field, &binding); quote! { #member: #value } }); - quote! { { #(#inits),* } } + Some(quote! { { #(#inits),* } }) } VariantShape::Unnamed => { let values = kept_fields.map(|(index, field)| { let binding = build_binding_ident(field, index); build_from_binding_expr(field, &binding) }); - quote! { ( #(#values),* ) } + Some(quote! { ( #(#values),* ) }) } - VariantShape::Unit => quote! {}, } } From ccc2ada793a7325848081b286a95e4c300634a5a Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 23:52:14 +0800 Subject: [PATCH 32/45] refactor(macro): remove unreachable item-kind guards --- recallable-macro/src/context.rs | 16 ++++++++++++++++ .../src/context/internal/enums/ir.rs | 19 ++++--------------- .../src/context/internal/structs/ir.rs | 19 ++++++------------- 3 files changed, 26 insertions(+), 28 deletions(-) diff --git a/recallable-macro/src/context.rs b/recallable-macro/src/context.rs index 8459f5b..f5564c5 100644 --- a/recallable-macro/src/context.rs +++ b/recallable-macro/src/context.rs @@ -128,4 +128,20 @@ mod tests { assert_eq!(memento.ident.to_string(), "ExampleMemento"); assert_eq!(memento.vis.to_token_stream().to_string(), "pub (crate)"); } + + #[test] + fn analyze_item_rejects_unions_at_outer_boundary() { + let input: syn::DeriveInput = parse_quote! { + union Example { + value: u32, + } + }; + + let error = analyze_item(&input).unwrap_err(); + + assert_eq!( + error.to_string(), + "This derive macro can only be applied to structs or enums" + ); + } } diff --git a/recallable-macro/src/context/internal/enums/ir.rs b/recallable-macro/src/context/internal/enums/ir.rs index e785c6a..5fcab57 100644 --- a/recallable-macro/src/context/internal/enums/ir.rs +++ b/recallable-macro/src/context/internal/enums/ir.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use proc_macro2::TokenStream as TokenStream2; use quote::{format_ident, quote}; -use syn::{DeriveInput, Generics, Ident, ImplGenerics, Visibility, WhereClause}; +use syn::{DataEnum, DeriveInput, Generics, Ident, ImplGenerics, Visibility, WhereClause}; use crate::context::SERDE_ENABLED; use crate::context::internal::shared::FieldMember; @@ -74,19 +74,6 @@ const ENUM_RECALL_MANUAL_ONLY_ERROR: &str = "enum `Recall` derive requires assig const ENUM_MODEL_MANUAL_ONLY_ERROR: &str = "`#[recallable_model]` on enums requires assignment-only variants; complex enums should \ derive `Recallable` and implement `Recall` or `TryRecall` manually"; -fn extract_enum_variants( - input: &DeriveInput, -) -> syn::Result<&syn::punctuated::Punctuated> { - if let syn::Data::Enum(data) = &input.data { - Ok(&data.variants) - } else { - Err(syn::Error::new_spanned( - input, - "This derive macro can only be applied to structs or enums", - )) - } -} - fn collect_variant_irs<'a>( variants: &'a syn::punctuated::Punctuated, generic_lookup: &GenericParamLookup<'a>, @@ -122,7 +109,9 @@ fn collect_variant_irs<'a>( impl<'a> EnumIr<'a> { pub(crate) fn analyze(input: &'a DeriveInput) -> syn::Result { - let variants = extract_enum_variants(input)?; + let syn::Data::Enum(DataEnum { variants, .. }) = &input.data else { + unreachable!("EnumIr::analyze only receives enum inputs"); + }; let struct_lifetimes = collect_struct_lifetimes(&input.generics); for variant in variants { validate_no_borrowed_fields(&variant.fields, &struct_lifetimes)?; diff --git a/recallable-macro/src/context/internal/structs/ir.rs b/recallable-macro/src/context/internal/structs/ir.rs index 3ab9b72..278c5c0 100644 --- a/recallable-macro/src/context/internal/structs/ir.rs +++ b/recallable-macro/src/context/internal/structs/ir.rs @@ -2,7 +2,9 @@ use std::collections::HashSet; use proc_macro2::TokenStream as TokenStream2; use quote::quote; -use syn::{DeriveInput, Fields, Generics, Ident, ImplGenerics, Visibility, WhereClause}; +use syn::{ + DataStruct, DeriveInput, Fields, Generics, Ident, ImplGenerics, Visibility, WhereClause, +}; use crate::context::SERDE_ENABLED; use crate::context::internal::shared::bounds::MementoTraitSpec; @@ -48,20 +50,11 @@ pub(crate) struct StructIr<'a> { skip_memento_default_derives: bool, } -fn extract_struct_fields(input: &DeriveInput) -> syn::Result<&Fields> { - if let syn::Data::Struct(data) = &input.data { - Ok(&data.fields) - } else { - Err(syn::Error::new_spanned( - input, - "This derive macro can only be applied to structs", - )) - } -} - impl<'a> StructIr<'a> { pub(crate) fn analyze(input: &'a DeriveInput) -> syn::Result { - let fields = extract_struct_fields(input)?; + let syn::Data::Struct(DataStruct { fields, .. }) = &input.data else { + unreachable!("StructIr::analyze only receives struct inputs"); + }; let struct_lifetimes = collect_struct_lifetimes(&input.generics); validate_no_borrowed_fields(fields, &struct_lifetimes)?; From cb6cbd65685f26f16f1ed999ed8e3ac7e8ff07b7 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sat, 28 Mar 2026 23:55:31 +0800 Subject: [PATCH 33/45] test(macro): cover top-level recallable parameter errors --- .../src/context/internal/shared/item.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/recallable-macro/src/context/internal/shared/item.rs b/recallable-macro/src/context/internal/shared/item.rs index ea24c89..458e374 100644 --- a/recallable-macro/src/context/internal/shared/item.rs +++ b/recallable-macro/src/context/internal/shared/item.rs @@ -39,3 +39,24 @@ impl<'a> ItemIr<'a> { } } } + +#[cfg(test)] +mod tests { + use syn::parse_quote; + + use super::has_skip_memento_default_derives; + + #[test] + fn struct_level_unknown_recallable_parameter_is_rejected() { + let input: syn::DeriveInput = parse_quote! { + #[recallable(unknown)] + struct Example { + value: u32, + } + }; + + let error = has_skip_memento_default_derives(&input).unwrap_err(); + + assert_eq!(error.to_string(), "unrecognized `recallable` parameter"); + } +} From 50b552068fcbc2479073da4f6b38d6f13fdb609a Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sun, 29 Mar 2026 00:02:14 +0800 Subject: [PATCH 34/45] refactor(macro): simplify enum memento emission --- recallable-macro/src/context/memento/enums.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/recallable-macro/src/context/memento/enums.rs b/recallable-macro/src/context/memento/enums.rs index 7d69960..0027c5e 100644 --- a/recallable-macro/src/context/memento/enums.rs +++ b/recallable-macro/src/context/memento/enums.rs @@ -51,12 +51,7 @@ fn build_memento_where_clause(ir: &EnumIr, env: &CodegenEnv) -> Option Vec { @@ -74,14 +69,11 @@ fn build_memento_variant( .map(|(_, field)| build_memento_field(field, recallable_trait, generic_type_params)) .peekable(); - if fields.peek().is_none() { - return quote! { #name }; - } - match variant.shape { + VariantShape::Unit => quote! { #name }, + _ if fields.peek().is_none() => quote! { #name }, VariantShape::Named => quote! { #name { #(#fields),* } }, VariantShape::Unnamed => quote! { #name(#(#fields),*) }, - VariantShape::Unit => quote! { #name }, } } From 5d859f69505275348a616efd42a5905bff3ff17c Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sun, 29 Mar 2026 00:21:43 +0800 Subject: [PATCH 35/45] refactor(macro): remove unreachable branch --- .../src/context/recallable_impl/enums.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/recallable-macro/src/context/recallable_impl/enums.rs b/recallable-macro/src/context/recallable_impl/enums.rs index 65fec83..cebfcef 100644 --- a/recallable-macro/src/context/recallable_impl/enums.rs +++ b/recallable-macro/src/context/recallable_impl/enums.rs @@ -73,16 +73,13 @@ fn gen_enum_restore_helper(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { } } -fn build_variant_memento_pattern(variant: &VariantIr<'_>) -> TokenStream2 { +fn build_variant_memento_pattern(variant: &VariantIr<'_>) -> Option { let mut bindings = variant.kept_bindings().peekable(); - if bindings.peek().is_none() { - return quote! {}; - } - match variant.shape { - VariantShape::Named => quote! { { #(#bindings),* } }, - VariantShape::Unnamed => quote! { ( #(#bindings),* ) }, - VariantShape::Unit => quote! {}, + VariantShape::Unit => None, + _ if bindings.peek().is_none() => None, + VariantShape::Named => Some(quote! { { #(#bindings),* } }), + VariantShape::Unnamed => Some(quote! { ( #(#bindings),* ) }), } } From 3bbde889d3f5ae6e09f59837ba33a59b93f60e8e Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sun, 29 Mar 2026 00:21:56 +0800 Subject: [PATCH 36/45] test(macro): cover model macro error paths --- recallable-macro/src/model_macro.rs | 94 ++++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 14 deletions(-) diff --git a/recallable-macro/src/model_macro.rs b/recallable-macro/src/model_macro.rs index bc7012c..1cbaf29 100644 --- a/recallable-macro/src/model_macro.rs +++ b/recallable-macro/src/model_macro.rs @@ -13,19 +13,20 @@ const SERDE_DERIVE: &str = "serde_derive"; #[must_use] pub(super) fn expand(attr: TokenStream, item: TokenStream) -> TokenStream { - let attr_tokens: TokenStream2 = attr.into(); - if let Err(err) = validate_model_attr(&attr_tokens) { + expand_tokens(attr.into(), item.into()).into() +} + +#[must_use] +fn expand_tokens(attr: TokenStream2, item: TokenStream2) -> TokenStream2 { + if let Err(err) = validate_model_attr(&attr) { return err.to_compile_error().into(); } - let mut model_item = match parse_model_item(item) { + let mut model_item = match parse_model_item_tokens(item) { Ok(item) => item, Err(e) => return e.to_compile_error().into(), }; - let derive_input: syn::DeriveInput = match model_item.parse() { - Ok(input) => input, - Err(e) => return e.to_compile_error().into(), - }; + let derive_input = model_item.parse(); if let Err(e) = context::analyze_model_input(&derive_input) { return e.to_compile_error().into(); } @@ -39,7 +40,7 @@ pub(super) fn expand(attr: TokenStream, item: TokenStream) -> TokenStream { model_item.add_serde_skip_attrs(); } - model_item.item_tokenstream().into() + model_item.item_tokenstream() } fn validate_model_attr(attr: &TokenStream2) -> syn::Result<()> { @@ -66,8 +67,8 @@ fn build_model_derive_attr(crate_path: &TokenStream2) -> syn::Attribute { } } -fn parse_model_item(item: TokenStream) -> syn::Result { - let item: Item = syn::parse(item)?; +fn parse_model_item_tokens(item: TokenStream2) -> syn::Result { + let item: Item = syn::parse2(item)?; match item { Item::Struct(item) => Ok(ModelItem::Struct(item)), Item::Enum(item) => Ok(ModelItem::Enum(item)), @@ -169,11 +170,11 @@ impl ModelItem { } } - fn parse(&self) -> syn::Result { - Ok(match self { + fn parse(&self) -> syn::DeriveInput { + match self { ModelItem::Struct(item) => item.clone().into(), ModelItem::Enum(item) => item.clone().into(), - }) + } } } @@ -182,7 +183,7 @@ mod tests { use quote::quote; use syn::parse_quote; - use super::{is_serde_serialize_path, validate_model_attr}; + use super::{expand_tokens, is_serde_serialize_path, parse_model_item_tokens, validate_model_attr}; #[test] fn serde_serialize_path_detection_is_precise() { @@ -216,4 +217,69 @@ mod tests { .contains("`#[recallable_model]` does not accept arguments") ); } + + #[test] + fn parse_model_item_rejects_non_struct_or_enum_items() { + let error = match parse_model_item_tokens(quote!( + fn example() {} + )) { + Ok(_) => panic!("expected parse_model_item to reject functions"), + Err(error) => error, + }; + + assert_eq!( + error.to_string(), + "`#[recallable_model]` can only be applied to structs or enums" + ); + } + + #[test] + fn expand_returns_compile_error_for_model_arguments() { + let tokens = expand_tokens(quote!(unexpected), quote!(struct Example;)).to_string(); + + assert!(tokens.contains("compile_error")); + assert!(tokens.contains("does not accept arguments")); + } + + #[test] + fn expand_returns_compile_error_for_non_model_items() { + let tokens = expand_tokens(quote!(), quote!(fn example() {})).to_string(); + + assert!(tokens.contains("compile_error")); + assert!(tokens.contains("can only be applied to structs or enums")); + } + + #[test] + fn expand_returns_compile_error_for_model_analysis_failures() { + let tokens = expand_tokens( + quote!(), + quote! { + enum Example { + Value(#[recallable] Inner), + } + }, + ) + .to_string(); + + assert!(tokens.contains("compile_error")); + assert!(tokens.contains("assignment-only variants")); + } + + #[cfg(feature = "serde")] + #[test] + fn expand_returns_compile_error_for_manual_serialize_derives() { + let tokens = expand_tokens( + quote!(), + quote! { + #[derive(serde::Serialize)] + struct Example { + value: u32, + } + }, + ) + .to_string(); + + assert!(tokens.contains("compile_error")); + assert!(tokens.contains("already derives")); + } } From 84cf97a271c36bc1124e9cc6df9924b120fcddcd Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sun, 29 Mar 2026 00:47:07 +0800 Subject: [PATCH 37/45] refactor(macro): make model macro expansion testable --- recallable-macro/src/model_macro.rs | 81 ++++++++++++++++------------- 1 file changed, 44 insertions(+), 37 deletions(-) diff --git a/recallable-macro/src/model_macro.rs b/recallable-macro/src/model_macro.rs index 1cbaf29..59f0834 100644 --- a/recallable-macro/src/model_macro.rs +++ b/recallable-macro/src/model_macro.rs @@ -13,34 +13,27 @@ const SERDE_DERIVE: &str = "serde_derive"; #[must_use] pub(super) fn expand(attr: TokenStream, item: TokenStream) -> TokenStream { - expand_tokens(attr.into(), item.into()).into() -} - -#[must_use] -fn expand_tokens(attr: TokenStream2, item: TokenStream2) -> TokenStream2 { - if let Err(err) = validate_model_attr(&attr) { - return err.to_compile_error().into(); + match expand_tokens(attr.into(), item.into()) { + Ok(ts) => ts.into(), + Err(e) => e.to_compile_error().into(), } +} - let mut model_item = match parse_model_item_tokens(item) { - Ok(item) => item, - Err(e) => return e.to_compile_error().into(), - }; +fn expand_tokens(attr: TokenStream2, item: TokenStream2) -> syn::Result { + validate_model_attr(&attr)?; + let mut model_item = parse_model_item_tokens(item)?; let derive_input = model_item.parse(); - if let Err(e) = context::analyze_model_input(&derive_input) { - return e.to_compile_error().into(); - } - if SERDE_ENABLED && let Err(e) = check_no_serialize_derive(model_item.attrs()) { - return e.to_compile_error().into(); + context::analyze_model_input(&derive_input)?; + if SERDE_ENABLED { + check_no_serialize_derive(model_item.attrs())?; } model_item.add_derives(); - if SERDE_ENABLED { model_item.add_serde_skip_attrs(); } - model_item.item_tokenstream() + Ok(model_item.item_tokenstream()) } fn validate_model_attr(attr: &TokenStream2) -> syn::Result<()> { @@ -183,7 +176,9 @@ mod tests { use quote::quote; use syn::parse_quote; - use super::{expand_tokens, is_serde_serialize_path, parse_model_item_tokens, validate_model_attr}; + use super::{ + expand_tokens, is_serde_serialize_path, parse_model_item_tokens, validate_model_attr, + }; #[test] fn serde_serialize_path_detection_is_precise() { @@ -234,24 +229,38 @@ mod tests { } #[test] - fn expand_returns_compile_error_for_model_arguments() { - let tokens = expand_tokens(quote!(unexpected), quote!(struct Example;)).to_string(); + fn expand_tokens_reject_model_arguments() { + let error = expand_tokens( + quote!(unexpected), + quote!( + struct Example; + ), + ) + .unwrap_err(); - assert!(tokens.contains("compile_error")); - assert!(tokens.contains("does not accept arguments")); + assert!(error.to_string().contains("does not accept arguments")); } #[test] - fn expand_returns_compile_error_for_non_model_items() { - let tokens = expand_tokens(quote!(), quote!(fn example() {})).to_string(); + fn expand_tokens_reject_non_model_items() { + let error = expand_tokens( + quote!(), + quote!( + fn example() {} + ), + ) + .unwrap_err(); - assert!(tokens.contains("compile_error")); - assert!(tokens.contains("can only be applied to structs or enums")); + assert!( + error + .to_string() + .contains("can only be applied to structs or enums") + ); } #[test] - fn expand_returns_compile_error_for_model_analysis_failures() { - let tokens = expand_tokens( + fn expand_tokens_reject_model_analysis_failures() { + let error = expand_tokens( quote!(), quote! { enum Example { @@ -259,16 +268,15 @@ mod tests { } }, ) - .to_string(); + .unwrap_err(); - assert!(tokens.contains("compile_error")); - assert!(tokens.contains("assignment-only variants")); + assert!(error.to_string().contains("assignment-only variants")); } #[cfg(feature = "serde")] #[test] - fn expand_returns_compile_error_for_manual_serialize_derives() { - let tokens = expand_tokens( + fn expand_tokens_reject_manual_serialize_derives() { + let error = expand_tokens( quote!(), quote! { #[derive(serde::Serialize)] @@ -277,9 +285,8 @@ mod tests { } }, ) - .to_string(); + .unwrap_err(); - assert!(tokens.contains("compile_error")); - assert!(tokens.contains("already derives")); + assert!(error.to_string().contains("already derives")); } } From 5f4960dc86f475bee710a56a85536913c92fd279 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sun, 29 Mar 2026 01:00:59 +0800 Subject: [PATCH 38/45] refactor(macro): align shared helper names with item scope --- .../src/context/internal/enums/ir.rs | 16 ++++---- .../src/context/internal/shared/generics.rs | 39 +++++-------------- .../src/context/internal/shared/item.rs | 21 +++++++++- .../src/context/internal/shared/lifetime.rs | 22 +++++------ .../src/context/internal/structs/ir.rs | 6 +-- ...rive_fail_recallable_skip_on_struct.stderr | 2 +- 6 files changed, 53 insertions(+), 53 deletions(-) diff --git a/recallable-macro/src/context/internal/enums/ir.rs b/recallable-macro/src/context/internal/enums/ir.rs index 5fcab57..8c46e17 100644 --- a/recallable-macro/src/context/internal/enums/ir.rs +++ b/recallable-macro/src/context/internal/enums/ir.rs @@ -10,12 +10,11 @@ use crate::context::internal::shared::bounds::MementoTraitSpec; use crate::context::internal::shared::codegen::CodegenItemIr; use crate::context::internal::shared::fields::{FieldIr, FieldStrategy, collect_field_irs}; use crate::context::internal::shared::generics::{ - GenericParamLookup, GenericParamPlan, collect_variant_marker_param_indices, - plan_memento_generics, + GenericParamLookup, GenericParamPlan, collect_marker_param_indices, plan_memento_generics, }; use crate::context::internal::shared::item::has_skip_memento_default_derives; use crate::context::internal::shared::lifetime::{ - collect_struct_lifetimes, is_phantom_data, validate_no_borrowed_fields, + collect_item_lifetimes, is_phantom_data, validate_no_borrowed_fields, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -112,9 +111,9 @@ impl<'a> EnumIr<'a> { let syn::Data::Enum(DataEnum { variants, .. }) = &input.data else { unreachable!("EnumIr::analyze only receives enum inputs"); }; - let struct_lifetimes = collect_struct_lifetimes(&input.generics); + let item_lifetimes = collect_item_lifetimes(&input.generics); for variant in variants { - validate_no_borrowed_fields(&variant.fields, &struct_lifetimes)?; + validate_no_borrowed_fields(&variant.fields, &item_lifetimes)?; } let generic_lookup = GenericParamLookup::new(&input.generics); @@ -126,8 +125,11 @@ impl<'a> EnumIr<'a> { let (usage, variant_irs) = collect_variant_irs(variants, &generic_lookup)?; let (generic_params, memento_where_clause) = plan_memento_generics(&input.generics, usage, &generic_lookup); - let marker_param_indices = - collect_variant_marker_param_indices(&variant_irs, &generic_params, &generic_lookup); + let marker_param_indices = collect_marker_param_indices( + variant_irs.iter().flat_map(|variant| variant.fields.iter()), + &generic_params, + &generic_lookup, + ); Ok(Self { name: &input.ident, diff --git a/recallable-macro/src/context/internal/shared/generics.rs b/recallable-macro/src/context/internal/shared/generics.rs index 4e6b1e8..39881c0 100644 --- a/recallable-macro/src/context/internal/shared/generics.rs +++ b/recallable-macro/src/context/internal/shared/generics.rs @@ -5,7 +5,7 @@ use quote::quote; use syn::visit::Visit; use syn::{GenericParam, Generics, Ident, PathArguments, Type, WhereClause, WherePredicate}; -use crate::context::internal::{enums::VariantIr, shared::FieldIr}; +use crate::context::internal::shared::FieldIr; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum GenericParamRetention { @@ -113,35 +113,16 @@ impl<'a> GenericParamLookup<'a> { pub(crate) struct BareTypeParam(pub(crate) usize); #[must_use] -pub(crate) fn collect_marker_param_indices( - fields: &[FieldIr<'_>], - generic_params: &[GenericParamPlan<'_>], - generic_lookup: &GenericParamLookup<'_>, -) -> Vec { +pub(crate) fn collect_marker_param_indices<'field, 'input>( + fields: impl IntoIterator>, + generic_params: &[GenericParamPlan<'input>], + generic_lookup: &GenericParamLookup<'input>, +) -> Vec +where + 'input: 'field, +{ let referenced_by_fields: HashSet<_> = fields - .iter() - .filter(|field| !field.strategy.is_skip()) - .flat_map(|field| collect_generic_dependencies_in_type(field.ty, generic_lookup)) - .collect(); - - generic_params - .iter() - .enumerate() - .filter_map(|(index, plan)| { - (plan.is_retained() && !referenced_by_fields.contains(&index)).then_some(index) - }) - .collect() -} - -#[must_use] -pub(crate) fn collect_variant_marker_param_indices( - variants: &[VariantIr<'_>], - generic_params: &[GenericParamPlan<'_>], - generic_lookup: &GenericParamLookup<'_>, -) -> Vec { - let referenced_by_fields: HashSet<_> = variants - .iter() - .flat_map(|variant| variant.fields.iter()) + .into_iter() .filter(|field| !field.strategy.is_skip()) .flat_map(|field| collect_generic_dependencies_in_type(field.ty, generic_lookup)) .collect(); diff --git a/recallable-macro/src/context/internal/shared/item.rs b/recallable-macro/src/context/internal/shared/item.rs index 458e374..da39f97 100644 --- a/recallable-macro/src/context/internal/shared/item.rs +++ b/recallable-macro/src/context/internal/shared/item.rs @@ -18,7 +18,7 @@ pub(crate) fn has_skip_memento_default_derives(input: &DeriveInput) -> syn::Resu skip_memento_default_derives = true; Ok(()) } else if meta.path.is_ident("skip") { - Err(meta.error("`skip` is a field-level attribute, not a struct-level attribute")) + Err(meta.error("`skip` is a field-level attribute, not an item-level attribute")) } else { Err(meta.error("unrecognized `recallable` parameter")) } @@ -47,7 +47,7 @@ mod tests { use super::has_skip_memento_default_derives; #[test] - fn struct_level_unknown_recallable_parameter_is_rejected() { + fn item_level_unknown_recallable_parameter_is_rejected() { let input: syn::DeriveInput = parse_quote! { #[recallable(unknown)] struct Example { @@ -59,4 +59,21 @@ mod tests { assert_eq!(error.to_string(), "unrecognized `recallable` parameter"); } + + #[test] + fn item_level_skip_parameter_is_rejected() { + let input: syn::DeriveInput = parse_quote! { + #[recallable(skip)] + enum Example { + Value(u32), + } + }; + + let error = has_skip_memento_default_derives(&input).unwrap_err(); + + assert_eq!( + error.to_string(), + "`skip` is a field-level attribute, not an item-level attribute" + ); + } } diff --git a/recallable-macro/src/context/internal/shared/lifetime.rs b/recallable-macro/src/context/internal/shared/lifetime.rs index 7e2d5b6..f395f7e 100644 --- a/recallable-macro/src/context/internal/shared/lifetime.rs +++ b/recallable-macro/src/context/internal/shared/lifetime.rs @@ -7,9 +7,9 @@ use super::fields::has_recallable_skip_attr; pub(crate) fn validate_no_borrowed_fields( fields: &Fields, - struct_lifetimes: &HashSet<&Ident>, + item_lifetimes: &HashSet<&Ident>, ) -> syn::Result<()> { - if struct_lifetimes.is_empty() { + if item_lifetimes.is_empty() { return Ok(()); } @@ -22,7 +22,7 @@ pub(crate) fn validate_no_borrowed_fields( if is_phantom_data(&field.ty) { continue; } - if field_uses_struct_lifetime(&field.ty, struct_lifetimes) { + if field_uses_item_lifetime(&field.ty, item_lifetimes) { let err = syn::Error::new_spanned(&field.ty, "Recall derives do not support borrowed fields"); match &mut errors { @@ -39,7 +39,7 @@ pub(crate) fn validate_no_borrowed_fields( } #[must_use] -pub(crate) fn collect_struct_lifetimes(generics: &Generics) -> HashSet<&Ident> { +pub(crate) fn collect_item_lifetimes(generics: &Generics) -> HashSet<&Ident> { generics .params .iter() @@ -51,13 +51,13 @@ pub(crate) fn collect_struct_lifetimes(generics: &Generics) -> HashSet<&Ident> { } struct LifetimeUsageChecker<'a> { - struct_lifetimes: &'a HashSet<&'a Ident>, + item_lifetimes: &'a HashSet<&'a Ident>, found: bool, } impl<'ast> Visit<'ast> for LifetimeUsageChecker<'_> { fn visit_lifetime(&mut self, lt: &'ast syn::Lifetime) { - if self.struct_lifetimes.contains(<.ident) { + if self.item_lifetimes.contains(<.ident) { self.found = true; } } @@ -73,9 +73,9 @@ pub(crate) fn is_phantom_data(ty: &Type) -> bool { } #[must_use] -pub(crate) fn field_uses_struct_lifetime(ty: &Type, struct_lifetimes: &HashSet<&Ident>) -> bool { +pub(crate) fn field_uses_item_lifetime(ty: &Type, item_lifetimes: &HashSet<&Ident>) -> bool { let mut checker = LifetimeUsageChecker { - struct_lifetimes, + item_lifetimes, found: false, }; checker.visit_type(ty); @@ -86,7 +86,7 @@ pub(crate) fn field_uses_struct_lifetime(ty: &Type, struct_lifetimes: &HashSet<& mod tests { use syn::parse_quote; - use super::{collect_struct_lifetimes, is_phantom_data, validate_no_borrowed_fields}; + use super::{collect_item_lifetimes, is_phantom_data, validate_no_borrowed_fields}; #[test] fn phantom_data_detection_accepts_common_path_variants() { @@ -118,8 +118,8 @@ mod tests { syn::Data::Struct(data) => &data.fields, _ => unreachable!(), }; - let struct_lifetimes = collect_struct_lifetimes(&input.generics); - let error = validate_no_borrowed_fields(fields, &struct_lifetimes).unwrap_err(); + let item_lifetimes = collect_item_lifetimes(&input.generics); + let error = validate_no_borrowed_fields(fields, &item_lifetimes).unwrap_err(); assert!( error diff --git a/recallable-macro/src/context/internal/structs/ir.rs b/recallable-macro/src/context/internal/structs/ir.rs index 278c5c0..1d8ed8b 100644 --- a/recallable-macro/src/context/internal/structs/ir.rs +++ b/recallable-macro/src/context/internal/structs/ir.rs @@ -15,7 +15,7 @@ use crate::context::internal::shared::generics::{ }; use crate::context::internal::shared::item::has_skip_memento_default_derives; use crate::context::internal::shared::lifetime::{ - collect_struct_lifetimes, validate_no_borrowed_fields, + collect_item_lifetimes, validate_no_borrowed_fields, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -55,8 +55,8 @@ impl<'a> StructIr<'a> { let syn::Data::Struct(DataStruct { fields, .. }) = &input.data else { unreachable!("StructIr::analyze only receives struct inputs"); }; - let struct_lifetimes = collect_struct_lifetimes(&input.generics); - validate_no_borrowed_fields(fields, &struct_lifetimes)?; + let item_lifetimes = collect_item_lifetimes(&input.generics); + validate_no_borrowed_fields(fields, &item_lifetimes)?; let shape = StructShape::from_fields(fields); let memento_name = quote::format_ident!("{}Memento", input.ident); diff --git a/recallable/tests/ui/derive_fail_recallable_skip_on_struct.stderr b/recallable/tests/ui/derive_fail_recallable_skip_on_struct.stderr index 5e85180..cc5778a 100644 --- a/recallable/tests/ui/derive_fail_recallable_skip_on_struct.stderr +++ b/recallable/tests/ui/derive_fail_recallable_skip_on_struct.stderr @@ -1,4 +1,4 @@ -error: `skip` is a field-level attribute, not a struct-level attribute +error: `skip` is a field-level attribute, not an item-level attribute --> tests/ui/derive_fail_recallable_skip_on_struct.rs:4:14 | 4 | #[recallable(skip)] From cae3607e93943e384f03ab8a4263780e1e1da0d5 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sun, 29 Mar 2026 01:48:27 +0800 Subject: [PATCH 39/45] test: add generic dependency coverage examples --- .../src/context/internal/shared/generics.rs | 59 ++++++++++++++++++- recallable/tests/no_serde.rs | 53 +++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/recallable-macro/src/context/internal/shared/generics.rs b/recallable-macro/src/context/internal/shared/generics.rs index 39881c0..f301030 100644 --- a/recallable-macro/src/context/internal/shared/generics.rs +++ b/recallable-macro/src/context/internal/shared/generics.rs @@ -353,8 +353,7 @@ pub(crate) fn is_generic_type_param(ty: &Type, generic_type_params: &HashSet<&Id match ty { Type::Path(tp) if tp.qself.is_none() && tp.path.segments.len() == 1 => { let segment = &tp.path.segments[0]; - matches!(segment.arguments, PathArguments::None) - && generic_type_params.contains(&segment.ident) + segment.arguments.is_empty() && generic_type_params.contains(&segment.ident) } _ => false, } @@ -365,9 +364,24 @@ mod tests { use quote::{ToTokens, quote}; use syn::parse_quote; + use super::{ + GenericParamLookup, GenericParamPlan, GenericParamRetention, + collect_generic_dependencies_in_where_predicate, + }; use crate::context::internal::shared::CodegenItemIr; use crate::context::internal::structs::StructIr; + #[test] + fn generic_param_plan_type_arg_emits_lifetime_arguments() { + let param = parse_quote!('a); + let plan = GenericParamPlan { + param: ¶m, + retention: GenericParamRetention::Retained, + }; + + assert_eq!(plan.type_arg().to_string(), quote!('a).to_string()); + } + #[test] fn memento_generics_preserve_retained_bounds_defaults_and_where_clauses() { let input = parse_quote! { @@ -441,4 +455,45 @@ mod tests { quote!(ExampleMemento).to_string() ); } + + #[test] + fn memento_where_clause_ignores_predicates_without_generic_dependencies() { + let input = parse_quote! { + struct Example + where + u8: Copy, + T: Clone, + { + value: T, + } + }; + + let ir = StructIr::analyze(&input).unwrap(); + + assert_eq!( + ir.memento_where_clause() + .unwrap() + .to_token_stream() + .to_string(), + quote!(where T: Clone).to_string() + ); + } + + #[test] + fn where_predicate_dependency_collection_ignores_non_generic_const_paths() { + let generics = parse_quote!(); + let lookup = GenericParamLookup::new(&generics); + let predicate = parse_quote!([u8; SOME_CONST]: Copy); + + assert!(collect_generic_dependencies_in_where_predicate(&predicate, &lookup).is_empty()); + } + + #[test] + fn where_predicate_dependency_collection_visits_non_single_segment_expr_paths() { + let generics = parse_quote!(); + let lookup = GenericParamLookup::new(&generics); + let predicate = parse_quote!([u8; module::SOME_CONST]: Copy); + + assert!(collect_generic_dependencies_in_where_predicate(&predicate, &lookup).is_empty()); + } } diff --git a/recallable/tests/no_serde.rs b/recallable/tests/no_serde.rs index b80155c..a94d220 100644 --- a/recallable/tests/no_serde.rs +++ b/recallable/tests/no_serde.rs @@ -95,6 +95,25 @@ struct DependentBoundOuter, U> { marker: PhantomData, } +#[derive(recallable::Recallable, recallable::Recall)] +struct LifetimeBoundOuter<'a, T> +where + T: Clone + core::fmt::Debug + PartialEq + From<&'a str>, +{ + value: T, + #[recallable(skip)] + marker: PhantomData<&'a str>, +} + +#[derive(recallable::Recallable, recallable::Recall)] +struct ConcretePredicateOuter +where + T: Clone + core::fmt::Debug + PartialEq, + [u8; 16]: Copy, +{ + value: T, +} + #[derive(Clone, Debug, PartialEq, Deserialize, recallable::Recallable, recallable::Recall)] struct GenericInner { value: T, @@ -153,6 +172,15 @@ struct ConstOuter { inner: ConstBuffer, } +mod fixed_lengths { + pub(super) const WIDTH: usize = 8; +} + +#[derive(Clone, Debug, PartialEq, recallable::Recallable, recallable::Recall)] +struct FixedWidthBuffer { + bytes: [u8; fixed_lengths::WIDTH], +} + #[derive(Clone, Debug, PartialEq, recallable::Recallable, recallable::Recall)] struct MixedConstOuter { #[recallable] @@ -264,6 +292,25 @@ fn test_bound_dependencies_keep_other_generic_params_retained() { assert_ne!(TypeId::of::(), TypeId::of::()); } +#[test] +fn test_bound_dependencies_keep_lifetimes_on_memento_types() { + // Regression: the retained where-clause lifetime `'a` must stay on the generated + // memento type, or this derive input stops compiling because `T: From<&'a str>` + // would mention an undeclared lifetime in the generated bounds. + let _: fn( + &mut LifetimeBoundOuter<'static, String>, + as recallable::Recallable>::Memento, + ) = as recallable::Recall>::recall; +} + +#[test] +fn test_concrete_only_where_predicates_are_accepted() { + let _: fn( + &mut ConcretePredicateOuter, + as recallable::Recallable>::Memento, + ) = as recallable::Recall>::recall; +} + #[test] fn test_recallable_field_accepts_generic_path_type() { let _: fn(&mut GenericPathOuter, ::Memento) = @@ -311,6 +358,12 @@ fn test_const_generic_recallable_field_with_mixed_type_and_const_args() { ) = as recallable::Recall>::recall; } +#[test] +fn test_multi_segment_const_paths_are_supported() { + let _: fn(&mut FixedWidthBuffer, ::Memento) = + ::recall; +} + #[test] fn test_skipped_only_const_params_are_pruned_from_memento_type() { type Left = as recallable::Recallable>::Memento; From 3a825ab89547c567abd3731b6ec5c6d1dab9a1c9 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sun, 29 Mar 2026 02:13:40 +0800 Subject: [PATCH 40/45] fix(macro): emit helper types for const markers --- .../src/context/internal/shared/codegen.rs | 29 +++++++++++++- .../src/context/internal/shared/generics.rs | 5 ++- recallable-macro/src/context/memento/enums.rs | 3 ++ .../src/context/memento/structs.rs | 3 ++ recallable/tests/no_serde.rs | 39 ++++++++++++++++++- 5 files changed, 73 insertions(+), 6 deletions(-) diff --git a/recallable-macro/src/context/internal/shared/codegen.rs b/recallable-macro/src/context/internal/shared/codegen.rs index e7a0106..ed55fdc 100644 --- a/recallable-macro/src/context/internal/shared/codegen.rs +++ b/recallable-macro/src/context/internal/shared/codegen.rs @@ -1,8 +1,8 @@ use std::collections::HashSet; use proc_macro2::TokenStream as TokenStream2; -use quote::quote; -use syn::{Generics, Ident, Type, WhereClause, WherePredicate}; +use quote::{format_ident, quote}; +use syn::{GenericParam, Generics, Ident, Type, WhereClause, WherePredicate}; use super::fields::{FieldIr, FieldMember, FieldStrategy}; use super::generics::{GenericParamPlan, is_generic_type_param, marker_component}; @@ -70,6 +70,12 @@ pub(crate) trait CodegenItemIr<'a> { }) } + fn synthetic_marker_helper_defs(&self) -> impl Iterator + '_ { + self.marker_param_indices() + .iter() + .filter_map(|&index| const_marker_helper_def(self.generic_params()[index].param)) + } + fn recallable_params<'b>(&'b self) -> impl Iterator + 'b where 'a: 'b, @@ -177,6 +183,25 @@ pub(crate) trait CodegenItemIr<'a> { } } +fn const_marker_helper_ident(ident: &Ident) -> Ident { + format_ident!("__RecallableConstMarker_{ident}") +} + +fn const_marker_helper_def(param: &GenericParam) -> Option { + match param { + GenericParam::Const(param) => { + let helper_ident = const_marker_helper_ident(¶m.ident); + let ty = ¶m.ty; + + Some(quote! { + #[doc(hidden)] + struct #helper_ident; + }) + } + _ => None, + } +} + #[must_use] pub(crate) fn build_memento_field_ty( field: &FieldIr<'_>, diff --git a/recallable-macro/src/context/internal/shared/generics.rs b/recallable-macro/src/context/internal/shared/generics.rs index f301030..a82a19b 100644 --- a/recallable-macro/src/context/internal/shared/generics.rs +++ b/recallable-macro/src/context/internal/shared/generics.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use proc_macro2::TokenStream as TokenStream2; -use quote::quote; +use quote::{format_ident, quote}; use syn::visit::Visit; use syn::{GenericParam, Generics, Ident, PathArguments, Type, WhereClause, WherePredicate}; @@ -343,7 +343,8 @@ pub(crate) fn marker_component(param: &GenericParam) -> TokenStream2 { } GenericParam::Const(param) => { let ident = ¶m.ident; - quote! { [(); { let _ = #ident; 0usize }] } + let helper_ident = format_ident!("__RecallableConstMarker_{ident}"); + quote! { #helper_ident<#ident> } } } } diff --git a/recallable-macro/src/context/memento/enums.rs b/recallable-macro/src/context/memento/enums.rs index 0027c5e..60cb396 100644 --- a/recallable-macro/src/context/memento/enums.rs +++ b/recallable-macro/src/context/memento/enums.rs @@ -15,6 +15,7 @@ use crate::context::internal::shared::{ #[must_use] pub(crate) fn gen_memento_enum(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { let derives = ir.memento_trait_spec().derive_attr(); + let marker_helpers = ir.synthetic_marker_helper_defs(); let visibility = ir.visibility(); let memento_name = ir.memento_name(); let memento_generics = ir.memento_decl_generics(); @@ -35,6 +36,8 @@ pub(crate) fn gen_memento_enum(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { ); quote! { + #(#marker_helpers)* + #[automatically_derived] #[allow(dead_code)] #derives diff --git a/recallable-macro/src/context/memento/structs.rs b/recallable-macro/src/context/memento/structs.rs index 8c0010c..7345350 100644 --- a/recallable-macro/src/context/memento/structs.rs +++ b/recallable-macro/src/context/memento/structs.rs @@ -12,12 +12,15 @@ use crate::context::internal::structs::{StructIr, StructShape, collect_recall_li #[must_use] pub(crate) fn gen_memento_struct(ir: &StructIr, env: &CodegenEnv) -> TokenStream2 { let derives = ir.memento_trait_spec().derive_attr(); + let marker_helpers = ir.synthetic_marker_helper_defs(); let visibility = ir.visibility(); let memento_name = ir.memento_name(); let memento_generics = ir.memento_decl_generics(); let body = build_memento_body(ir, env); quote! { + #(#marker_helpers)* + #[automatically_derived] #[allow(dead_code)] #derives diff --git a/recallable/tests/no_serde.rs b/recallable/tests/no_serde.rs index a94d220..053656f 100644 --- a/recallable/tests/no_serde.rs +++ b/recallable/tests/no_serde.rs @@ -105,6 +105,16 @@ where marker: PhantomData<&'a str>, } +#[derive(recallable::Recallable, recallable::Recall)] +struct ConstBoundOuter +where + T: Clone + core::fmt::Debug + PartialEq + From>, +{ + value: T, + #[recallable(skip)] + marker: PhantomData>, +} + #[derive(recallable::Recallable, recallable::Recall)] struct ConcretePredicateOuter where @@ -155,6 +165,15 @@ struct ConstTag { value: usize, } +#[derive(Clone, Debug, PartialEq)] +struct GenericConstValue; + +impl From> for GenericConstValue { + fn from(_: ConstTag) -> Self { + Self + } +} + #[derive(Clone, Debug, PartialEq, recallable::Recallable, recallable::Recall)] struct ConstBuffer { tag: ConstTag, @@ -295,14 +314,30 @@ fn test_bound_dependencies_keep_other_generic_params_retained() { #[test] fn test_bound_dependencies_keep_lifetimes_on_memento_types() { // Regression: the retained where-clause lifetime `'a` must stay on the generated - // memento type, or this derive input stops compiling because `T: From<&'a str>` - // would mention an undeclared lifetime in the generated bounds. + // memento type, and the skipped field means the synthetic marker must mention it + // using the lifetime branch of `marker_component`. let _: fn( &mut LifetimeBoundOuter<'static, String>, as recallable::Recallable>::Memento, ) = as recallable::Recall>::recall; } +#[test] +fn test_bound_dependencies_keep_const_params_on_memento_types() { + // Regression: the retained const param `N` only survives via the skipped field, + // so the synthetic marker must mention it using the const branch of + // `marker_component`. + let _: fn( + &mut ConstBoundOuter, + as recallable::Recallable>::Memento, + ) = as recallable::Recall>::recall; + + type Left = as recallable::Recallable>::Memento; + type Right = as recallable::Recallable>::Memento; + + assert_ne!(TypeId::of::(), TypeId::of::()); +} + #[test] fn test_concrete_only_where_predicates_are_accepted() { let _: fn( From c4f173dc46f90633ab2579e10a1ddb63a3c95e2d Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sun, 29 Mar 2026 13:39:21 +0800 Subject: [PATCH 41/45] refactor(macro): simplify enum shape branching --- recallable-macro/src/context/from_impl/enums.rs | 8 ++++---- recallable-macro/src/context/memento/enums.rs | 9 ++++----- recallable-macro/src/context/recallable_impl/enums.rs | 8 ++++---- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/recallable-macro/src/context/from_impl/enums.rs b/recallable-macro/src/context/from_impl/enums.rs index 4d2cae3..20595c0 100644 --- a/recallable-macro/src/context/from_impl/enums.rs +++ b/recallable-macro/src/context/from_impl/enums.rs @@ -87,10 +87,9 @@ fn build_binding_pattern(field: &FieldIr<'_>, index: usize) -> TokenStream2 { fn build_variant_from_expr(variant: &VariantIr<'_>) -> Option { let mut kept_fields = variant.kept_fields().peekable(); + let non_empty = kept_fields.peek().is_some(); match variant.shape { - VariantShape::Unit => None, - _ if kept_fields.peek().is_none() => None, - VariantShape::Named => { + VariantShape::Named if non_empty => { let inits = kept_fields.map(|(index, field)| { let member = &field.member; let binding = build_binding_ident(field, index); @@ -99,13 +98,14 @@ fn build_variant_from_expr(variant: &VariantIr<'_>) -> Option { }); Some(quote! { { #(#inits),* } }) } - VariantShape::Unnamed => { + VariantShape::Unnamed if non_empty => { let values = kept_fields.map(|(index, field)| { let binding = build_binding_ident(field, index); build_from_binding_expr(field, &binding) }); Some(quote! { ( #(#values),* ) }) } + _ => None, } } diff --git a/recallable-macro/src/context/memento/enums.rs b/recallable-macro/src/context/memento/enums.rs index 60cb396..8d0e69e 100644 --- a/recallable-macro/src/context/memento/enums.rs +++ b/recallable-macro/src/context/memento/enums.rs @@ -71,12 +71,11 @@ fn build_memento_variant( .kept_fields() .map(|(_, field)| build_memento_field(field, recallable_trait, generic_type_params)) .peekable(); - + let non_empty = fields.peek().is_some(); match variant.shape { - VariantShape::Unit => quote! { #name }, - _ if fields.peek().is_none() => quote! { #name }, - VariantShape::Named => quote! { #name { #(#fields),* } }, - VariantShape::Unnamed => quote! { #name(#(#fields),*) }, + VariantShape::Named if non_empty => quote! { #name { #(#fields),* } }, + VariantShape::Unnamed if non_empty => quote! { #name(#(#fields),*) }, + _ => quote! { #name }, } } diff --git a/recallable-macro/src/context/recallable_impl/enums.rs b/recallable-macro/src/context/recallable_impl/enums.rs index cebfcef..75261ed 100644 --- a/recallable-macro/src/context/recallable_impl/enums.rs +++ b/recallable-macro/src/context/recallable_impl/enums.rs @@ -75,11 +75,11 @@ fn gen_enum_restore_helper(ir: &EnumIr, env: &CodegenEnv) -> TokenStream2 { fn build_variant_memento_pattern(variant: &VariantIr<'_>) -> Option { let mut bindings = variant.kept_bindings().peekable(); + let non_empty = bindings.peek().is_some(); match variant.shape { - VariantShape::Unit => None, - _ if bindings.peek().is_none() => None, - VariantShape::Named => Some(quote! { { #(#bindings),* } }), - VariantShape::Unnamed => Some(quote! { ( #(#bindings),* ) }), + VariantShape::Named if non_empty => Some(quote! { { #(#bindings),* } }), + VariantShape::Unnamed if non_empty => Some(quote! { ( #(#bindings),* ) }), + _ => None, } } From 49760f56ee21e312c84f59aa3212ea7b800a9b4d Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sun, 29 Mar 2026 14:22:17 +0800 Subject: [PATCH 42/45] refactor(macro): use Option for empty codegen fragments --- recallable-macro/src/context/from_impl/enums.rs | 8 ++++---- recallable-macro/src/context/internal/shared/codegen.rs | 8 ++------ recallable-macro/src/context/internal/shared/generics.rs | 4 ++-- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/recallable-macro/src/context/from_impl/enums.rs b/recallable-macro/src/context/from_impl/enums.rs index 20595c0..5a825f3 100644 --- a/recallable-macro/src/context/from_impl/enums.rs +++ b/recallable-macro/src/context/from_impl/enums.rs @@ -54,7 +54,7 @@ fn build_enum_from_body(ir: &EnumIr) -> TokenStream2 { } } -fn build_variant_source_pattern(variant: &VariantIr<'_>) -> TokenStream2 { +fn build_variant_source_pattern(variant: &VariantIr<'_>) -> Option { match variant.shape { VariantShape::Named => { let patterns = variant.indexed_fields().map(|(index, field)| { @@ -65,15 +65,15 @@ fn build_variant_source_pattern(variant: &VariantIr<'_>) -> TokenStream2 { build_binding_ident(field, index).to_token_stream() } }); - quote! { { #(#patterns),* } } + Some(quote! { { #(#patterns),* } }) } VariantShape::Unnamed => { let patterns = variant .indexed_fields() .map(|(index, field)| build_binding_pattern(field, index)); - quote! { ( #(#patterns),* ) } + Some(quote! { ( #(#patterns),* ) }) } - VariantShape::Unit => quote! {}, + VariantShape::Unit => None, } } diff --git a/recallable-macro/src/context/internal/shared/codegen.rs b/recallable-macro/src/context/internal/shared/codegen.rs index ed55fdc..904191c 100644 --- a/recallable-macro/src/context/internal/shared/codegen.rs +++ b/recallable-macro/src/context/internal/shared/codegen.rs @@ -21,7 +21,7 @@ pub(crate) trait CodegenItemIr<'a> { fn all_fields(&self) -> Self::Fields<'_>; #[must_use] - fn memento_decl_generics(&self) -> TokenStream2 { + fn memento_decl_generics(&self) -> Option { let mut params = self .generic_params() .iter() @@ -29,11 +29,7 @@ pub(crate) trait CodegenItemIr<'a> { .map(GenericParamPlan::decl_param) .peekable(); - if params.peek().is_none() { - quote! {} - } else { - quote! { <#(#params),*> } - } + params.peek().is_some().then_some(quote! { <#(#params),*> }) } #[must_use] diff --git a/recallable-macro/src/context/internal/shared/generics.rs b/recallable-macro/src/context/internal/shared/generics.rs index a82a19b..532570f 100644 --- a/recallable-macro/src/context/internal/shared/generics.rs +++ b/recallable-macro/src/context/internal/shared/generics.rs @@ -402,7 +402,7 @@ mod tests { let ir = StructIr::analyze(&input).unwrap(); assert_eq!( - ir.memento_decl_generics().to_string(), + ir.memento_decl_generics().unwrap().to_string(), quote!().to_string() ); assert_eq!( @@ -441,7 +441,7 @@ mod tests { let ir = StructIr::analyze(&input).unwrap(); assert_eq!( - ir.memento_decl_generics().to_string(), + ir.memento_decl_generics().unwrap().to_string(), quote!().to_string() ); assert_eq!( From 29522caaf5fbc0c0ce8ef2187ff4b3bf25c82049 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sun, 29 Mar 2026 16:25:43 +0800 Subject: [PATCH 43/45] docs: resync public docs with current macro behavior --- GUIDE.md | 27 +++++++++++++++++---------- README.md | 24 ++++++++++++++++-------- recallable-macro/src/lib.rs | 13 ++++++++----- recallable/src/lib.rs | 31 ++++++++++++++++++------------- 4 files changed, 59 insertions(+), 36 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index 475a973..17ee69a 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -151,7 +151,7 @@ Use this when you want an owned memento value within the same process: - test fixtures - state handoff between components -Enable the `impl_from` feature to derive `From` for the generated +Enable the `impl_from` feature to derive `From` for the generated memento type. ## Installation and Features @@ -171,7 +171,7 @@ Feature flags: derive `serde::Deserialize`, and `#[recallable_model]` also injects source-side serde behavior. This feature remains compatible with `no_std` as long as your serde stack is configured for `no_std`. -- `impl_from`: generates `From` for the generated memento +- `impl_from`: generates `From` for the generated memento - `full`: convenience feature for `serde` + `impl_from` - `default-features = false`: disables recallable's default serde integration. It is useful for non-serde setups, but it is not what makes `no_std` @@ -258,7 +258,9 @@ With the default `serde` feature enabled, it also injects: Enum support is intentionally split: - assignment-only enums can use `#[recallable_model]` directly -- enums with skipped `PhantomData<_>` marker fields can also use it directly +- enums with `PhantomData<_>` marker fields can also use it directly; those + marker fields are auto-skipped, and explicit `#[recallable(skip)]` remains + accepted - enums with nested `#[recallable]` or other `#[recallable(skip)]` fields should derive `Recallable` and implement `Recall` or `TryRecall` manually @@ -397,8 +399,9 @@ apply it" instead of depending on widened field visibility. ### Skipped `PhantomData` and retained generics -Most skipped fields simply disappear from the generated memento. The tricky case -is when a skipped `PhantomData<_>` is the only field mentioning a generic that +Most skipped fields simply disappear from the generated memento. `PhantomData<_>` +fields are auto-skipped by the derive, and the tricky case is when such a field +is the only field mentioning a generic that still must remain part of the memento type. ```rust @@ -545,7 +548,7 @@ Every `Recall` type automatically implements `TryRecall` with ## In-memory snapshots with `impl_from` -Enable `impl_from` when you want a derived `From` implementation for +Enable `impl_from` when you want a derived `From` implementation for the generated memento. ```toml @@ -603,6 +606,9 @@ For `#[recallable]` fields, this also requires: That extra export-side bound is why `impl_from` is not enabled implicitly for all workflows. +With `impl_from`, both struct and enum `Recallable` derives can generate +`From` for the companion memento, as long as the generated bounds hold. + ## Manual trait implementations You do not need the macros to use the traits. @@ -684,7 +690,8 @@ owned type. That means: - skipped borrowed fields are allowed -- lifetime-only markers such as `PhantomData<&'a T>` are allowed +- `PhantomData<_>` fields are allowed because the derive auto-skips them; this + includes lifetime-bearing markers such as `PhantomData<&'a T>` - non-skipped borrowed state fields like `&'a str` are rejected ## Serialization guidance @@ -734,7 +741,7 @@ Generates: - the companion memento type - the `Recallable` implementation -- `From` for the memento when `impl_from` is enabled +- `From` for the memento when `impl_from` is enabled - enum-shaped mementos for enums, even when `Recall` must stay manual ### `#[derive(Recall)]` @@ -744,8 +751,8 @@ Generates the `Recall` implementation. Behavior: - struct fields are handled as before -- enum derives are supported only for assignment-only variants, plus skipped - `PhantomData<_>` marker fields +- enum derives are supported only for assignment-only variants, plus + `PhantomData<_>` marker fields that are auto-skipped by the derive - enums with nested `#[recallable]` or other skipped fields should derive `Recallable` and implement `Recall` or `TryRecall` manually diff --git a/README.md b/README.md index 9c19917..afac968 100644 --- a/README.md +++ b/README.md @@ -100,15 +100,17 @@ For `no_std + serde` deployments, prefer a `no_std`-compatible format such as For enums, `#[recallable_model]` is intentionally narrower than `#[derive(Recallable)]`: - assignment-only enums are supported directly -- enums with skipped `PhantomData<_>` marker fields are still supported directly +- enums with `PhantomData<_>` marker fields are still supported directly; those + marker fields are auto-skipped, and explicit `#[recallable(skip)]` remains + accepted - enums with nested `#[recallable]` or other `#[recallable(skip)]` fields should derive `Recallable` and implement `Recall` or `TryRecall` manually -Skipped `PhantomData<_>` can also affect generic retention on generated -mementos. When a skipped marker is the only field mentioning a generic that -still must remain part of the memento type, the derive keeps that generic alive -with an internal hidden marker. The user-facing examples for that corner case -live in the API docs and [GUIDE.md](GUIDE.md). +Auto-skipped `PhantomData<_>` can also affect generic retention on generated +mementos. When such a marker is the only field mentioning a generic that still +must remain part of the memento type, the derive keeps that generic alive with +an internal hidden marker. The user-facing examples for that corner case live +in the API docs and [GUIDE.md](GUIDE.md). ## Features @@ -116,7 +118,7 @@ live in the API docs and [GUIDE.md](GUIDE.md). `serde::Deserialize`, and `#[recallable_model]` also adds the source-side serde behavior described above. This feature remains compatible with `no_std` as long as your serde stack is configured for `no_std`. -- `impl_from`: generates `From` for `::Memento` +- `impl_from`: generates `From` for `::Memento` - `full`: convenience feature for `serde` + `impl_from` - `default-features = false`: disables recallable's default serde integration. It is useful for non-serde setups, but it is not what makes `no_std` possible. @@ -166,6 +168,9 @@ source struct's serialized shape already matches the generated memento shape. Enable `impl_from` when you want an owned memento inside the same process for checkpoint/rollback, undo stacks, or test fixtures. +With `impl_from`, both struct and enum `Recallable` derives can generate +`From` for the companion memento, as long as the generated bounds hold. + ```rust use recallable::{Recall, Recallable, recallable_model}; @@ -240,10 +245,13 @@ impl Recall for EngineState { - `#[derive(Recallable)]` supports enums under the normal field rules - `#[derive(Recall)]` and `#[recallable_model]` support enums only when every non-marker variant field is assignment-only -- Enums with skipped `PhantomData<_>` marker fields are still supported +- Enums with `PhantomData<_>` marker fields are still supported; those marker + fields are auto-skipped, and explicit `#[recallable(skip)]` remains accepted - Enums with nested `#[recallable]` or other `#[recallable(skip)]` fields should derive `Recallable` and implement `Recall` or `TryRecall` manually - Borrowed state fields are rejected unless they are skipped +- `PhantomData<_>` fields are auto-skipped by the derive, including + lifetime-bearing markers such as `PhantomData<&'a T>` - `#[recallable]` is path-only: it supports type parameters, path types, and associated types, but not tuple/reference/slice/function syntax directly - Serde attributes are not forwarded to the generated memento diff --git a/recallable-macro/src/lib.rs b/recallable-macro/src/lib.rs index 800f67d..2a1df0a 100644 --- a/recallable-macro/src/lib.rs +++ b/recallable-macro/src/lib.rs @@ -9,7 +9,9 @@ //! crate it also adds `serde::Serialize` and applies `#[serde(skip)]` to fields //! marked `#[recallable(skip)]`. Complex enums with nested `#[recallable]` //! fields or non-marker skipped fields should derive `Recallable` and -//! implement `Recall` or `TryRecall` manually. +//! implement `Recall` or `TryRecall` manually. `PhantomData<_>` fields are +//! auto-skipped by the derive, and explicit `#[recallable(skip)]` on them +//! remains accepted. //! //! - `#[derive(Recallable)]`: generates an internal companion memento type, exposes //! it as `::Memento`, and emits the `Recallable` impl; with the @@ -20,7 +22,7 @@ //! when every non-marker variant field is assignment-only. //! //! Feature flags are evaluated in the `recallable-macro` crate itself. See `context` -//! for details about the generated memento struct and trait implementations. +//! for details about the generated memento type and trait implementations. use proc_macro::TokenStream; @@ -43,7 +45,7 @@ mod model_macro; /// fields are rejected so the caller can keep `Recall` or `TryRecall` /// explicit. /// -/// This macro preserves the original struct shape and only mutates attributes. +/// This macro preserves the original item shape and only mutates attributes. /// /// **Attribute ordering:** This macro must appear *before* any attributes it needs /// to inspect. An attribute macro only receives attributes that follow it in source @@ -83,7 +85,7 @@ pub fn recallable_model(attr: TokenStream, item: TokenStream) -> TokenStream { /// The `Recallable` impl sets `type Memento` to that generated type and adds any required generic /// bounds. /// -/// The generated memento struct always derives `Clone`, `Debug`, and `PartialEq`. +/// The generated memento type always derives `Clone`, `Debug`, and `PartialEq`. /// When the `serde` feature is enabled, it also derives `serde::Deserialize`. /// All non-skipped field types must implement these derived traits. /// @@ -131,7 +133,8 @@ pub fn derive_recallable(input: TokenStream) -> TokenStream { /// For `#[recallable]` fields, replace/merge behavior comes from the field type's own /// `Recall` implementation. /// Enums are supported only when every non-marker variant field is -/// assignment-only. +/// assignment-only. `PhantomData<_>` marker fields are auto-skipped by the +/// derive, and explicit `#[recallable(skip)]` on them remains accepted. /// For supported enums, the generated implementation restores the target variant /// from the memento directly. pub fn derive_recall(input: TokenStream) -> TokenStream { diff --git a/recallable/src/lib.rs b/recallable/src/lib.rs index deecf94..a4160bb 100644 --- a/recallable/src/lib.rs +++ b/recallable/src/lib.rs @@ -29,16 +29,18 @@ extern crate self as recallable; /// Attribute macro that prepares a struct or assignment-only enum for the Memento pattern. /// /// Adds `#[derive(Recallable, Recall)]` automatically. When the `serde` feature is enabled, -/// also derives `serde::Serialize` on the struct and injects `#[serde(skip)]` on fields +/// also derives `serde::Serialize` on the source item and injects `#[serde(skip)]` on fields /// marked with `#[recallable(skip)]`. /// Complex enums with nested `#[recallable]` fields or non-marker /// `#[recallable(skip)]` fields should derive [`Recallable`] and implement /// [`Recall`] or [`TryRecall`] manually. Skipped `PhantomData<_>` marker fields -/// remain supported on assignment-only enums. +/// remain supported on assignment-only enums, and `PhantomData<_>` fields are +/// auto-skipped by the derive even without an explicit `#[recallable(skip)]`. /// /// Lifetime parameters are supported only when the generated memento can stay owned: -/// non-skipped fields may not borrow one of the struct's lifetimes. Skipped borrowed -/// fields and lifetime-only markers such as `PhantomData<&'a T>` are allowed. +/// non-skipped fields may not borrow one of the item's lifetimes. Skipped borrowed +/// fields are allowed, and `PhantomData<_>` fields are allowed because the derive +/// auto-skips them, including lifetime-bearing markers such as `PhantomData<&'a T>`. /// /// This example requires the `serde` feature. /// @@ -79,17 +81,17 @@ pub use recallable_macro::recallable_model; /// fields can still derive [`Recallable`] and provide manual [`Recall`] or /// [`TryRecall`] behavior. /// -/// The memento struct mirrors the original but replaces `#[recallable]`-annotated fields +/// The generated memento type mirrors the original item but replaces `#[recallable]`-annotated fields /// with their `::Memento` type and omits `#[recallable(skip)]` fields. -/// The generated companion type has the same visibility as the input struct. +/// The generated companion type has the same visibility as the input item. /// Its fields are always emitted without visibility modifiers, so they remain private to the /// containing module. This is intentional: mementos are meant to be created and consumed alongside -/// the companion struct, primarily via [`Recall::recall`] and [`TryRecall::try_recall`], with only +/// the companion item, primarily via [`Recall::recall`] and [`TryRecall::try_recall`], with only /// occasional same-file testing or debugging use. /// For container-like field types, this is whatever memento shape that field type chose; the macro /// does not special-case merge semantics. /// When the `impl_from` feature is enabled, `#[derive(Recallable)]` also generates -/// `From` for the memento type, which requires +/// `From` for the memento type, which requires /// `::Memento: From` for each `#[recallable]` field. /// /// When a skipped field would otherwise remove the last field-level mention of a @@ -120,8 +122,9 @@ pub use recallable_macro::recallable_model; /// retained generic `T` depends on it through `T: From`. /// /// Lifetime parameters are supported only when the generated memento can stay owned: -/// non-skipped fields may not borrow one of the struct's lifetimes. Skipped borrowed -/// fields and lifetime-only markers such as `PhantomData<&'a T>` are allowed. +/// non-skipped fields may not borrow one of the item's lifetimes. Skipped borrowed +/// fields are allowed, and `PhantomData<_>` fields are allowed because the derive +/// auto-skips them, including lifetime-bearing markers such as `PhantomData<&'a T>`. /// /// This example requires the `serde` feature. /// @@ -162,15 +165,17 @@ pub use recallable_macro::Recallable; /// For `#[recallable]` fields, replace/merge behavior comes from the field type's own /// [`Recall`] implementation. /// Enums are supported only when every non-marker variant field is -/// assignment-only. Skipped `PhantomData<_>` marker fields are allowed. +/// assignment-only. `PhantomData<_>` marker fields are allowed because the derive +/// auto-skips them, and explicit `#[recallable(skip)]` on such fields remains accepted. /// Enums with nested `#[recallable]` fields or other skipped fields should /// derive [`Recallable`] and implement [`Recall`] or [`TryRecall`] manually. /// For supported enums, the derived implementation restores the target variant /// from the memento directly. /// /// Lifetime parameters are supported only when the generated memento can stay owned: -/// non-skipped fields may not borrow one of the struct's lifetimes. Skipped borrowed -/// fields and lifetime-only markers such as `PhantomData<&'a T>` are allowed. +/// non-skipped fields may not borrow one of the item's lifetimes. Skipped borrowed +/// fields are allowed, and `PhantomData<_>` fields are allowed because the derive +/// auto-skips them, including lifetime-bearing markers such as `PhantomData<&'a T>`. /// /// This example requires the `serde` feature. /// From ec2fd515b19ac797420643d79414a43a75489230 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sun, 29 Mar 2026 17:21:13 +0800 Subject: [PATCH 44/45] docs: clarify persistence and enum derive workflows --- GUIDE.md | 71 ++++++++++++++++++++++++++----------- README.md | 17 ++++++--- recallable-macro/src/lib.rs | 23 ++++++------ recallable/src/lib.rs | 44 +++++++++++++++++++---- 4 files changed, 113 insertions(+), 42 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index 17ee69a..1c7e06c 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -259,26 +259,47 @@ Enum support is intentionally split: - assignment-only enums can use `#[recallable_model]` directly - enums with `PhantomData<_>` marker fields can also use it directly; those - marker fields are auto-skipped, and explicit `#[recallable(skip)]` remains - accepted + marker fields are the only skipped-field exception the derive handles + automatically - enums with nested `#[recallable]` or other `#[recallable(skip)]` fields should derive `Recallable` and implement `Recall` or `TryRecall` manually -Example: +Concrete example: ```rust -use recallable::recallable_model; +use recallable::{Recall, Recallable, recallable_model}; #[recallable_model] -#[derive(Clone, Debug, PartialEq, Eq)] -struct UserProfile { - id: u64, - display_name: String, - #[recallable(skip)] - cache_key: String, +#[derive(Clone, Debug, PartialEq)] +enum ConnectionState { + Disconnected, + Connected { session_id: u64, label: String }, +} + +fn main() { + let mut state = ConnectionState::Disconnected; + let memento: ::Memento = + serde_json::from_str(r#"{"Connected":{"session_id":7,"label":"live"}}"#).unwrap(); + + state.recall(memento); + + assert_eq!( + state, + ConnectionState::Connected { + session_id: 7, + label: "live".into(), + } + ); } ``` +That example is the normal supported enum flow: + +- assignment-only variants work directly with `#[recallable_model]` +- nested `#[recallable]` enum fields still need manual `Recall` or `TryRecall` +- `PhantomData<_>` markers are the only skipped-field exception handled + automatically + ### Attribute ordering requirement `#[recallable_model]` must appear before the attributes it needs to inspect. @@ -363,18 +384,21 @@ struct SessionState { Important distinction: - `#[recallable_model]` mutates source-side serde behavior for the common case -- direct `#[derive(Recallable, Recall)]` does not +- direct `#[derive(Recallable, Recall)]` does not change the source item for + you Direct derives are also the split point for complex enums: - `#[derive(Recallable)]` supports enum-shaped mementos under the normal field rules -- `#[derive(Recall)]` works only for assignment-only enums -- enums with nested `#[recallable]` or skipped variant fields should derive +- `#[derive(Recall)]` works only for assignment-only enums, plus + `PhantomData<_>` marker fields that are auto-skipped +- enums with nested `#[recallable]` or other skipped variant fields should derive `Recallable` and implement `Recall` or `TryRecall` manually If you use direct derives and want the source struct to serialize in the same shape as the generated memento, you must add the serde derives and -`#[serde(skip)]` attributes yourself. +`#[serde(skip)]` attributes yourself. Use `#[recallable_model]` when you want +those shapes to align automatically. ## Skipped fields and memento visibility @@ -668,7 +692,7 @@ The derive macros support more than just simple named structs. - unit structs - enums for `Recallable` - enums for `Recall` and `recallable_model` only when every variant field is - assignment-only + assignment-only, plus `PhantomData<_>` markers that the derive auto-skips - complex enums should derive `Recallable` only and supply manual `Recall` or `TryRecall` @@ -697,21 +721,28 @@ That means: ## Serialization guidance Recallable is codec-agnostic. -It only cares that your chosen format can: +For persisted state, the usual flow is: + +1. serialize the source value with your chosen Serde-compatible format +2. deserialize into the memento type you apply +3. apply the memento -- serialize the source-side state you emit -- deserialize into the memento type you apply +This applies to any Serde-compatible format, not just the `serde_json` and +`postcard` examples in this repository. Compatibility is a property of the +chosen format and its deserializer behavior, not of Recallable itself. Practical guidance: -- use `serde_json` for readable examples, tests, and debugging -- use `postcard` or another binary format when you care about size or `no_std` +- use a human-readable format when you want easier inspection in docs or tests +- use a binary format when you care about size or `no_std`, but treat + fixed-layout codecs as schema-sensitive unless you version them - use `#[recallable_model]` when you want the source struct's serialized shape to align automatically with the generated memento - use direct derives when you want explicit source-side serde control Important asymmetry: +- generated mementos are the deserialization targets you apply - generated mementos derive `Deserialize` - generated mementos do not derive `Serialize` diff --git a/README.md b/README.md index afac968..1247437 100644 --- a/README.md +++ b/README.md @@ -152,16 +152,23 @@ recallable = { version = "0.2", features = ["impl_from"] } ### Persistence and restore -This is the default path when state crosses process boundaries or is written to -disk. It works in both `std` and `no_std` environments; only the serialization -format and serde configuration differ. +This is the normal persistence path when state crosses process boundaries or +is written to disk. It works in both `std` and `no_std` environments; only the +serialization format and serde configuration differ. -1. Serialize the source struct. +1. Serialize the source value with your chosen Serde-compatible format. 2. Deserialize into `::Memento`. 3. Apply the memento with `recall` or `try_recall`. +Generated mementos are deserialization targets, not the default write-side +export format. If you want a stable custom wire format or different +source-side serde behavior, define the memento manually. + This flow is especially convenient with `#[recallable_model]`, because the -source struct's serialized shape already matches the generated memento shape. +source struct's serialized shape already matches the generated memento shape +automatically. Any Serde-compatible format can be used here; `serde_json` and +`postcard` show up in this repository's tests and examples only as concrete +reference points. ### In-memory snapshots diff --git a/recallable-macro/src/lib.rs b/recallable-macro/src/lib.rs index 2a1df0a..068538a 100644 --- a/recallable-macro/src/lib.rs +++ b/recallable-macro/src/lib.rs @@ -7,15 +7,17 @@ //! - `#[recallable_model]`: injects `Recallable`/`Recall` derives for structs and //! assignment-only enums; with the `serde` Cargo feature enabled for this macro //! crate it also adds `serde::Serialize` and applies `#[serde(skip)]` to fields -//! marked `#[recallable(skip)]`. Complex enums with nested `#[recallable]` -//! fields or non-marker skipped fields should derive `Recallable` and -//! implement `Recall` or `TryRecall` manually. `PhantomData<_>` fields are -//! auto-skipped by the derive, and explicit `#[recallable(skip)]` on them -//! remains accepted. +//! marked `#[recallable(skip)]`, keeping the source-side serde shape aligned +//! with the generated memento in the common path. Complex enums with nested +//! `#[recallable]` fields or non-marker skipped fields should derive +//! `Recallable` and implement `Recall` or `TryRecall` manually. `PhantomData<_>` +//! fields are auto-skipped by the derive, and explicit `#[recallable(skip)]` on +//! them remains accepted. //! //! - `#[derive(Recallable)]`: generates an internal companion memento type, exposes //! it as `::Memento`, and emits the `Recallable` impl; with the -//! `impl_from` Cargo feature it also generates `From` for the memento type. +//! `impl_from` Cargo feature it also generates `From` for the memento type +//! for in-memory snapshot workflows. //! //! - `#[derive(Recall)]`: generates the `Recall` implementation, recursively //! recalls struct fields annotated with `#[recallable]`, and supports enums only @@ -39,7 +41,7 @@ mod model_macro; /// - When the `serde` feature is enabled for the macro crate, it also adds /// `#[derive(serde::Serialize)]`. /// - For fields annotated with `#[recallable(skip)]`, it injects `#[serde(skip)]` -/// to keep serde output aligned with recall behavior. +/// to keep source-side serde aligned with the generated memento shape. /// - This attribute itself takes no arguments. /// - Complex enums with nested `#[recallable]` fields or non-marker skipped /// fields are rejected so the caller can keep `Recall` or `TryRecall` @@ -78,7 +80,7 @@ pub fn recallable_model(attr: TokenStream, item: TokenStream) -> TokenStream { /// semantics; it uses whatever memento shape the field type defines. /// /// The companion type itself is generated as an internal implementation detail. The supported -/// way to name it is `::Memento`. It is intended to be produced and consumed +/// way to name it is `::Memento`. It is intended to be deserialized and applied /// alongside the source item, primarily through `Recall::recall`/`TryRecall::try_recall`, not as /// a field-inspection surface with widened visibility. /// @@ -95,8 +97,9 @@ pub fn recallable_model(attr: TokenStream, item: TokenStream) -> TokenStream { /// still derived on the memento even with this attribute. /// /// When the `impl_from` feature is enabled for the macro crate, a -/// `From` implementation is also generated for the memento type. For `#[recallable]` -/// fields, that additionally requires `::Memento: From`. +/// `From` implementation is also generated for the memento type. This is useful for +/// in-memory snapshot flows. For `#[recallable]` fields, that additionally requires +/// `::Memento: From`. #[proc_macro_derive(Recallable, attributes(recallable))] pub fn derive_recallable(input: TokenStream) -> TokenStream { let input: DeriveInput = parse_macro_input!(input as DeriveInput); diff --git a/recallable/src/lib.rs b/recallable/src/lib.rs index a4160bb..4783730 100644 --- a/recallable/src/lib.rs +++ b/recallable/src/lib.rs @@ -10,6 +10,12 @@ //! turns on macro-generated serde support, and it remains usable in `no_std` environments when //! your serde stack is configured without `std`. //! +//! Persisted-state compatibility depends on the chosen codec or format: serialize the source +//! value, deserialize into the generated memento, then apply it. Generated mementos are +//! apply-side/deserialization targets, not the default write-side/export format. Any +//! Serde-compatible format can be used if it can target the generated memento, and fixed-layout +//! binary codecs should be treated as schema-sensitive unless you version them explicitly. +//! //! ## Motivation //! //! Many systems receive incremental updates where only a subset of fields change or can be @@ -30,7 +36,8 @@ extern crate self as recallable; /// /// Adds `#[derive(Recallable, Recall)]` automatically. When the `serde` feature is enabled, /// also derives `serde::Serialize` on the source item and injects `#[serde(skip)]` on fields -/// marked with `#[recallable(skip)]`. +/// marked with `#[recallable(skip)]`, so the source-side serde shape stays aligned with the +/// generated memento in the common path. /// Complex enums with nested `#[recallable]` fields or non-marker /// `#[recallable(skip)]` fields should derive [`Recallable`] and implement /// [`Recall`] or [`TryRecall`] manually. Skipped `PhantomData<_>` marker fields @@ -69,6 +76,28 @@ extern crate self as recallable; /// // on_change is skipped — unchanged by recall /// # } /// ``` +/// +/// Enum example: +/// +/// ```rust +/// # #[cfg(feature = "serde")] +/// # { +/// use recallable::{Recall, Recallable, recallable_model}; +/// +/// #[recallable_model] +/// #[derive(Clone, Debug, PartialEq)] +/// enum Mode { +/// Off, +/// On { level: u8 }, +/// } +/// +/// let mut mode = Mode::Off; +/// let memento: ::Memento = +/// serde_json::from_str(r#"{"On":{"level":3}}"#).unwrap(); +/// mode.recall(memento); +/// assert_eq!(mode, Mode::On { level: 3 }); +/// # } +/// ``` pub use recallable_macro::recallable_model; /// Derive macro that generates a companion memento type and the [`Recallable`] trait impl. @@ -81,17 +110,18 @@ pub use recallable_macro::recallable_model; /// fields can still derive [`Recallable`] and provide manual [`Recall`] or /// [`TryRecall`] behavior. /// -/// The generated memento type mirrors the original item but replaces `#[recallable]`-annotated fields -/// with their `::Memento` type and omits `#[recallable(skip)]` fields. +/// The generated memento type mirrors the original item but replaces `#[recallable]`-annotated +/// fields with their `::Memento` type and omits +/// `#[recallable(skip)]` fields. /// The generated companion type has the same visibility as the input item. /// Its fields are always emitted without visibility modifiers, so they remain private to the -/// containing module. This is intentional: mementos are meant to be created and consumed alongside -/// the companion item, primarily via [`Recall::recall`] and [`TryRecall::try_recall`], with only -/// occasional same-file testing or debugging use. +/// containing module. This is intentional: mementos are meant to be deserialized and applied +/// alongside the source item, primarily via [`Recall::recall`] and [`TryRecall::try_recall`], with +/// only occasional same-file testing or debugging use. /// For container-like field types, this is whatever memento shape that field type chose; the macro /// does not special-case merge semantics. /// When the `impl_from` feature is enabled, `#[derive(Recallable)]` also generates -/// `From` for the memento type, which requires +/// `From` for the memento type, which is useful for in-memory snapshot flows and requires /// `::Memento: From` for each `#[recallable]` field. /// /// When a skipped field would otherwise remove the last field-level mention of a From 2325cb361c67baf6aa6345d41202d46cb95cf679 Mon Sep 17 00:00:00 2001 From: "Lan, Jian" Date: Sun, 29 Mar 2026 18:45:37 +0800 Subject: [PATCH 45/45] docs: add macro crate internals guide Comprehensive developer guide covering the recallable-macro architecture: three-phase pipeline, IR layer design, generic retention algorithm, trait bound inference, codegen modules, and full module map. --- recallable-macro/MACRO_INTERNALS.md | 570 ++++++++++++++++++++++++++++ 1 file changed, 570 insertions(+) create mode 100644 recallable-macro/MACRO_INTERNALS.md diff --git a/recallable-macro/MACRO_INTERNALS.md b/recallable-macro/MACRO_INTERNALS.md new file mode 100644 index 0000000..ddabdb9 --- /dev/null +++ b/recallable-macro/MACRO_INTERNALS.md @@ -0,0 +1,570 @@ +# `recallable-macro` Internals + +Developer guide to the procedural macro crate that powers `recallable`. +This document explains the architecture, data flow, and design decisions +so you can navigate and extend the codebase confidently. + +--- + +## Table of Contents + +- [High-Level Architecture](#high-level-architecture) +- [Entry Points (`lib.rs`)](#entry-points) +- [Attribute Macro (`model_macro.rs`)](#attribute-macro) +- [The `context` Facade (`context.rs`)](#the-context-facade) +- [Semantic Analysis — the IR Layer](#semantic-analysis--the-ir-layer) + - [Shared Infrastructure (`internal/shared/`)](#shared-infrastructure) + - [Struct IR (`internal/structs/`)](#struct-ir) + - [Enum IR (`internal/enums/`)](#enum-ir) +- [Code Generation](#code-generation) + - [Memento Type (`memento/`)](#memento-type) + - [Recallable Impl (`recallable_impl/`)](#recallable-impl) + - [Recall Impl (`recall_impl/`)](#recall-impl) + - [From Impl (`from_impl/`)](#from-impl) +- [Generic Retention System](#generic-retention-system) +- [Trait Bound Inference](#trait-bound-inference) +- [Feature Flags](#feature-flags) +- [Design Principles](#design-principles) + +--- + +## High-Level Architecture + +The crate follows a three-phase pipeline: + +```text +DeriveInput ──► Semantic Analysis (IR) ──► Code Generation (TokenStream) + │ │ + StructIr / EnumIr memento struct/enum + Recallable impl + Recall impl + From impl (optional) +``` + +1. **Parse** — `syn` parses the token stream into a `DeriveInput`. +2. **Analyze** — The IR layer (`StructIr` or `EnumIr`) validates the input and + builds a rich intermediate representation: field strategies, generic retention + plans, where-clause filtering, and marker param detection. +3. **Generate** — Codegen functions consume `&StructIr`/`&EnumIr` + `&CodegenEnv` + and emit `TokenStream2` fragments that `lib.rs` assembles into the final output. + +The IR is built once and shared across all codegen passes. This avoids redundant +parsing and keeps each codegen module focused on token assembly. + +--- + +## Entry Points + +**File:** `src/lib.rs` + +Three proc-macro entry points, each a thin shell: + +| Macro | Function | What it does | +|-------------------------|-----------------------|--------------------------------------------------------------------------------------| +| `#[recallable_model]` | `recallable_model()` | Attribute macro — delegates to `model_macro::expand` | +| `#[derive(Recallable)]` | `derive_recallable()` | Analyzes input → generates memento type + `Recallable` impl (+ optional `From` impl) | +| `#[derive(Recall)]` | `derive_recall()` | Analyzes input → generates `Recall` impl | + +All generated code is wrapped in `const _: () = { ... };` blocks with +`#[automatically_derived]` to avoid polluting the item namespace and to signal +to lints that the code is machine-generated. + +--- + +## Attribute Macro + +**File:** `src/model_macro.rs` + +`#[recallable_model]` is a convenience attribute that: + +1. Validates it receives no arguments. +2. Parses the item as a struct or enum via `ModelItem`. +3. Runs `context::analyze_model_input()` to reject unsupported enum shapes. +4. Checks for duplicate `Serialize` derives (serde feature only). +5. Injects `#[derive(Recallable, Recall)]` (and `serde::Serialize` when serde is enabled). +6. Adds `#[serde(skip)]` to fields marked `#[recallable(skip)]`. + +`ModelItem` is a local enum over `ItemStruct` / `ItemEnum` that provides a +uniform interface for attribute manipulation (`add_derives`, `add_serde_skip_attrs`, +`parse` -> `DeriveInput`). + +The duplicate-Serialize check scans `#[derive(...)]` attributes for paths ending +in `Serialize`, matching `serde::Serialize`, `serde_derive::Serialize`, and bare +`Serialize`. This catches the most common user mistake since `#[recallable_model]` +auto-adds `Serialize` when serde is enabled. + +> **Attribute ordering:** `#[recallable_model]` must appear *before* any attributes +> it needs to inspect. An attribute macro's `item` token stream only contains +> attributes that follow it in source order. + +--- + +## The `context` Facade + +**File:** `src/context.rs` + +A thin coordination layer that: + +- Re-exports IR types and codegen functions for `lib.rs` to consume. +- Defines feature-flag constants: `SERDE_ENABLED` and `IMPL_FROM_ENABLED`. +- Provides three analysis entry points: + - `analyze_item()` — builds `ItemIr` (struct or enum). + - `analyze_recall_input()` — same, but also validates enum recall-derive eligibility. + - `analyze_model_input()` — validates enum model-attribute eligibility. +- Delegates codegen to submodules: `memento`, `recallable_impl`, `recall_impl`, `from_impl`. + +Each codegen submodule has a top-level dispatcher that matches on `ItemIr::Struct` / +`ItemIr::Enum` and calls the appropriate struct- or enum-specific generator. + +--- + +## Semantic Analysis — the IR Layer + +### Shared Infrastructure + +**Directory:** `src/context/internal/shared/` + +This is the foundation that both `StructIr` and `EnumIr` build on. + +#### `item.rs` — `ItemIr` enum + +```rust +enum ItemIr<'a> { + Struct(StructIr<'a>), + Enum(EnumIr<'a>), +} +``` + +The top-level dispatch type. `ItemIr::analyze()` routes to the appropriate IR +constructor based on `syn::Data`. Also handles item-level attribute parsing +(`skip_memento_default_derives`). + +#### `fields.rs` — Field analysis + +Core types: + +- **`FieldStrategy`** — `Skip | StoreAsSelf | StoreAsMemento`. Determines how each + field participates in the memento. +- **`FieldIr`** — Per-field IR: source reference, memento index (position in the + generated memento after skipped fields are removed), member accessor, type, strategy. +- **`FieldMember`** — `Named(&Ident) | Unnamed(Index)`. Implements `ToTokens` for + use in both pattern and expression positions. + +Field classification logic: + +1. Parse `#[recallable]` and `#[recallable(skip)]` attributes → `FieldBehavior`. +2. Conflicting `#[recallable]` + `#[recallable(skip)]` on the same field → compile error. +3. `PhantomData` fields → auto-skipped (regardless of attributes). +4. `#[recallable]` on a bare type param `T` → `StoreAsMemento` with the param + flagged as `RetainedAsRecallable`. +5. `#[recallable]` on a complex path type like `Option` → `StoreAsMemento` + with whole-type bounds (no bare-param shorthand). +6. Everything else → `StoreAsSelf`. + +`collect_field_irs()` processes all fields and returns both the field IR vec and +a `GenericUsage` summary (which generic params are retained, which are recallable). + +#### `generics.rs` — Generic retention + +The most complex piece of the analysis. See [Generic Retention System](#generic-retention-system) +for the full algorithm. + +Key types: + +- **`GenericParamRetention`** — `Dropped | Retained | RetainedAsRecallable`. +- **`GenericParamPlan`** — Pairs a `&GenericParam` with its retention decision. + Provides `decl_param()`, `type_arg()`, `recallable_ident()`. +- **`GenericParamLookup`** — Index from ident → param position, split by kind + (type, const, lifetime). Used during field analysis and dependency collection. +- **`GenericUsage`** — Accumulated from field analysis: which param indices are + retained, which are recallable type params. +- **`GenericDependencyCollector`** — A `syn::Visit` walker that collects which + generic params a given type or where-predicate depends on. + +#### `lifetime.rs` — Borrowed-field rejection + +- `collect_item_lifetimes()` — extracts lifetime param idents from generics. +- `validate_no_borrowed_fields()` — rejects non-`PhantomData`, non-skipped fields + that reference struct lifetimes. Uses `LifetimeUsageChecker` (a `syn::Visit` walker). +- `is_phantom_data()` — heuristic path match: any path ending in `PhantomData`. + +#### `bounds.rs` — Trait bound assembly + +- **`MementoTraitSpec`** — Encapsulates the derive/bound configuration for the + memento type. Controlled by two flags: `serde_enabled` and `derive_off` + (from `#[recallable(skip_memento_default_derives)]`). + - `derive_attr()` → the `#[derive(...)]` attribute for the memento. + - `common_bound_tokens()` → `Clone + Debug + PartialEq` (or empty if derive_off). + - `serde_nested_bound()` → `DeserializeOwned` (or None if serde disabled). +- `collect_shared_memento_bounds()` — Builds where-predicates for memento type + definitions: `T::Memento: Clone + Debug + PartialEq` and whole-type equivalents. +- `collect_recall_like_bounds()` — Builds where-predicates for trait impls + (`Recallable`, `Recall`): direct bounds on recallable params + shared memento bounds. + +#### `codegen.rs` — Shared codegen helpers + +- **`CodegenItemIr`** trait — The polymorphism layer. Both `StructIr` and `EnumIr` + implement this trait, providing uniform access to generics, fields, memento name, + marker params, etc. This lets bound-collection and field-token-building code work + generically across item kinds. +- `build_memento_field_ty()` — Produces the memento field type: `T::Memento` for + bare type params, `::Memento` for complex paths, + or the original type for `StoreAsSelf`. +- `build_memento_field_tokens()` — Wraps the type with the field name for named + fields, or emits just the type for tuple fields. +- `build_from_value_expr()` — Wraps an expression in `From::from()` for + `StoreAsMemento` fields, passes through for `StoreAsSelf`. + +#### `env.rs` — `CodegenEnv` + +```rust +struct CodegenEnv { + recallable_trait: TokenStream2, // e.g. ::recallable::Recallable + recall_trait: TokenStream2, // e.g. ::recallable::Recall +} +``` + +Resolved once per macro invocation via `crate_path()`. Holds the fully-qualified +trait paths used throughout codegen. + +#### `util.rs` — Crate path resolution + +`crate_path()` uses `proc-macro-crate` to resolve the `recallable` crate name, +handling re-exports and the `Itself` case (when the macro is invoked from within +the `recallable` crate itself). + +### Struct IR + +**Directory:** `src/context/internal/structs/` + +#### `ir.rs` — `StructIr` + +The struct-specific IR. Built by `StructIr::analyze()`: + +1. Collect item lifetimes, validate no borrowed fields. +2. Determine `StructShape` (`Named | Unnamed | Unit`). +3. Build `GenericParamLookup`, collect field IRs + generic usage. +4. Run `plan_memento_generics()` → generic param plans + filtered where-clause. +5. Detect marker param indices (retained params not referenced by any kept field). +6. Parse `skip_memento_default_derives`. + +Key accessors: `struct_type()`, `memento_name()`, `visibility()`, `shape()`, +`impl_generics()`, `memento_fields()` (iterator over non-skipped fields), +`has_synthetic_marker()`. + +Implements `CodegenItemIr` for polymorphic codegen. + +#### `bounds.rs` — Struct-specific bound wrappers + +Thin wrappers around the shared `collect_shared_memento_bounds` and +`collect_recall_like_bounds`, automatically passing the struct's `MementoTraitSpec`. + +### Enum IR + +**Directory:** `src/context/internal/enums/` + +#### `ir.rs` — `EnumIr` and `VariantIr` + +The enum-specific IR. `EnumIr::analyze()` follows the same pattern as `StructIr` +but processes variants: + +1. Each variant's fields are analyzed independently via `collect_field_irs()`. +2. Variants are classified by `VariantShape` (`Named | Unnamed | Unit`). +3. Generic usage is merged across all variants. + +**`VariantIr`** holds per-variant data: name, shape, field IRs. Provides +`kept_fields()` (non-skipped) and `kept_bindings()` (binding idents for pattern matching). + +**Enum restrictions:** + +- `ensure_recall_derive_allowed()` — Rejects enums with `#[recallable]` fields or + non-`PhantomData` skipped fields. These "complex" enums must implement `Recall` + or `TryRecall` manually. +- `ensure_model_derive_allowed()` — Same restriction for `#[recallable_model]`. +- `supports_derived_recall()` — Returns whether the restore helper should be generated. + +`build_binding_ident()` creates binding names for pattern matching: named fields +keep their ident, unnamed fields get `__recallable_field_{index}`. + +Implements `CodegenItemIr` with `all_fields()` flat-mapping across all variants. + +#### `bounds.rs` — Enum-specific bound wrappers + +Same pattern as struct bounds — thin wrappers passing the enum's `MementoTraitSpec`. + +--- + +## Code Generation + +Each codegen module has a top-level dispatcher in its `mod.rs` that matches on +`ItemIr` and delegates to struct- or enum-specific generators. + +### Memento Type + +**Directory:** `src/context/memento/` + +Generates the companion memento type that mirrors the source item. + +**Structs** (`structs.rs`): + +- Emits `#[derive(Clone, Debug, PartialEq)]` (+ `Deserialize` with serde). +- Mirrors the struct shape (named/unnamed/unit). +- Field types: original type for `StoreAsSelf`, `T::Memento` or + `::Memento` for `StoreAsMemento`. +- All fields are private (no visibility modifiers). +- Appends a synthetic `_recallable_marker: PhantomData<(...)>` field when + retained generic params aren't referenced by any kept field. +- Where-clause includes `Recallable` bounds + memento trait bounds. + +**Enums** (`enums.rs`): + +- Same derive/visibility/bound strategy as structs. +- Each variant mirrors the source variant shape, with skipped fields removed. +- Variants that lose all fields become unit variants. +- Synthetic marker uses a hidden `__RecallableMarker(PhantomData<(...)>)` variant + with `#[serde(skip)]` when serde is enabled. + +### Recallable Impl + +**Directory:** `src/context/recallable_impl/` + +Generates `impl Recallable for Type { type Memento = TypeMemento; }`. + +**Structs** (`structs.rs`): Straightforward — emits the impl with appropriate +generics and where-clause. + +**Enums** (`enums.rs`): Additionally generates a private +`__recallable_restore_from_memento()` helper method on the enum type (when the +enum supports derived recall). This helper contains the `match` expression that +reconstructs enum values from memento variants. It's emitted alongside the +`Recallable` impl so that `Recall` can call it. + +The restore helper: + +- Matches each memento variant and reconstructs the corresponding source variant. +- `StoreAsSelf` fields pass through directly. +- Skipped `PhantomData` fields are reconstructed as `PhantomData`. +- The `__RecallableMarker` variant arm is `unreachable!()`. + +### Recall Impl + +**Directory:** `src/context/recall_impl/` + +Generates `impl Recall for Type { fn recall(&mut self, memento) { ... } }`. + +**Structs** (`structs.rs`): + +- For each non-skipped field: + - `StoreAsSelf` → `self.field = memento.field;` + - `StoreAsMemento` → `Recall::recall(&mut self.field, memento.field);` +- Memento parameter is named `_memento` when there are no fields to recall. +- The `recall` method is `#[inline]`. + +**Enums** (`enums.rs`): + +- Delegates to `*self = Self::__recallable_restore_from_memento(memento);` +- The actual reconstruction logic lives in the restore helper generated by + `recallable_impl`. + +### From Impl + +**Directory:** `src/context/from_impl/` + +Generates `impl From for TypeMemento` (behind the `impl_from` feature). + +**Structs** (`structs.rs`): + +- Named: `Self { field: value.field, ... }` +- Unnamed: `Self(value.0, ...)` +- Unit: `Self` +- `StoreAsMemento` fields wrapped in `From::from()`. +- Synthetic marker initialized as `PhantomData`. + +**Enums** (`enums.rs`): + +- Match on source enum, construct corresponding memento variant. +- Binding idents used for destructuring and reconstruction. +- Skipped fields matched as `_` in patterns. + +--- + +## Generic Retention System + +The generic retention algorithm determines which generic parameters from the +source type appear on the generated memento. This is the most intricate part +of the analysis. + +### Algorithm + +1. **Field analysis** (`collect_field_irs`) records which generic params each + non-skipped field depends on → `GenericUsage.retained`. + +2. **Fixed-point dependency closure** (`plan_memento_generics`): + - Start with the set of params directly used by kept fields. + - Scan where-clause predicates: if a predicate depends on any retained param, + mark all params it references as retained too. + - Repeat until no new params are added (fixed-point). + - This ensures transitive dependencies are captured. For example, if `T: Into` + and `T` is retained, then `U` must also be retained. + +3. **Where-clause filtering**: Only predicates that depend on at least one retained + param are kept on the memento's where-clause. Predicates involving only dropped + params are removed. + +4. **Retention classification**: + - `Dropped` — param not used by any kept field (directly or transitively). + - `Retained` — param used by a `StoreAsSelf` field. + - `RetainedAsRecallable` — type param used by a `StoreAsMemento` field + (gets `Recallable` bounds). + +5. **Marker params** (`collect_marker_param_indices`): Retained params that aren't + referenced by any kept field's type need a `PhantomData` marker to satisfy + Rust's "must use all generic params" rule. This happens when a param is only + referenced through where-clause dependencies. + +### Marker field generation + +- **Structs**: A `_recallable_marker: PhantomData<(T, U, ...)>` field is appended. + For named structs it's a named field; for tuple structs it's a positional field. +- **Enums**: A hidden `__RecallableMarker(PhantomData<(T, U, ...)>)` variant is added, + with `#[doc(hidden)]` and `#[serde(skip)]`. +- Const generic params in the marker use a helper type alias + `type __RecallableConstMarker = [(); N];` to embed them in `PhantomData`. + +--- + +## Trait Bound Inference + +The macro automatically infers trait bounds for generic parameters. Bounds are +assembled in layers: + +### For the memento type definition + +```rust +T: Recallable // bare recallable params +T::Memento: Clone + Debug + PartialEq // common trait bounds (unless derive_off) +T::Memento: DeserializeOwned // serde feature only +ComplexType: Recallable // whole-type recallable bounds +::Memento: ... // whole-type memento bounds +``` + +### For `Recallable` / `Recall` impls + +Same structure, but the direct bound varies: + +- `Recallable` impl uses `T: Recallable` as the direct bound. +- `Recall` impl uses `T: Recall` as the direct bound. + +### For `From` impl + +```rust +T: Recallable +T::Memento: From // conversion bound +// + shared memento bounds +// + whole-type From bounds +``` + +The `collect_recall_like_bounds()` function in `shared/bounds.rs` is the +workhorse — it's parameterized by the "direct bound" trait and assembles +the full predicate list. + +--- + +## Feature Flags + +Feature flags are evaluated at compile time of the macro crate itself (not the +downstream user's crate): + +```rust +// context.rs +pub(super) const SERDE_ENABLED: bool = cfg!(feature = "serde"); +pub(super) const IMPL_FROM_ENABLED: bool = cfg!(feature = "impl_from"); +``` + +These are module-level constants, not part of `CodegenEnv`. They gate: + +| Flag | Effect | +|-------------|-------------------------------------------------------------------------------| +| `serde` | 1. Memento derives `Deserialize`; | +| | 2. `#[recallable_model]` adds `Serialize`; `#[serde(skip)]` on marker fields; | +| | 3. `DeserializeOwned` bounds on nested mementos | +|-------------|-------------------------------------------------------------------------------| +| `impl_from` | `From` impl generated for memento types | + +--- + +## Design Principles + +1. **Analyze once, generate many.** The IR is the single source of truth. Each + codegen module is a pure function from `(&IR, &CodegenEnv) -> TokenStream`. + +2. **Polymorphism via `CodegenItemIr`.** Shared codegen logic (generics, bounds, + field tokens) works across structs and enums through a trait, avoiding + duplication while keeping item-specific code in dedicated modules. + +3. **Dependency-closed generics.** The fixed-point loop ensures the memento's + generic parameter set is self-consistent — no dangling bounds or missing params. + +4. **Opaque mementos.** Generated memento fields are always private. Mementos are + state tokens, not an inspection surface. + +5. **Heuristic type matching.** The macro can't resolve types, so it uses path-based + heuristics (e.g., `is_phantom_data` matches any path ending in `PhantomData`, + `is_generic_type_param` checks single-segment paths against known type params). + +6. **Graceful enum restrictions.** Complex enums (with `#[recallable]` fields or + non-phantom skipped fields) are rejected with a clear error message directing + users to manual `Recall`/`TryRecall` implementations. Simple "assignment-only" + enums get full derive support. + +7. **Hygienic output.** All generated code lives in `const _: () = { ... }` blocks, + uses fully-qualified paths (`::core::...`, `::serde::...`), and is marked + `#[automatically_derived]`. + +--- + +## Module Map + +```text +recallable-macro/src/ +├── lib.rs # proc-macro entry points +├── model_macro.rs # #[recallable_model] expansion +├── context.rs # facade: re-exports, feature flags, analysis entry points +└── context/ + ├── memento.rs # ItemIr dispatcher + ├── memento/ + │ ├── structs.rs # memento struct generation + │ └── enums.rs # memento enum generation + ├── recallable_impl.rs # ItemIr dispatcher + ├── recallable_impl/ + │ ├── structs.rs # Recallable impl for structs + │ └── enums.rs # Recallable impl + restore helper for enums + ├── recall_impl.rs # ItemIr dispatcher + ├── recall_impl/ + │ ├── structs.rs # Recall impl for structs + │ └── enums.rs # Recall impl for enums (delegates to restore helper) + ├── from_impl.rs # ItemIr dispatcher + ├── from_impl/ + │ ├── structs.rs # From impl for structs + │ └── enums.rs # From impl for enums + ├── internal.rs # re-exports shared/structs/enums + └── internal/ + ├── shared.rs # re-exports all shared types + ├── shared/ + │ ├── item.rs # ItemIr enum, item-level attr parsing + │ ├── fields.rs # FieldIr, FieldStrategy, field classification + │ ├── generics.rs # generic retention algorithm, dependency collector + │ ├── lifetime.rs # borrowed-field rejection, PhantomData detection + │ ├── bounds.rs # MementoTraitSpec, bound collection + │ ├── codegen.rs # CodegenItemIr trait, shared field/type helpers + │ ├── env.rs # CodegenEnv (resolved crate paths) + │ └── util.rs # crate_path(), is_recallable_attr() + ├── structs.rs # re-exports + ├── structs/ + │ ├── ir.rs # StructIr, StructShape + │ └── bounds.rs # struct-specific bound wrappers + ├── enums.rs # re-exports + └── enums/ + ├── ir.rs # EnumIr, VariantIr, VariantShape + └── bounds.rs # enum-specific bound wrappers +```