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