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