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