Skip to main content

cranpose_macros/
lib.rs

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