Skip to main content

cranpose_macros/
lib.rs

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