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