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