Skip to main content

cranpose_macros/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use proc_macro::TokenStream;
4use proc_macro_crate::{FoundCrate, crate_name};
5use proc_macro2::{Span, TokenStream as TokenStream2};
6use quote::quote;
7use syn::{FnArg, Ident, ItemFn, Pat, PatType, ReturnType, Type, parse_macro_input};
8
9mod branch_groups;
10mod preview;
11#[cfg(test)]
12#[path = "tests/preview_tests.rs"]
13mod preview_tests;
14
15/// Registers a parameterless component fixture for IDE previews.
16///
17/// Enable Cranpose's `preview` feature. Options are `name`, `group`, `width`,
18/// `height`, and `dark`. Combine this attribute with `#[composable]`.
19#[proc_macro_attribute]
20pub fn preview(args: TokenStream, item: TokenStream) -> TokenStream {
21    let function = parse_macro_input!(item as ItemFn);
22    preview::expand(args.into(), function)
23        .unwrap_or_else(|error| error.to_compile_error())
24        .into()
25}
26
27fn is_fn_like_type(ty: &Type) -> bool {
28    match ty {
29        Type::ImplTrait(impl_trait) => impl_trait.bounds.iter().any(|bound| {
30            if let syn::TypeParamBound::Trait(trait_bound) = bound {
31                let path = &trait_bound.path;
32                if let Some(segment) = path.segments.last() {
33                    let ident_str = segment.ident.to_string();
34                    return ident_str == "FnMut" || ident_str == "Fn" || ident_str == "FnOnce";
35                }
36            }
37            false
38        }),
39        Type::Path(type_path) => {
40            if let Some(segment) = type_path.path.segments.last()
41                && segment.ident == "Box"
42                && let syn::PathArguments::AngleBracketed(args) = &segment.arguments
43                && let Some(syn::GenericArgument::Type(Type::TraitObject(trait_obj))) =
44                    args.args.first()
45            {
46                return trait_obj.bounds.iter().any(|bound| {
47                    if let syn::TypeParamBound::Trait(trait_bound) = bound {
48                        let path = &trait_bound.path;
49                        if let Some(segment) = path.segments.last() {
50                            let ident_str = segment.ident.to_string();
51                            return ident_str == "FnMut"
52                                || ident_str == "Fn"
53                                || ident_str == "FnOnce";
54                        }
55                    }
56                    false
57                });
58            }
59            false
60        }
61        Type::FnPtr(_) => true,
62        _ => false,
63    }
64}
65
66fn is_generic_fn_like(ty: &Type, generics: &syn::Generics) -> bool {
67    let type_ident = match ty {
68        Type::Path(type_path) if type_path.path.segments.len() == 1 => {
69            &type_path.path.segments[0].ident
70        }
71        _ => return false,
72    };
73
74    for param in &generics.params {
75        if let syn::GenericParam::Type(type_param) = param
76            && type_param.ident == *type_ident
77        {
78            for bound in &type_param.bounds {
79                if let syn::TypeParamBound::Trait(trait_bound) = bound
80                    && let Some(segment) = trait_bound.path.segments.last()
81                {
82                    let ident_str = segment.ident.to_string();
83                    if ident_str == "FnMut" || ident_str == "Fn" || ident_str == "FnOnce" {
84                        return true;
85                    }
86                }
87            }
88        }
89    }
90
91    if let Some(where_clause) = &generics.where_clause {
92        for predicate in &where_clause.predicates {
93            if let syn::WherePredicate::Type(pred) = predicate
94                && let Type::Path(bounded_type) = &pred.bounded_ty
95                && bounded_type.path.segments.len() == 1
96                && bounded_type.path.segments[0].ident == *type_ident
97            {
98                for bound in &pred.bounds {
99                    if let syn::TypeParamBound::Trait(trait_bound) = bound
100                        && let Some(segment) = trait_bound.path.segments.last()
101                    {
102                        let ident_str = segment.ident.to_string();
103                        if ident_str == "FnMut" || ident_str == "Fn" || ident_str == "FnOnce" {
104                            return true;
105                        }
106                    }
107                }
108            }
109        }
110    }
111
112    false
113}
114
115/// A composable call's parameters that are neither callbacks nor
116/// `impl Trait`, kept together in one slot and compared field by field: one
117/// slot lookup and one payload per call instead of one per parameter.
118struct PackedParams<'a> {
119    params: Vec<(&'a Ident, &'a Type)>,
120}
121
122impl PackedParams<'_> {
123    fn slot() -> Ident {
124        Ident::new("__params_slot", Span::mixed_site())
125    }
126
127    fn state_type(&self, core_path: &TokenStream2) -> TokenStream2 {
128        let types = self.params.iter().map(|(_, ty)| ty);
129        quote! { #core_path::ParamState<(#(#types,)*)> }
130    }
131
132    fn slot_stmt(&self, core_path: &TokenStream2, composer: &Ident) -> TokenStream2 {
133        let state = self.state_type(core_path);
134        let slot = Self::slot();
135        quote! {
136            let #slot = #composer.__use_param_slot(|| <#state>::default());
137        }
138    }
139
140    /// `setup` led by the statements that store the parameters and mark the
141    /// call changed when any differs from the last composition.
142    fn with_setup(
143        &self,
144        core_path: &TokenStream2,
145        composer: &Ident,
146        mut setup: Vec<TokenStream2>,
147    ) -> Vec<TokenStream2> {
148        if self.params.is_empty() {
149            return setup;
150        }
151        let state = self.state_type(core_path);
152        let slot = Self::slot();
153        let param_state = Ident::new("__param_state", Span::mixed_site());
154        let stored = Ident::new("__stored", Span::mixed_site());
155        let idents = self.params.iter().map(|(ident, _)| ident);
156        let refreshes =
157            self.params.iter().enumerate().map(|(index, (ident, ty))| {
158                param_field_refresh(core_path, &stored, ident, ty, index)
159            });
160        let slot_stmt = self.slot_stmt(core_path, composer);
161        setup.insert(
162            0,
163            quote! {
164                #slot_stmt
165                if #composer.with_slot_value_mut::<#state, _>(#slot, |#param_state| {
166                    #param_state.update_fields(
167                        || (#(::core::clone::Clone::clone(&#idents),)*),
168                        |#stored| false #(| #refreshes)*,
169                    )
170                }) {
171                    __changed = true;
172                }
173            },
174        );
175        setup
176    }
177
178    /// `setup` led by the statement that finds the parameters' slot.
179    fn with_slot(
180        &self,
181        core_path: &TokenStream2,
182        composer: &Ident,
183        mut setup: Vec<TokenStream2>,
184    ) -> Vec<TokenStream2> {
185        if !self.params.is_empty() {
186            setup.insert(0, self.slot_stmt(core_path, composer));
187        }
188        setup
189    }
190
191    /// The statements that read the stored parameters back for a
192    /// recomposition started from the call's scope.
193    fn reads(&self, core_path: &TokenStream2, composer: &Ident) -> Vec<TokenStream2> {
194        if self.params.is_empty() {
195            return Vec::new();
196        }
197        let state = self.state_type(core_path);
198        let slot = Self::slot();
199        let param_state = Ident::new("__param_state", Span::mixed_site());
200        let idents = self.params.iter().map(|(ident, _)| ident);
201        vec![quote! {
202            let (#(#idents,)*) = #composer.with_slot_value::<#state, _>(#slot, |#param_state| {
203                #param_state
204                    .value()
205                    .expect("composable parameter missing for recomposition")
206            });
207        }]
208    }
209}
210
211/// The call that brings field `index` of a call's stored parameters up to
212/// date with `ident` and says whether it changed: `refresh_shared_param` for
213/// an `Rc` or `Arc`, whose unchanged allocation needs no comparison of its
214/// contents.
215fn param_field_refresh(
216    core_path: &TokenStream2,
217    stored: &Ident,
218    ident: &Ident,
219    ty: &Type,
220    index: usize,
221) -> TokenStream2 {
222    let index = syn::Index::from(index);
223    if is_shared_pointer(ty) {
224        quote! { #core_path::refresh_shared_param(&mut #stored.#index, &#ident) }
225    } else {
226        quote! { #core_path::refresh_param(&mut #stored.#index, &#ident) }
227    }
228}
229
230/// Whether a parameter type is an `Rc<..>` or `Arc<..>`.
231fn is_shared_pointer(ty: &Type) -> bool {
232    match ty {
233        Type::Path(path) if path.qself.is_none() => path
234            .path
235            .segments
236            .last()
237            .is_some_and(|segment| segment.ident == "Rc" || segment.ident == "Arc"),
238        _ => false,
239    }
240}
241
242fn is_fn_param(ty: &Type, generics: &syn::Generics) -> bool {
243    is_fn_like_type(ty) || is_generic_fn_like(ty, generics)
244}
245
246fn is_zero_arg_fn_impl_trait(ty: &Type) -> bool {
247    if let Type::ImplTrait(impl_trait) = ty {
248        impl_trait.bounds.iter().any(|bound| {
249            if let syn::TypeParamBound::Trait(trait_bound) = bound
250                && let Some(segment) = trait_bound.path.segments.last()
251            {
252                let ident_str = segment.ident.to_string();
253                if (ident_str == "Fn" || ident_str == "FnMut")
254                    && let syn::PathArguments::Parenthesized(args) = &segment.arguments
255                {
256                    let returns_unit = match &args.output {
257                        ReturnType::Default => true,
258                        ReturnType::Type(_, ty) => {
259                            matches!(&**ty, Type::Tuple(tuple) if tuple.elems.is_empty())
260                        }
261                    };
262                    return args.inputs.is_empty() && returns_unit;
263                }
264            }
265            false
266        })
267    } else {
268        false
269    }
270}
271
272fn type_bare_generic_ident(ty: &Type) -> Option<&Ident> {
273    match ty {
274        Type::Path(type_path)
275            if type_path.qself.is_none()
276                && type_path.path.segments.len() == 1
277                && type_path.path.segments[0].arguments.is_none() =>
278        {
279            Some(&type_path.path.segments[0].ident)
280        }
281        _ => None,
282    }
283}
284
285fn stream_mentions_ident(tokens: &TokenStream2, name: &str) -> bool {
286    tokens.clone().into_iter().any(|tt| match tt {
287        proc_macro2::TokenTree::Ident(ident) => ident == name,
288        proc_macro2::TokenTree::Group(group) => stream_mentions_ident(&group.stream(), name),
289        _ => false,
290    })
291}
292
293fn filter_generics(
294    generics: &syn::Generics,
295    strip: &std::collections::HashSet<String>,
296) -> syn::Generics {
297    let mut filtered = generics.clone();
298    filtered.params = filtered
299        .params
300        .into_iter()
301        .filter(|param| match param {
302            syn::GenericParam::Type(type_param) => !strip.contains(&type_param.ident.to_string()),
303            _ => true,
304        })
305        .collect();
306    if let Some(where_clause) = &mut filtered.where_clause {
307        where_clause.predicates = where_clause
308            .predicates
309            .clone()
310            .into_iter()
311            .filter(|predicate| {
312                if let syn::WherePredicate::Type(pred) = predicate
313                    && let Some(ident) = type_bare_generic_ident(&pred.bounded_ty)
314                {
315                    return !strip.contains(&ident.to_string());
316                }
317                true
318            })
319            .collect();
320        if where_clause.predicates.is_empty() {
321            filtered.where_clause = None;
322        }
323    }
324    filtered
325}
326
327fn is_node_id_return(ty: &Type) -> bool {
328    matches!(
329        ty,
330        Type::Path(type_path)
331            if type_path
332                .path
333                .segments
334                .last()
335                .is_some_and(|segment| segment.ident == "NodeId")
336    )
337}
338
339fn core_crate_path() -> TokenStream2 {
340    let crate_name = crate_name("cranpose")
341        .ok()
342        .or_else(|| crate_name("cranpose-core").ok());
343
344    match crate_name {
345        Some(FoundCrate::Itself) => quote!(crate),
346        Some(FoundCrate::Name(name)) => {
347            let ident = Ident::new(&name, Span::call_site());
348            quote!(#ident)
349        }
350        None => quote!(cranpose_core),
351    }
352}
353
354fn definition_key_stmt(
355    core_path: &TokenStream2,
356    caller_key_ident: &Ident,
357    name: &Ident,
358) -> TokenStream2 {
359    if cfg!(feature = "hot-reload") {
360        // A hot patch moves definitions and may reuse a static cache for a
361        // different item. Development keys name the definition instead.
362        let name = name.to_string();
363        return quote! {
364            let #caller_key_ident = #core_path::composable_identity_key({
365                const __CRANPOSE_DEFINITION_KEY: #core_path::Key =
366                    #core_path::hot_definition_key(file!(), module_path!(), #name);
367                __CRANPOSE_DEFINITION_KEY
368            });
369        };
370    }
371    quote! {
372        let #caller_key_ident = #core_path::composable_identity_key({
373            struct __CranposeDefinitionMarker;
374            static __CRANPOSE_DEFINITION_KEY: ::std::sync::OnceLock<#core_path::Key> =
375                ::std::sync::OnceLock::new();
376            #core_path::cached_composable_definition_key(
377                &__CRANPOSE_DEFINITION_KEY,
378                file!(),
379                line!(),
380                column!(),
381                ::std::any::TypeId::of::<__CranposeDefinitionMarker>(),
382            )
383        });
384    }
385}
386
387/// Turns a function into a composable: its body runs inside a group keyed by
388/// the call site, and recomposition skips it while its arguments are
389/// unchanged. `#[composable(no_skip)]` always re-runs the body.
390///
391/// Composables are named in CamelCase, as in Jetpack Compose, so the
392/// generated function carries `#[allow(non_snake_case)]` and callers need no
393/// lint allowance of their own.
394#[proc_macro_attribute]
395pub fn composable(attr: TokenStream, item: TokenStream) -> TokenStream {
396    let attr_tokens = TokenStream2::from(attr);
397    let mut enable_skip = true;
398    let core_path = core_crate_path();
399    if !attr_tokens.is_empty() {
400        match syn::parse2::<Ident>(attr_tokens) {
401            Ok(ident) if ident == "no_skip" => enable_skip = false,
402            Ok(other) => {
403                return syn::Error::new_spanned(other, "unsupported composable attribute")
404                    .to_compile_error()
405                    .into();
406            }
407            Err(err) => {
408                return err.to_compile_error().into();
409            }
410        }
411    }
412
413    let mut func = parse_macro_input!(item as ItemFn);
414
415    struct ParamInfo {
416        ident: Ident,
417        pat: Box<Pat>,
418        ty: Type,
419        pat_is_mut: bool,
420        is_impl_trait: bool,
421    }
422
423    let mut param_info: Vec<ParamInfo> = Vec::new();
424
425    for (index, arg) in func.sig.inputs.iter_mut().enumerate() {
426        if let FnArg::Typed(PatType { pat, ty, .. }) = arg {
427            if let Some(reserved) = find_reserved_pattern_ident(pat) {
428                let name = reserved.to_string();
429                return syn::Error::new(
430                    reserved.span(),
431                    format!("`{name}` is reserved by #[composable]"),
432                )
433                .to_compile_error()
434                .into();
435            }
436            let pat_is_mut = matches!(
437                pat.as_ref(),
438                Pat::Ident(pat_ident) if pat_ident.mutability.is_some()
439            );
440            let is_impl_trait = matches!(**ty, Type::ImplTrait(_));
441
442            if is_impl_trait {
443                let original_pat: Box<Pat> = pat.clone();
444                if let Pat::Ident(pat_ident) = &**pat {
445                    param_info.push(ParamInfo {
446                        ident: pat_ident.ident.clone(),
447                        pat: original_pat,
448                        ty: ty.as_ref().clone(),
449                        pat_is_mut,
450                        is_impl_trait: true,
451                    });
452                } else {
453                    param_info.push(ParamInfo {
454                        ident: Ident::new(&format!("__arg{index}"), Span::mixed_site()),
455                        pat: original_pat,
456                        ty: ty.as_ref().clone(),
457                        pat_is_mut,
458                        is_impl_trait: true,
459                    });
460                }
461            } else {
462                let ident = Ident::new(&format!("__arg{index}"), Span::mixed_site());
463                let original_pat: Box<Pat> = pat.clone();
464                **pat = syn::parse_quote! { #ident };
465                param_info.push(ParamInfo {
466                    ident,
467                    pat: original_pat,
468                    ty: ty.as_ref().clone(),
469                    pat_is_mut,
470                    is_impl_trait: false,
471                });
472            }
473        }
474    }
475
476    inject_groups(&core_path, &mut func);
477    let has_rust_abi = match &func.sig.abi {
478        None => true,
479        Some(abi) => abi.name.as_ref().is_some_and(|name| name.value() == "Rust"),
480    };
481    if has_rust_abi {
482        func.attrs.push(syn::parse_quote!(#[track_caller]));
483    }
484    func.attrs.push(syn::parse_quote!(#[allow(non_snake_case)]));
485
486    let scope_label_ident = func.sig.ident.clone();
487    let original_block = func.block.clone();
488    let body_end = func.block.brace_token.span.close();
489    let composer_ident = Ident::new("__composer", Span::mixed_site());
490    let outer_composer_ident = Ident::new("__outer_composer", Span::mixed_site());
491    let caller_key_ident = Ident::new("__cranpose_caller_key", Span::mixed_site());
492    let current_scope_ident = Ident::new("__current_scope", Span::mixed_site());
493    let result_slot_index_ident = Ident::new("__result_slot_index", Span::mixed_site());
494    let has_previous_ident = Ident::new("__has_previous", Span::mixed_site());
495    let result_ident = Ident::new("__result", Span::mixed_site());
496    let value_ident = Ident::new("__value", Span::mixed_site());
497    let key_expr = quote! { #caller_key_ident };
498    let caller_key_stmt = definition_key_stmt(&core_path, &caller_key_ident, &scope_label_ident);
499
500    let rebinds_for_no_skip: Vec<_> = param_info
501        .iter()
502        .map(|info| {
503            let ident = &info.ident;
504            let pat = &info.pat;
505            quote! { let #pat = #ident; }
506        })
507        .collect();
508
509    let return_ty: syn::Type = match &func.sig.output {
510        ReturnType::Default => syn::parse_quote! { () },
511        ReturnType::Type(_, ty) => ty.as_ref().clone(),
512    };
513    let returns_unit = match &func.sig.output {
514        ReturnType::Default => true,
515        ReturnType::Type(_, ty) => {
516            matches!(ty.as_ref(), Type::Tuple(tuple) if tuple.elems.is_empty())
517        }
518    };
519    let invalidate_return_consumer = if returns_unit || is_node_id_return(&return_ty) {
520        quote! {}
521    } else {
522        quote! { #composer_ident.__invalidate_return_consumer_scope(); }
523    };
524    let _helper_ident = Ident::new(
525        &format!("__cranpose_impl_{}", func.sig.ident),
526        Span::mixed_site(),
527    );
528    let generics = func.sig.generics.clone();
529    let (_impl_generics, _ty_generics, _where_clause) = generics.split_for_impl();
530
531    let _helper_inputs: Vec<TokenStream2> = param_info
532        .iter()
533        .map(|info| {
534            let ident = &info.ident;
535            let ty = &info.ty;
536            quote! { #ident: #ty }
537        })
538        .collect();
539
540    let has_unhandled_impl_trait = param_info
541        .iter()
542        .any(|info| info.is_impl_trait && !is_zero_arg_fn_impl_trait(&info.ty));
543
544    if enable_skip && !has_unhandled_impl_trait {
545        let helper_ident = Ident::new(
546            &format!("__cranpose_impl_{}", func.sig.ident),
547            Span::mixed_site(),
548        );
549        let generics = func.sig.generics.clone();
550
551        let param_erased: Vec<bool> = param_info
552            .iter()
553            .map(|info| {
554                (info.is_impl_trait && is_zero_arg_fn_impl_trait(&info.ty))
555                    || (!info.is_impl_trait
556                        && type_bare_generic_ident(&info.ty).is_some()
557                        && is_generic_fn_like(&info.ty, &generics))
558            })
559            .collect();
560
561        let mut strippable: std::collections::HashSet<String> = param_info
562            .iter()
563            .zip(&param_erased)
564            .filter(|(info, erased)| **erased && !info.is_impl_trait)
565            .filter_map(|(info, _)| type_bare_generic_ident(&info.ty))
566            .map(Ident::to_string)
567            .collect();
568        loop {
569            use quote::ToTokens;
570            let mut used_elsewhere: Vec<TokenStream2> = Vec::new();
571            for (info, erased) in param_info.iter().zip(&param_erased) {
572                if !*erased {
573                    used_elsewhere.push(info.ty.to_token_stream());
574                }
575            }
576            used_elsewhere.push(return_ty.to_token_stream());
577            for param in &generics.params {
578                match param {
579                    syn::GenericParam::Type(type_param) => {
580                        if !strippable.contains(&type_param.ident.to_string()) {
581                            used_elsewhere.push(type_param.bounds.to_token_stream());
582                            if let Some((_, default)) = &type_param.default {
583                                used_elsewhere.push(default.to_token_stream());
584                            }
585                        }
586                    }
587                    syn::GenericParam::Const(const_param) => {
588                        used_elsewhere.push(const_param.ty.to_token_stream());
589                    }
590                    syn::GenericParam::Lifetime(_) => {}
591                }
592            }
593            if let Some(where_clause) = &generics.where_clause {
594                for predicate in &where_clause.predicates {
595                    if let syn::WherePredicate::Type(pred) = predicate
596                        && let Some(ident) = type_bare_generic_ident(&pred.bounded_ty)
597                        && strippable.contains(&ident.to_string())
598                    {
599                        continue;
600                    }
601                    used_elsewhere.push(predicate.to_token_stream());
602                }
603            }
604            let before = strippable.len();
605            strippable.retain(|name| {
606                !used_elsewhere
607                    .iter()
608                    .any(|tokens| stream_mentions_ident(tokens, name))
609            });
610            if strippable.len() == before {
611                break;
612            }
613        }
614
615        let helper_generics = filter_generics(&generics, &strippable);
616        let (impl_generics, ty_generics, where_clause) = helper_generics.split_for_impl();
617        let ty_generics_turbofish = ty_generics.as_turbofish();
618
619        let helper_inputs: Vec<TokenStream2> = param_info
620            .iter()
621            .zip(&param_erased)
622            .filter_map(|(info, erased)| {
623                if info.is_impl_trait && !is_zero_arg_fn_impl_trait(&info.ty) {
624                    None
625                } else if *erased {
626                    let ident = &info.ident;
627                    Some(quote! { #ident: ::std::boxed::Box<dyn ::core::ops::FnMut() + 'static> })
628                } else {
629                    let ident = &info.ident;
630                    let ty = &info.ty;
631                    Some(quote! { #ident: #ty })
632                }
633            })
634            .collect();
635
636        let param_state_slots: Vec<Ident> = (0..param_info.len())
637            .map(|index| Ident::new(&format!("__param_state_slot{index}"), Span::mixed_site()))
638            .collect();
639
640        let param_is_callback: Vec<bool> = param_info
641            .iter()
642            .map(|info| {
643                (info.is_impl_trait && is_zero_arg_fn_impl_trait(&info.ty))
644                    || (!info.is_impl_trait && is_fn_param(&info.ty, &generics))
645            })
646            .collect();
647
648        let param_setup: Vec<TokenStream2> = param_info
649            .iter()
650            .zip(param_state_slots.iter())
651            .zip(&param_erased)
652            .zip(&param_is_callback)
653            .map(|(((info, slot_ident), erased), is_callback)| {
654                if *is_callback {
655                    let ident = &info.ident;
656                    let update = if *erased {
657                        quote! { holder.update_boxed(#ident); }
658                    } else {
659                        quote! { holder.update(#ident); }
660                    };
661                    quote! {
662                        let #slot_ident = #composer_ident
663                            .__use_param_slot(|| #core_path::CallbackHolder::new());
664                        #composer_ident.with_slot_value::<#core_path::CallbackHolder, _>(
665                            #slot_ident,
666                            |holder| {
667                                #update
668                            },
669                        );
670                        __changed = true;
671                    }
672                } else if info.is_impl_trait {
673                    quote! { __changed = true; }
674                } else {
675                    quote! {}
676                }
677            })
678            .collect();
679
680        let param_setup_recompose: Vec<TokenStream2> = param_state_slots
681            .iter()
682            .zip(&param_is_callback)
683            .filter(|(_, is_callback)| **is_callback)
684            .map(|(slot_ident, _)| {
685                quote! {
686                    let #slot_ident = #composer_ident
687                        .__use_param_slot(|| #core_path::CallbackHolder::new());
688                }
689            })
690            .collect();
691
692        let callback_rebinds: Vec<TokenStream2> = param_info
693            .iter()
694            .zip(param_state_slots.iter())
695            .zip(&param_is_callback)
696            .filter(|(_, is_callback)| **is_callback)
697            .map(|((info, slot_ident), _)| {
698                let pat = &info.pat;
699                let can_add_mut = matches!(pat.as_ref(), Pat::Ident(_));
700                let binding = if can_add_mut && !info.pat_is_mut {
701                    quote! { mut #pat }
702                } else {
703                    quote! { #pat }
704                };
705                quote! {
706                    #[allow(unused_mut)]
707                    let #binding = #composer_ident
708                        .with_slot_value::<#core_path::CallbackHolder, _>(
709                            #slot_ident,
710                            |holder| holder.clone_rc(),
711                        );
712                }
713            })
714            .collect();
715
716        let body_inputs: Vec<TokenStream2> = param_info
717            .iter()
718            .zip(param_state_slots.iter())
719            .zip(&param_is_callback)
720            .map(|((info, slot_ident), is_callback)| {
721                if *is_callback {
722                    quote! {
723                        #slot_ident: #core_path::ValueSlotHandle<'_, #core_path::CallbackHolder>
724                    }
725                } else {
726                    let pat = &info.pat;
727                    let ty = &info.ty;
728                    quote! { #pat: #ty }
729                }
730            })
731            .collect();
732
733        let body_args: Vec<TokenStream2> = param_info
734            .iter()
735            .zip(param_state_slots.iter())
736            .zip(&param_is_callback)
737            .map(|((info, slot_ident), is_callback)| {
738                if *is_callback {
739                    quote! { #slot_ident }
740                } else {
741                    let ident = &info.ident;
742                    quote! { #ident }
743                }
744            })
745            .collect();
746
747        let packed = PackedParams {
748            params: param_info
749                .iter()
750                .zip(&param_is_callback)
751                .filter(|(info, is_callback)| !**is_callback && !info.is_impl_trait)
752                .map(|(info, _)| (&info.ident, &info.ty))
753                .collect(),
754        };
755        let param_setup = packed.with_setup(&core_path, &composer_ident, param_setup);
756        let param_setup_recompose =
757            packed.with_slot(&core_path, &composer_ident, param_setup_recompose);
758        let reads_for_recompose = packed.reads(&core_path, &composer_ident);
759
760        let body_ident = Ident::new(
761            &format!("__cranpose_body_{}", func.sig.ident),
762            Span::mixed_site(),
763        );
764        let body_call = quote! {
765            #body_ident #ty_generics_turbofish (#composer_ident #(, #body_args)*)
766        };
767
768        let recompose_fn_ident = Ident::new(
769            &format!("__cranpose_recompose_{}", func.sig.ident),
770            Span::mixed_site(),
771        );
772
773        let recompose_setter = quote! {
774            {
775                #composer_ident.set_recompose_callback(move |
776                    #composer_ident: &#core_path::Composer|
777                {
778                    let _ = #recompose_fn_ident #ty_generics_turbofish (
779                        #composer_ident
780                    );
781                });
782            }
783        };
784
785        let helper_body = if returns_unit {
786            quote! {
787                #core_path::debug_label_current_scope(stringify!(#scope_label_ident));
788                let #current_scope_ident = #composer_ident
789                    .current_recompose_scope()
790                    .expect("missing recompose scope");
791                let mut __changed = #current_scope_ident.should_recompose();
792                #(#param_setup)*
793                #recompose_setter
794                if !__changed && #current_scope_ident.has_composed_once() {
795                    #composer_ident.skip_current_group();
796                    return;
797                }
798                #body_call
799            }
800        } else {
801            quote! {
802                #core_path::debug_label_current_scope(stringify!(#scope_label_ident));
803                let #current_scope_ident = #composer_ident
804                    .current_recompose_scope()
805                    .expect("missing recompose scope");
806                let mut __changed = #current_scope_ident.should_recompose();
807                #(#param_setup)*
808                #recompose_setter
809                let #result_slot_index_ident = #composer_ident
810                    .__use_return_slot(|| #core_path::ReturnSlot::<#return_ty>::default());
811                let #has_previous_ident = #composer_ident
812                    .with_slot_value::<#core_path::ReturnSlot<#return_ty>, _>(
813                        #result_slot_index_ident,
814                        |slot| slot.get().is_some(),
815                    );
816                if !__changed && #has_previous_ident {
817                    #composer_ident.skip_current_group();
818                    let #result_ident = #composer_ident
819                        .with_slot_value::<#core_path::ReturnSlot<#return_ty>, _>(
820                            #result_slot_index_ident,
821                            |slot| {
822                                slot.get()
823                                    .expect("composable return value missing during skip")
824                            },
825                        );
826                    return #result_ident;
827                }
828                let #value_ident: #return_ty = #body_call;
829                #composer_ident.with_slot_value_mut::<#core_path::ReturnSlot<#return_ty>, _>(
830                    #result_slot_index_ident,
831                    |slot| {
832                        slot.store(#value_ident.clone());
833                    },
834                );
835                #value_ident
836            }
837        };
838
839        let recompose_fn_body = if returns_unit {
840            quote! {
841                #(#param_setup_recompose)*
842                #(#reads_for_recompose)*
843                #body_call;
844                #recompose_setter
845            }
846        } else {
847            quote! {
848                #(#param_setup_recompose)*
849                let #result_slot_index_ident = #composer_ident
850                    .__use_return_slot(|| #core_path::ReturnSlot::<#return_ty>::default());
851                #(#reads_for_recompose)*
852                let #value_ident: #return_ty = #body_call;
853                #composer_ident.with_slot_value_mut::<#core_path::ReturnSlot<#return_ty>, _>(
854                    #result_slot_index_ident,
855                    |slot| {
856                        slot.store(#value_ident.clone());
857                    },
858                );
859                #recompose_setter
860                #invalidate_return_consumer
861                #value_ident
862            }
863        };
864
865        let slot_origin = hot_slot_origin(&core_path, &func.sig.ident, body_end);
866        let recompose_fn = quote! {
867            #[allow(non_snake_case)]
868            fn #recompose_fn_ident #impl_generics (
869                #composer_ident: &#core_path::Composer
870            ) -> #return_ty #where_clause {
871                #slot_origin
872                #recompose_fn_body
873            }
874        };
875
876        let helper_fn = quote! {
877            #[allow(non_snake_case, clippy::too_many_arguments)]
878            fn #helper_ident #impl_generics (
879                #composer_ident: &#core_path::Composer
880                #(, #helper_inputs)*
881            ) -> #return_ty #where_clause {
882                #slot_origin
883                #helper_body
884            }
885        };
886
887        let body_fn = quote! {
888            #[allow(non_snake_case, clippy::too_many_arguments)]
889            fn #body_ident #impl_generics (
890                #composer_ident: &#core_path::Composer
891                #(, #body_inputs)*
892            ) -> #return_ty #where_clause {
893                #(#callback_rebinds)*
894                let _cranpose_source_scope = #core_path::__source_scope(
895                    stringify!(#scope_label_ident), file!(), line!(), env!("CARGO_MANIFEST_DIR"),
896                );
897                #original_block
898            }
899        };
900
901        let wrapper_args: Vec<TokenStream2> = param_info
902            .iter()
903            .zip(&param_erased)
904            .filter_map(|(info, erased)| {
905                if info.is_impl_trait && !is_zero_arg_fn_impl_trait(&info.ty) {
906                    None
907                } else if *erased {
908                    let ident = &info.ident;
909                    Some(quote! { ::std::boxed::Box::new(#ident) })
910                } else {
911                    let ident = &info.ident;
912                    Some(quote! { #ident })
913                }
914            })
915            .collect();
916
917        let wrapped = quote!({
918            #caller_key_stmt
919            #core_path::with_current_composer(|#composer_ident: &#core_path::Composer| {
920                #composer_ident.with_group(#key_expr, |#composer_ident: &#core_path::Composer| {
921                    #helper_ident(#composer_ident #(, #wrapper_args)*)
922                })
923            })
924        });
925        *func.block = syn::parse2(wrapped).expect("failed to build block");
926        TokenStream::from(quote! {
927            #body_fn
928            #recompose_fn
929            #helper_fn
930            #func
931        })
932    } else {
933        let wrapped = quote!({
934            #caller_key_stmt
935            #core_path::with_current_composer(|#outer_composer_ident: &#core_path::Composer| {
936                #outer_composer_ident.with_group(#key_expr, |#composer_ident: &#core_path::Composer| {
937                    #core_path::debug_label_current_scope(stringify!(#scope_label_ident));
938                    #(#rebinds_for_no_skip)*
939                    let _cranpose_source_scope = #core_path::__source_scope(
940                        stringify!(#scope_label_ident), file!(), line!(), env!("CARGO_MANIFEST_DIR"),
941                    );
942                    #original_block
943                })
944            })
945        });
946        *func.block = syn::parse2(wrapped).expect("failed to build block");
947        TokenStream::from(quote! { #func })
948    }
949}
950
951/// Adds branch groups; with the development-only `hot-reload` feature the
952/// body also marks its definition as the origin for call-site keys.
953fn inject_groups(core_path: &TokenStream2, func: &mut ItemFn) {
954    let hot_reload = cfg!(feature = "hot-reload");
955    branch_groups::inject_branch_groups_with(
956        core_path,
957        &mut func.block,
958        &func.sig.ident,
959        hot_reload,
960    );
961    if hot_reload {
962        let end = func.block.brace_token.span.close();
963        let origin = branch_groups::hot_origin_stmt(core_path, &func.sig.ident, end);
964        func.block
965            .stmts
966            .insert(0, syn::parse2(origin).expect("hot origin statement"));
967    }
968}
969
970/// Parameter and return slots are keyed by call sites in generated code. With
971/// hot reload those keys follow the current origin, so the first composition
972/// and an independent recomposition both set the composable's own origin.
973fn hot_slot_origin(core_path: &TokenStream2, name: &Ident, end: Span) -> TokenStream2 {
974    if cfg!(feature = "hot-reload") {
975        branch_groups::hot_origin_stmt(core_path, name, end)
976    } else {
977        TokenStream2::new()
978    }
979}
980
981fn find_reserved_pattern_ident(pat: &Pat) -> Option<&Ident> {
982    use syn::visit::Visit;
983
984    struct Scan<'ast> {
985        found: Option<&'ast Ident>,
986    }
987    impl<'ast> syn::visit::Visit<'ast> for Scan<'ast> {
988        fn visit_pat_ident(&mut self, node: &'ast syn::PatIdent) {
989            if self.found.is_none() {
990                let name = node.ident.to_string();
991                if name == "__composer" || name.starts_with("__cranpose") {
992                    self.found = Some(&node.ident);
993                }
994            }
995            syn::visit::visit_pat_ident(self, node);
996        }
997    }
998    let mut scan = Scan { found: None };
999    scan.visit_pat(pat);
1000    scan.found
1001}
1002
1003#[cfg(test)]
1004#[path = "tests/macros_tests.rs"]
1005mod tests;