Skip to main content

cranpose_macros/
lib.rs

1#![deny(missing_docs)]
2#![doc = include_str!("../README.md")]
3
4use callback_param::CallbackParam;
5use held_callback::{HeldCallback, borrows};
6use packed_params::PackedParams;
7use proc_macro::TokenStream;
8use proc_macro_crate::{FoundCrate, crate_name};
9use proc_macro2::{Span, TokenStream as TokenStream2};
10use quote::quote;
11use returning_call::ReturningCall;
12use syn::{FnArg, Ident, ItemFn, Pat, PatType, ReturnType, Type, parse_macro_input};
13
14mod branch_groups;
15mod callback_param;
16mod constant_modifiers;
17mod held_callback;
18#[cfg(feature = "localization")]
19mod localization;
20mod packed_params;
21mod preview;
22mod returning_call;
23mod worker_job;
24
25/// Localizes readable source text using the nearest localization provider.
26/// Named arguments use `name = expression`; optional literal metadata is
27/// `id`, `context`, `comment`, and `source_locale` (default `en`).
28#[cfg(feature = "localization")]
29#[proc_macro]
30pub fn tr(input: TokenStream) -> TokenStream {
31    localization::translate(input.into())
32        .unwrap_or_else(|error| error.to_compile_error())
33        .into()
34}
35
36/// Embeds `<directory>/<locale>/<namespace>.ftl` catalogs and checks them at compile time.
37/// Syntax: `translations!("locales", fallback = "en")`.
38#[cfg(feature = "localization")]
39#[proc_macro]
40pub fn translations(input: TokenStream) -> TokenStream {
41    localization::catalogs(input.into())
42        .unwrap_or_else(|error| error.to_compile_error())
43        .into()
44}
45
46/// Captures readable source text and arguments for translation when displayed.
47/// Accepts the same syntax as `tr!`, but works outside composition.
48#[cfg(feature = "localization")]
49#[proc_macro]
50pub fn message(input: TokenStream) -> TokenStream {
51    localization::message(input.into())
52        .unwrap_or_else(|error| error.to_compile_error())
53        .into()
54}
55
56/// Generates a module of named message accessors from a source-language Fluent file.
57/// Syntax: `translation_messages!(pub mod messages, "locales/en/app.ftl")`.
58#[cfg(feature = "localization")]
59#[proc_macro]
60pub fn translation_messages(input: TokenStream) -> TokenStream {
61    localization::bindings(input.into())
62        .unwrap_or_else(|error| error.to_compile_error())
63        .into()
64}
65#[cfg(test)]
66#[path = "tests/preview_tests.rs"]
67mod preview_tests;
68
69/// Makes a function a job that `LaunchedEffectScope::launch_worker` runs off
70/// the UI thread: on a thread where the platform has threads, in a Web Worker
71/// in a browser.
72///
73/// The function takes one parameter and returns a value. Both types implement
74/// `WorkerData`. The function's name is the job's name, one of a kind in the
75/// app. The attribute replaces the function with a unit type of the same
76/// name, which is what `launch_worker` takes.
77#[proc_macro_attribute]
78pub fn worker_job(_args: TokenStream, item: TokenStream) -> TokenStream {
79    let item = syn::parse_macro_input!(item as ItemFn);
80    worker_job::expand(&item)
81        .unwrap_or_else(|error| error.to_compile_error())
82        .into()
83}
84
85/// Registers a parameterless component fixture for IDE previews.
86///
87/// Enable Cranpose's `preview` feature. Options are `name`, `group`, `width`,
88/// `height`, and `dark`. Combine this attribute with `#[composable]`.
89#[proc_macro_attribute]
90pub fn preview(args: TokenStream, item: TokenStream) -> TokenStream {
91    let function = parse_macro_input!(item as ItemFn);
92    preview::expand(args.into(), function)
93        .unwrap_or_else(|error| error.to_compile_error())
94        .into()
95}
96
97fn is_fn_like_type(ty: &Type) -> bool {
98    match ty {
99        Type::ImplTrait(impl_trait) => impl_trait.bounds.iter().any(|bound| {
100            if let syn::TypeParamBound::Trait(trait_bound) = bound {
101                let path = &trait_bound.path;
102                if let Some(segment) = path.segments.last() {
103                    let ident_str = segment.ident.to_string();
104                    return ident_str == "FnMut" || ident_str == "Fn" || ident_str == "FnOnce";
105                }
106            }
107            false
108        }),
109        Type::Path(type_path) => {
110            if let Some(segment) = type_path.path.segments.last()
111                && segment.ident == "Box"
112                && let syn::PathArguments::AngleBracketed(args) = &segment.arguments
113                && let Some(syn::GenericArgument::Type(Type::TraitObject(trait_obj))) =
114                    args.args.first()
115            {
116                return trait_obj.bounds.iter().any(|bound| {
117                    if let syn::TypeParamBound::Trait(trait_bound) = bound {
118                        let path = &trait_bound.path;
119                        if let Some(segment) = path.segments.last() {
120                            let ident_str = segment.ident.to_string();
121                            return ident_str == "FnMut"
122                                || ident_str == "Fn"
123                                || ident_str == "FnOnce";
124                        }
125                    }
126                    false
127                });
128            }
129            false
130        }
131        Type::FnPtr(_) => true,
132        _ => false,
133    }
134}
135
136fn is_generic_fn_like(ty: &Type, generics: &syn::Generics) -> bool {
137    let type_ident = match ty {
138        Type::Path(type_path) if type_path.path.segments.len() == 1 => {
139            &type_path.path.segments[0].ident
140        }
141        _ => return false,
142    };
143
144    for param in &generics.params {
145        if let syn::GenericParam::Type(type_param) = param
146            && type_param.ident == *type_ident
147        {
148            for bound in &type_param.bounds {
149                if let syn::TypeParamBound::Trait(trait_bound) = bound
150                    && let Some(segment) = trait_bound.path.segments.last()
151                {
152                    let ident_str = segment.ident.to_string();
153                    if ident_str == "FnMut" || ident_str == "Fn" || ident_str == "FnOnce" {
154                        return true;
155                    }
156                }
157            }
158        }
159    }
160
161    if let Some(where_clause) = &generics.where_clause {
162        for predicate in &where_clause.predicates {
163            if let syn::WherePredicate::Type(pred) = predicate
164                && let Type::Path(bounded_type) = &pred.bounded_ty
165                && bounded_type.path.segments.len() == 1
166                && bounded_type.path.segments[0].ident == *type_ident
167            {
168                for bound in &pred.bounds {
169                    if let syn::TypeParamBound::Trait(trait_bound) = bound
170                        && let Some(segment) = trait_bound.path.segments.last()
171                    {
172                        let ident_str = segment.ident.to_string();
173                        if ident_str == "FnMut" || ident_str == "Fn" || ident_str == "FnOnce" {
174                            return true;
175                        }
176                    }
177                }
178            }
179        }
180    }
181
182    false
183}
184
185/// A parameter of a `#[composable]` function.
186struct ParamInfo {
187    ident: Ident,
188    pat: Box<Pat>,
189    ty: Type,
190    pat_is_mut: bool,
191    is_impl_trait: bool,
192}
193
194fn is_fn_param(ty: &Type, generics: &syn::Generics) -> bool {
195    is_fn_like_type(ty) || is_generic_fn_like(ty, generics)
196}
197
198fn type_bare_generic_ident(ty: &Type) -> Option<&Ident> {
199    match ty {
200        Type::Path(type_path)
201            if type_path.qself.is_none()
202                && type_path.path.segments.len() == 1
203                && type_path.path.segments[0].arguments.is_none() =>
204        {
205            Some(&type_path.path.segments[0].ident)
206        }
207        _ => None,
208    }
209}
210
211fn stream_mentions_ident(tokens: &TokenStream2, name: &str) -> bool {
212    tokens.clone().into_iter().any(|tt| match tt {
213        proc_macro2::TokenTree::Ident(ident) => ident == name,
214        proc_macro2::TokenTree::Group(group) => stream_mentions_ident(&group.stream(), name),
215        _ => false,
216    })
217}
218
219fn filter_generics(
220    generics: &syn::Generics,
221    strip: &std::collections::BTreeSet<String>,
222) -> syn::Generics {
223    let mut filtered = generics.clone();
224    filtered.params = filtered
225        .params
226        .into_iter()
227        .filter(|param| match param {
228            syn::GenericParam::Type(type_param) => !strip.contains(&type_param.ident.to_string()),
229            _ => true,
230        })
231        .collect();
232    if let Some(where_clause) = &mut filtered.where_clause {
233        where_clause.predicates = where_clause
234            .predicates
235            .clone()
236            .into_iter()
237            .filter(|predicate| {
238                if let syn::WherePredicate::Type(pred) = predicate
239                    && let Some(ident) = type_bare_generic_ident(&pred.bounded_ty)
240                {
241                    return !strip.contains(&ident.to_string());
242                }
243                true
244            })
245            .collect();
246        if where_clause.predicates.is_empty() {
247            filtered.where_clause = None;
248        }
249    }
250    filtered
251}
252
253fn is_node_id_return(ty: &Type) -> bool {
254    matches!(
255        ty,
256        Type::Path(type_path)
257            if type_path
258                .path
259                .segments
260                .last()
261                .is_some_and(|segment| segment.ident == "NodeId")
262    )
263}
264
265fn core_crate_path() -> TokenStream2 {
266    let crate_name = crate_name("cranpose")
267        .ok()
268        .or_else(|| crate_name("cranpose-core").ok());
269
270    match crate_name {
271        Some(FoundCrate::Itself) => quote!(crate),
272        Some(FoundCrate::Name(name)) => {
273            let ident = Ident::new(&name, Span::call_site());
274            quote!(#ident)
275        }
276        None => quote!(cranpose_core),
277    }
278}
279
280/// The key of a slot the expansion takes: a constant of the call site, or
281/// under hot reload, where a patch moves call sites, the caller's location
282/// as a call computes it.
283fn slot_key(core_path: &TokenStream2) -> TokenStream2 {
284    if cfg!(feature = "hot-reload") {
285        return quote! { #core_path::caller_location_key() };
286    }
287    quote! {{
288        const __CRANPOSE_SLOT_KEY: #core_path::Key =
289            #core_path::const_location_key(file!(), line!(), column!());
290        #core_path::noted_location_key(__CRANPOSE_SLOT_KEY, file!(), line!(), column!())
291    }}
292}
293
294fn definition_key_stmt(
295    core_path: &TokenStream2,
296    caller_key_ident: &Ident,
297    name: &Ident,
298) -> TokenStream2 {
299    if cfg!(feature = "hot-reload") {
300        // A hot patch moves definitions and may reuse a static cache for a
301        // different item. Development keys name the definition instead.
302        let name = name.to_string();
303        return quote! {
304            let #caller_key_ident = #core_path::composable_identity_key({
305                const __CRANPOSE_DEFINITION_KEY: #core_path::Key =
306                    #core_path::hot_definition_key(file!(), module_path!(), #name);
307                __CRANPOSE_DEFINITION_KEY
308            });
309        };
310    }
311    quote! {
312        let #caller_key_ident = #core_path::composable_identity_key({
313            struct __CranposeDefinitionMarker;
314            static __CRANPOSE_DEFINITION_KEY: ::std::sync::OnceLock<#core_path::Key> =
315                ::std::sync::OnceLock::new();
316            #core_path::cached_composable_definition_key(
317                &__CRANPOSE_DEFINITION_KEY,
318                file!(),
319                line!(),
320                column!(),
321                ::std::any::TypeId::of::<__CranposeDefinitionMarker>(),
322            )
323        });
324    }
325}
326
327/// Turns a function into a composable: its body runs inside a group keyed by
328/// the call site, and recomposition skips it while its arguments are
329/// unchanged. `#[composable(no_skip)]` always re-runs the body and stores no
330/// parameters: as Compose's inline composables, it composes in its caller's
331/// group, where what it composes is matched by position, as a loop's items
332/// are, so a state it or its content reads recomposes the composable that
333/// called it.
334///
335/// Composables are named in CamelCase, as in Jetpack Compose, so the
336/// generated function carries `#[allow(non_snake_case)]` and callers need no
337/// lint allowance of their own.
338#[proc_macro_attribute]
339pub fn composable(attr: TokenStream, item: TokenStream) -> TokenStream {
340    let attr_tokens = TokenStream2::from(attr);
341    let mut enable_skip = true;
342    let core_path = core_crate_path();
343    if !attr_tokens.is_empty() {
344        match syn::parse2::<Ident>(attr_tokens) {
345            Ok(ident) if ident == "no_skip" => enable_skip = false,
346            Ok(other) => {
347                return syn::Error::new_spanned(other, "unsupported composable attribute")
348                    .to_compile_error()
349                    .into();
350            }
351            Err(err) => {
352                return err.to_compile_error().into();
353            }
354        }
355    }
356
357    let mut func = parse_macro_input!(item as ItemFn);
358
359    let mut param_info: Vec<ParamInfo> = Vec::new();
360
361    for (index, arg) in func.sig.inputs.iter_mut().enumerate() {
362        if let FnArg::Typed(PatType { pat, ty, .. }) = arg {
363            if let Some(reserved) = find_reserved_pattern_ident(pat) {
364                let name = reserved.to_string();
365                return syn::Error::new(
366                    reserved.span(),
367                    format!("`{name}` is reserved by #[composable]"),
368                )
369                .to_compile_error()
370                .into();
371            }
372            let pat_is_mut = matches!(
373                pat.as_ref(),
374                Pat::Ident(pat_ident) if pat_ident.mutability.is_some()
375            );
376            let is_impl_trait = matches!(**ty, Type::ImplTrait(_));
377
378            if is_impl_trait {
379                let ident = if let Pat::Ident(pat_ident) = &**pat {
380                    pat_ident.ident.clone()
381                } else {
382                    Ident::new(&format!("__arg{index}"), Span::mixed_site())
383                };
384                param_info.push(ParamInfo {
385                    ident,
386                    pat: pat.clone(),
387                    ty: ty.as_ref().clone(),
388                    pat_is_mut,
389                    is_impl_trait: true,
390                });
391            } else {
392                let ident = Ident::new(&format!("__arg{index}"), Span::mixed_site());
393                let original_pat: Box<Pat> = pat.clone();
394                **pat = syn::parse_quote! { #ident };
395                param_info.push(ParamInfo {
396                    ident,
397                    pat: original_pat,
398                    ty: ty.as_ref().clone(),
399                    pat_is_mut,
400                    is_impl_trait: false,
401                });
402            }
403        }
404    }
405
406    inject_groups(&core_path, &mut func);
407    let has_rust_abi = match &func.sig.abi {
408        None => true,
409        Some(abi) => abi.name.as_ref().is_some_and(|name| name.value() == "Rust"),
410    };
411    if has_rust_abi {
412        func.attrs.push(syn::parse_quote!(#[track_caller]));
413    }
414    func.attrs.push(syn::parse_quote!(
415        #[allow(non_snake_case, reason = "names follow Compose's casing")]
416    ));
417
418    let scope_label_ident = func.sig.ident.clone();
419    let original_block = func.block.clone();
420    let body_end = func.block.brace_token.span.close();
421    let composer_ident = Ident::new("__composer", Span::mixed_site());
422    let slot_key = slot_key(&core_path);
423    let caller_key_ident = Ident::new("__cranpose_caller_key", Span::mixed_site());
424    let current_scope_ident = Ident::new("__current_scope", Span::mixed_site());
425    let result_ident = Ident::new("__result", Span::mixed_site());
426    let group_ident = Ident::new("__cranpose_group", Span::mixed_site());
427    let key_expr = quote! { #caller_key_ident };
428    let caller_key_stmt = definition_key_stmt(&core_path, &caller_key_ident, &scope_label_ident);
429
430    let rebinds_for_no_skip: Vec<_> = param_info
431        .iter()
432        .map(|info| {
433            let ident = &info.ident;
434            let pat = &info.pat;
435            quote! { let #pat = #ident; }
436        })
437        .collect();
438
439    let return_ty: Type = match &func.sig.output {
440        ReturnType::Default => syn::parse_quote! { () },
441        ReturnType::Type(_, ty) => ty.as_ref().clone(),
442    };
443    let returns_unit = returns_unit(&func.sig.output);
444    let invalidate_return_consumer = if returns_unit || is_node_id_return(&return_ty) {
445        quote! {}
446    } else {
447        quote! { #composer_ident.__invalidate_return_consumer_scope(); }
448    };
449    let _helper_ident = Ident::new(
450        &format!("__cranpose_impl_{}", func.sig.ident),
451        Span::mixed_site(),
452    );
453    let generics = func.sig.generics.clone();
454    let (_impl_generics, _ty_generics, _where_clause) = generics.split_for_impl();
455
456    let _helper_inputs: Vec<TokenStream2> = param_info
457        .iter()
458        .map(|info| {
459            let ident = &info.ident;
460            let ty = &info.ty;
461            quote! { #ident: #ty }
462        })
463        .collect();
464
465    let held: Vec<Option<HeldCallback>> = param_info
466        .iter()
467        .map(|info| HeldCallback::of(&info.ty, &func.sig.generics))
468        .collect();
469    // A borrowed parameter cannot be kept for a later comparison either.
470    let has_unhandled_impl_trait = param_info
471        .iter()
472        .zip(&held)
473        .any(|(info, held)| (info.is_impl_trait && held.is_none()) || borrows(&info.ty));
474
475    if enable_skip && !has_unhandled_impl_trait {
476        let helper_ident = Ident::new(
477            &format!("__cranpose_impl_{}", func.sig.ident),
478            Span::mixed_site(),
479        );
480        let generics = func.sig.generics.clone();
481
482        let param_state_slots: Vec<Ident> = (0..param_info.len())
483            .map(|index| Ident::new(&format!("__param_state_slot{index}"), Span::mixed_site()))
484            .collect();
485        let callbacks: Vec<Option<CallbackParam>> = param_info
486            .iter()
487            .zip(&param_state_slots)
488            .zip(&held)
489            .map(|((info, slot), held)| {
490                CallbackParam::of(&core_path, info, slot, held.as_ref(), &generics)
491            })
492            .collect();
493        let param_erased: Vec<bool> = callbacks
494            .iter()
495            .map(|callback| callback.as_ref().is_some_and(CallbackParam::boxes))
496            .collect();
497
498        let mut strippable: std::collections::BTreeSet<String> = param_info
499            .iter()
500            .zip(&param_erased)
501            .filter(|(info, erased)| **erased && !info.is_impl_trait)
502            .filter_map(|(info, _)| type_bare_generic_ident(&info.ty))
503            .map(Ident::to_string)
504            .collect();
505        loop {
506            use quote::ToTokens;
507            let mut used_elsewhere: Vec<TokenStream2> = Vec::new();
508            for (info, erased) in param_info.iter().zip(&param_erased) {
509                if !*erased {
510                    used_elsewhere.push(info.ty.to_token_stream());
511                }
512            }
513            used_elsewhere.push(return_ty.to_token_stream());
514            for param in &generics.params {
515                match param {
516                    syn::GenericParam::Type(type_param) => {
517                        if !strippable.contains(&type_param.ident.to_string()) {
518                            used_elsewhere.push(type_param.bounds.to_token_stream());
519                            if let Some((_, default)) = &type_param.default {
520                                used_elsewhere.push(default.to_token_stream());
521                            }
522                        }
523                    }
524                    syn::GenericParam::Const(const_param) => {
525                        used_elsewhere.push(const_param.ty.to_token_stream());
526                    }
527                    syn::GenericParam::Lifetime(_) => {}
528                }
529            }
530            if let Some(where_clause) = &generics.where_clause {
531                for predicate in &where_clause.predicates {
532                    if let syn::WherePredicate::Type(pred) = predicate
533                        && let Some(ident) = type_bare_generic_ident(&pred.bounded_ty)
534                        && strippable.contains(&ident.to_string())
535                    {
536                        continue;
537                    }
538                    used_elsewhere.push(predicate.to_token_stream());
539                }
540            }
541            let before = strippable.len();
542            strippable.retain(|name| {
543                !used_elsewhere
544                    .iter()
545                    .any(|tokens| stream_mentions_ident(tokens, name))
546            });
547            if strippable.len() == before {
548                break;
549            }
550        }
551
552        let helper_generics = filter_generics(&generics, &strippable);
553        let (impl_generics, ty_generics, where_clause) = helper_generics.split_for_impl();
554        let ty_generics_turbofish = ty_generics.as_turbofish();
555
556        let helper_inputs: Vec<TokenStream2> = param_info
557            .iter()
558            .zip(&callbacks)
559            .map(|(info, callback)| {
560                let ident = &info.ident;
561                match callback.as_ref().and_then(CallbackParam::boxed_type) {
562                    Some(boxed) => quote! { #ident: ::std::boxed::Box<#boxed> },
563                    None => {
564                        let ty = &info.ty;
565                        quote! { #ident: #ty }
566                    }
567                }
568            })
569            .collect();
570
571        let param_setup: Vec<TokenStream2> = callbacks
572            .iter()
573            .flatten()
574            .map(|callback| callback.setup(&core_path, &composer_ident))
575            .collect();
576        let param_setup_recompose: Vec<TokenStream2> = callbacks
577            .iter()
578            .flatten()
579            .map(|callback| callback.recompose_setup(&core_path, &composer_ident))
580            .collect();
581        let callback_rebinds: Vec<TokenStream2> = callbacks
582            .iter()
583            .flatten()
584            .map(|callback| callback.rebind(&core_path, &composer_ident))
585            .collect();
586
587        let body_inputs: Vec<TokenStream2> = param_info
588            .iter()
589            .zip(&callbacks)
590            .map(|(info, callback)| match callback {
591                Some(callback) => callback.body_input(&core_path),
592                None => {
593                    let pat = &info.pat;
594                    let ty = &info.ty;
595                    quote! { #pat: #ty }
596                }
597            })
598            .collect();
599        let body_args: Vec<&Ident> = param_info
600            .iter()
601            .zip(&callbacks)
602            .map(|(info, callback)| {
603                callback
604                    .as_ref()
605                    .map_or(&info.ident, |callback| callback.slot)
606            })
607            .collect();
608
609        let packed = PackedParams {
610            params: param_info
611                .iter()
612                .zip(&callbacks)
613                .filter(|(info, callback)| callback.is_none() && !info.is_impl_trait)
614                .map(|(info, _)| (&info.ident, &info.ty))
615                .collect(),
616            returns: (!returns_unit).then_some(&return_ty),
617        };
618        // Every parameter here is compared or is a held callback.
619        let skippable = true;
620        let param_setup = packed.with_setup(
621            &core_path,
622            &composer_ident,
623            (&ReturningCall::previous(), skippable),
624            param_setup,
625        );
626        let param_setup_recompose =
627            packed.with_slot(&core_path, &composer_ident, param_setup_recompose);
628        let reads_for_recompose = packed.reads(&core_path, &composer_ident);
629
630        let body_ident = Ident::new(
631            &format!("__cranpose_body_{}", func.sig.ident),
632            Span::mixed_site(),
633        );
634        let body_call = quote! {
635            #body_ident #ty_generics_turbofish (#composer_ident #(, #body_args)*)
636        };
637
638        let recompose_fn_ident = Ident::new(
639            &format!("__cranpose_recompose_{}", func.sig.ident),
640            Span::mixed_site(),
641        );
642
643        let recompose_fn = quote! { #recompose_fn_ident #ty_generics_turbofish };
644
645        let returning = ReturningCall {
646            core_path: &core_path,
647            composer: &composer_ident,
648            group: &group_ident,
649            current_scope: &current_scope_ident,
650            scope_label: &scope_label_ident,
651            return_ty: &return_ty,
652            slot_key: &slot_key,
653            packed: &packed,
654            skippable,
655            body_call: &body_call,
656        };
657        let helper_body = if returns_unit {
658            unit_helper_body(&returning, &param_setup, &recompose_fn)
659        } else {
660            let setter =
661                recompose_setter(&group_ident, &recompose_fn, (&core_path, &composer_ident));
662            returning.helper_body(&param_setup, &setter)
663        };
664
665        let recompose_fn_body = if returns_unit {
666            quote! {
667                #(#param_setup_recompose)*
668                #(#reads_for_recompose)*
669                #body_call;
670            }
671        } else {
672            returning.recompose_body(
673                &param_setup_recompose,
674                &reads_for_recompose,
675                &invalidate_return_consumer,
676            )
677        };
678
679        let slot_origin = hot_slot_origin(&core_path, &func.sig.ident, body_end);
680        let recompose_fn = quote! {
681            #[allow(non_snake_case, reason = "names follow Compose's casing")]
682            fn #recompose_fn_ident #impl_generics (
683                #composer_ident: &#core_path::Composer
684            ) -> #return_ty #where_clause {
685                #slot_origin
686                #recompose_fn_body
687            }
688        };
689
690        let helper_fn = quote! {
691            #[allow(non_snake_case, clippy::too_many_arguments, reason = "the helper takes the composable's own parameters")]
692            fn #helper_ident #impl_generics (
693                #composer_ident: &#core_path::Composer,
694                #group_ident: &#core_path::ComposableGroup<'_>
695                #(, #helper_inputs)*
696            ) -> #return_ty #where_clause {
697                #slot_origin
698                let #current_scope_ident = #group_ident.scope();
699                #helper_body
700            }
701        };
702
703        let body_fn = quote! {
704            #[allow(non_snake_case, clippy::too_many_arguments, reason = "the helper takes the composable's own parameters")]
705            fn #body_ident #impl_generics (
706                #composer_ident: &#core_path::Composer
707                #(, #body_inputs)*
708            ) -> #return_ty #where_clause {
709                #(#callback_rebinds)*
710                let _cranpose_source_scope = #core_path::__source_scope(
711                    stringify!(#scope_label_ident), file!(), line!(), env!("CARGO_MANIFEST_DIR"),
712                );
713                #original_block
714            }
715        };
716
717        let wrapper_args: Vec<TokenStream2> = param_info
718            .iter()
719            .zip(&param_erased)
720            .map(|(info, erased)| {
721                let ident = &info.ident;
722                if *erased {
723                    quote! { ::std::boxed::Box::new(#ident) }
724                } else {
725                    quote! { #ident }
726                }
727            })
728            .collect();
729
730        let open_group = open_group_fn(returns_unit);
731        let wrapped = quote!({
732            #caller_key_stmt
733            let #composer_ident = #core_path::__current_composer();
734            let #group_ident = #composer_ident.#open_group(#key_expr);
735            let #result_ident = #helper_ident(
736                &#composer_ident,
737                &#group_ident
738                #(, #wrapper_args)*
739            );
740            #group_ident.close();
741            #result_ident
742        });
743        *func.block = syn::parse2(wrapped).expect("failed to build block");
744        TokenStream::from(quote! {
745            #body_fn
746            #recompose_fn
747            #helper_fn
748            #func
749        })
750    } else {
751        let wrapped = quote!({
752            #core_path::with_current_composer(|#composer_ident: &#core_path::Composer| {
753                #(#rebinds_for_no_skip)*
754                let _cranpose_source_scope = #core_path::__inline_source_scope(
755                    stringify!(#scope_label_ident), file!(), line!(), env!("CARGO_MANIFEST_DIR"),
756                );
757                #original_block
758            })
759        });
760        *func.block = syn::parse2(wrapped).expect("failed to build block");
761        TokenStream::from(quote! { #func })
762    }
763}
764
765/// Adds branch groups; with the development-only `hot-reload` feature the
766/// body also marks its definition as the origin for call-site keys.
767fn inject_groups(core_path: &TokenStream2, func: &mut ItemFn) {
768    let hot_reload = cfg!(feature = "hot-reload");
769    branch_groups::inject_branch_groups_with(
770        core_path,
771        &mut func.block,
772        &func.sig.ident,
773        hot_reload,
774    );
775    // A hot patch keeps the slots of unchanged code, so a kept chain whose
776    // constants it edits would stay as it was.
777    if !hot_reload {
778        constant_modifiers::keep_constant_modifiers(core_path, &mut func.block);
779    }
780    if hot_reload {
781        let end = func.block.brace_token.span.close();
782        let origin = branch_groups::hot_origin_stmt(core_path, &func.sig.ident, end);
783        func.block
784            .stmts
785            .insert(0, syn::parse2(origin).expect("hot origin statement"));
786    }
787}
788
789// Whether a function returns `()`.
790fn returns_unit(output: &ReturnType) -> bool {
791    match output {
792        ReturnType::Default => true,
793        ReturnType::Type(_, ty) => {
794            matches!(ty.as_ref(), Type::Tuple(tuple) if tuple.elems.is_empty())
795        }
796    }
797}
798
799// The statement that gives the group of a composable that returns a value
800// its recompose function: a wrapper that drops the value.
801fn recompose_setter(
802    group: &Ident,
803    recompose_fn: &TokenStream2,
804    (core_path, composer): (&TokenStream2, &Ident),
805) -> TokenStream2 {
806    quote! {
807        #group.set_recompose_fn(|#composer: &#core_path::Composer| {
808            let _ = #recompose_fn(#composer);
809        });
810    }
811}
812
813// The helper body of a composable that returns nothing: one call gives the
814// group its recompose function, a plain function with no box, vtable or call
815// shim per composable, and says whether the body runs. It skips the body
816// when no parameter changed, and leaves it for a later frame when the group
817// opened in pausable content past the frame's budget.
818fn unit_helper_body(
819    call: &ReturningCall<'_>,
820    param_setup: &[TokenStream2],
821    recompose_fn: &TokenStream2,
822) -> TokenStream2 {
823    let (core_path, group, current_scope) = (call.core_path, call.group, call.current_scope);
824    let (scope_label, body_call) = (call.scope_label, call.body_call);
825    quote! {
826        #core_path::debug_label_current_scope(stringify!(#scope_label));
827        let mut __changed = #current_scope.should_recompose();
828        #(#param_setup)*
829        if !#group.should_execute(__changed.into(), #recompose_fn) {
830            return;
831        }
832        #body_call
833    }
834}
835
836// The composer method that opens a composable's group: only a composable that
837// returns nothing may leave its first body for a later frame.
838fn open_group_fn(returns_unit: bool) -> Ident {
839    let name = if returns_unit {
840        "__open_pausable_group"
841    } else {
842        "__open_composable_group"
843    };
844    Ident::new(name, Span::call_site())
845}
846
847/// Parameter and return slots are keyed by call sites in generated code. With
848/// hot reload those keys follow the current origin, so the first composition
849/// and an independent recomposition both set the composable's own origin.
850fn hot_slot_origin(core_path: &TokenStream2, name: &Ident, end: Span) -> TokenStream2 {
851    if cfg!(feature = "hot-reload") {
852        branch_groups::hot_origin_stmt(core_path, name, end)
853    } else {
854        TokenStream2::new()
855    }
856}
857
858fn find_reserved_pattern_ident(pat: &Pat) -> Option<&Ident> {
859    use syn::visit::Visit;
860
861    struct Scan<'ast> {
862        found: Option<&'ast Ident>,
863    }
864    impl<'ast> Visit<'ast> for Scan<'ast> {
865        fn visit_pat_ident(&mut self, node: &'ast syn::PatIdent) {
866            if self.found.is_none() {
867                let name = node.ident.to_string();
868                if name == "__composer" || name.starts_with("__cranpose") {
869                    self.found = Some(&node.ident);
870                }
871            }
872            syn::visit::visit_pat_ident(self, node);
873        }
874    }
875    let mut scan = Scan { found: None };
876    scan.visit_pat(pat);
877    scan.found
878}
879
880#[cfg(test)]
881#[path = "tests/macros_tests.rs"]
882mod tests;