Skip to main content

pax_macro/
lib.rs

1extern crate proc_macro;
2extern crate proc_macro2;
3mod launch;
4mod root_crate;
5mod source;
6
7/// Prepare a separately linked UI library. With no arguments, select this
8/// package's library or its `package.metadata.pax.cartridge` selection;
9/// `init!(my_ui)` selects an explicit dependency.
10/// Initializes the selected root in the current native process. `run()` enters
11/// its platform application loop. Neither operation parses application arguments
12/// or starts build tools or development servers, including with `designtime`.
13/// Use `init_with!` to provide explicit startup options.
14#[proc_macro]
15pub fn init(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
16    launch::init(input, false)
17}
18
19/// Prepare a UI with `InitOptions`: `init_with!(options)` or
20/// `init_with!(my_ui, options)` for a separate UI dependency.
21/// Always initializes in the current native process; never parses development
22/// arguments or starts a workstation build/server.
23#[proc_macro]
24pub fn init_with(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
25    launch::init(input, true)
26}
27
28#[doc(hidden)]
29#[proc_macro]
30pub fn __pax_launch(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
31    launch::expand(input)
32}
33mod templating;
34use std::fs::File;
35use std::io::Read;
36use std::path::Path;
37use std::str::FromStr;
38use std::{env, path::PathBuf}; // Necessary for `writeln!` macro to work
39
40use proc_macro2::{Ident, Span, TokenStream};
41use quote::{format_ident, quote, ToTokens};
42
43use syn::punctuated::Punctuated;
44use templating::{
45    EnumVariantDefinition, InternalDefinitions, StaticPropertyDefinition, TemplateArgsCartridge,
46    TemplateArgsDerivePax, TemplateBuildConfig,
47};
48
49use sailfish::TemplateOnce;
50
51fn is_root_crate() -> bool {
52    if env_flag_explicitly_disabled("PAX_BUILD_CARTRIDGE") {
53        return false;
54    }
55    root_crate::is_application_crate(
56        env::var_os("PAX_CARTRIDGE_MANIFEST_DIR")
57            .as_deref()
58            .map(Path::new),
59        env::var_os("PAX_DIR").as_deref().map(Path::new),
60        &env::var_os("CARGO_MANIFEST_DIR")
61            .map(PathBuf::from)
62            .unwrap_or_default(),
63        env::var_os("CARGO_PRIMARY_PACKAGE").is_some(),
64    )
65}
66
67/// Declares a Pax UI that can be launched as an application or embedded in a
68/// host, using the selected root component and optional initializer.
69///
70/// ```ignore
71/// pub mod ui;
72/// pax_kit::runnable! { root: crate::ui::App, init: crate::ui::init }
73/// ```
74///
75/// `init` is an ordinary `fn(&InitContext) -> AppResult<Root>`; omitting it
76/// constructs `Root::default()`. Paths resolve at the declaration, and normal
77/// Rust visibility applies. Multiple declarations share one catalog; repeated
78/// roots are deduplicated and each root has one initializer. Select a root with
79/// `InitOptions::entry` when more than one is declared. Do not mix `runnable!`
80/// with `#[main]` in the same library.
81///
82/// Rust selects conditional source in each owning crate, including dependency
83/// features and custom cfgs. The macro compiles the selected program during
84/// ordinary Cargo compilation, without a user build script or nested build.
85/// Neither compilation nor dependency use evaluates initializers or mounts UI.
86/// Platform exports remain in the separately linked library cartridge.
87/// Direct `pax-engine` users can set `engine_import_path: pax_engine`.
88#[proc_macro]
89pub fn runnable(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
90    source::runnable(input)
91}
92
93fn render_cartridge(root: &str, initializer: Option<String>, engine: &str) -> String {
94    render_generated_cartridge(
95        root,
96        initializer,
97        engine,
98        String::new(),
99        template_build_config(),
100    )
101}
102
103fn render_generated_cartridge(
104    root: &str,
105    initializer: Option<String>,
106    engine: &str,
107    generated: String,
108    build_config: TemplateBuildConfig,
109) -> String {
110    let initializer = initializer.unwrap_or_else(|| format!("|_| Ok(<{root}>::default())"));
111    // Keep the signature check and path resolution at the declaration's scope,
112    // including when the library is reused without its platform adapter.
113    let mut output = format!(
114        r#"
115        #[allow(unused_imports)]
116        use {root} as __PaxSelectedRoot;
117        #[allow(dead_code)]
118        fn __pax_initialize(context: &{engine}::api::InitContext) -> {engine}::api::AppResult<__PaxSelectedRoot> {{
119            ({initializer})(context)
120        }}
121        const _: Option<&str> = option_env!("PAX_DIR");
122        const _: Option<&str> = option_env!("PAX_CARTRIDGE_MANIFEST_DIR");
123        const _: Option<&str> = option_env!("PAX_BUILD_CARTRIDGE");
124        const _: Option<&str> = option_env!("PAX_BUILD_TARGET");
125        const _: Option<&str> = option_env!("PAX_BUILD_DESIGNTIME");
126        const _: Option<&str> = option_env!("CARGO_PRIMARY_PACKAGE");
127    "#
128    );
129    if generated.is_empty() && !is_root_crate() {
130        return output;
131    }
132    if !(build_config.web || build_config.macos || build_config.ios || build_config.ipados) {
133        return output;
134    }
135    let snippet = if cfg!(feature = "designtime")
136        && env_flag_explicitly_disabled("PAX_BUILD_DESIGNTIME")
137    {
138        "compile_error!(\"Pax release builds cannot include the `pax-engine/designtime` feature. Remove the transitive designtime feature activation or build this app in debug mode.\");".into()
139    } else if !generated.is_empty() {
140        generated
141    } else if let Some(pax_dir) = env::var_os("PAX_DIR") {
142        let path = PathBuf::from(pax_dir).join("cartridge.partial.rs");
143        format!("include!({:?});", path.to_string_lossy())
144    } else {
145        "compile_error!(\"PAX_DIR was not set for the selected Pax cartridge. Build the application through pax-cli to generate its cartridge.\");".into()
146    };
147    let adapter = TemplateArgsCartridge {
148        catalog: false,
149        web_entrypoint: "init_web".into(),
150        root_path: "super::__PaxSelectedRoot".into(),
151        initializer: "super::__pax_initialize".into(),
152        cartridge_snippet: snippet,
153        engine_import_path: engine.into(),
154        build_config,
155    }
156    .render_once()
157    .unwrap();
158    let facade_import = if engine.replace(' ', "") == "pax_kit::pax_engine" {
159        "use pax_kit::*;"
160    } else {
161        ""
162    };
163    output.push_str(&format!(
164        r#"
165        #[allow(unused_imports)]
166        mod __pax_cartridge {{
167            use super::*;
168            {facade_import}
169            use {engine}::*;
170            use {engine}::api::*;
171            {adapter}
172        }}
173        #[allow(unused_imports)]
174        pub use __pax_cartridge::*;
175    "#
176    ));
177    output
178}
179
180fn env_flag(name: &str) -> bool {
181    env::var(name)
182        .map(|value| {
183            let normalized = value.to_ascii_lowercase();
184            matches!(normalized.as_str(), "1" | "true" | "yes" | "on")
185        })
186        .unwrap_or(false)
187}
188
189fn env_flag_explicitly_disabled(name: &str) -> bool {
190    env::var(name)
191        .map(|value| {
192            let normalized = value.to_ascii_lowercase();
193            matches!(normalized.as_str(), "0" | "false" | "no" | "off")
194        })
195        .unwrap_or(false)
196}
197
198fn cargo_feature_enabled(feature: &str) -> bool {
199    env::var_os(format!(
200        "CARGO_FEATURE_{}",
201        feature.replace('-', "_").to_ascii_uppercase()
202    ))
203    .is_some()
204}
205
206pub(crate) fn designtime_enabled() -> bool {
207    match std::env::var("PAX_BUILD_DESIGNTIME") {
208        Ok(_) => env_flag("PAX_BUILD_DESIGNTIME"),
209        Err(_) => cfg!(feature = "designtime"),
210    }
211}
212
213fn template_build_config() -> TemplateBuildConfig {
214    let pax_build_target = env::var("PAX_BUILD_TARGET").unwrap_or_default();
215    let designtime = designtime_enabled();
216
217    TemplateBuildConfig {
218        web: pax_build_target == "web" || cargo_feature_enabled("web"),
219        macos: pax_build_target == "macos" || cargo_feature_enabled("macos"),
220        ios: pax_build_target == "ios" || cargo_feature_enabled("ios"),
221        ipados: pax_build_target == "ipados",
222        designtime,
223    }
224}
225
226use syn::{
227    parse_macro_input, Data, DeriveInput, Field, Fields, FnArg, GenericArgument, ImplItem,
228    ImplItemMethod, ItemImpl, Lit, Meta, PatType, PathArguments, Signature, Token, Type,
229};
230
231fn pax_primitive(
232    input_parsed: &DeriveInput,
233    _primitive_instance_import_path: String,
234    is_custom_interpolatable: bool,
235    is_custom_coercion_rules: bool,
236    can_derive_identity_roundtrip: bool,
237    engine_import_path: String,
238) -> proc_macro2::TokenStream {
239    let _original_tokens = quote! { #input_parsed }.to_string();
240    let pascal_identifier = input_parsed.ident.to_string();
241    let is_enum = match &input_parsed.data {
242        Data::Enum(_) => true,
243        _ => false,
244    };
245
246    let internal_definitions = get_internal_definitions_from_tokens(&input_parsed.data);
247
248    let output = TemplateArgsDerivePax {
249        cartridge: String::new(),
250        internal_definitions,
251        pascal_identifier,
252        is_custom_interpolatable,
253        is_custom_coercion_rules,
254        can_derive_identity_roundtrip,
255        _is_enum: is_enum,
256        engine_import_path,
257    }
258    .render_once()
259    .unwrap()
260    .to_string();
261
262    TokenStream::from_str(&output).unwrap().into()
263}
264
265fn pax_struct_only_component(
266    input_parsed: &DeriveInput,
267    is_custom_interpolatable: bool,
268    is_custom_coercion_rules: bool,
269    can_derive_identity_roundtrip: bool,
270    engine_import_path: String,
271) -> proc_macro2::TokenStream {
272    let pascal_identifier = input_parsed.ident.to_string();
273    let is_enum = match &input_parsed.data {
274        Data::Enum(_) => true,
275        _ => false,
276    };
277
278    let internal_definitions = get_internal_definitions_from_tokens(&input_parsed.data);
279
280    let output = TemplateArgsDerivePax {
281        cartridge: String::new(),
282
283        pascal_identifier: pascal_identifier.clone(),
284        internal_definitions,
285        is_custom_interpolatable,
286        is_custom_coercion_rules,
287        can_derive_identity_roundtrip,
288        _is_enum: is_enum,
289        engine_import_path,
290    }
291    .render_once()
292    .unwrap()
293    .to_string();
294
295    TokenStream::from_str(&output).unwrap().into()
296}
297
298/// Returns the type associated with a field, as well as a flag describing whether the property
299/// type is wrapped in Property<T>
300fn is_rust_field(field: &Field) -> bool {
301    field.attrs.iter().any(|attr| {
302        attr.path.is_ident("pax")
303            && attr
304                .parse_args::<syn::Ident>()
305                .map(|name| name == "rust")
306                .unwrap_or(false)
307    })
308}
309
310fn get_field_type(f: &Field) -> Option<(Type, bool)> {
311    if is_rust_field(f) {
312        return None;
313    }
314    let mut ret = None;
315    if let Type::Path(tp) = &f.ty {
316        match tp.qself {
317            None => {
318                tp.path.segments.iter().for_each(|ps| {
319                    //Only generate parsing logic for types wrapped in `Property<>`
320                    if ps.ident.to_string().ends_with("Property") {
321                        if let PathArguments::AngleBracketed(abga) = &ps.arguments {
322                            abga.args.iter().for_each(|abgaa| {
323                                if let GenericArgument::Type(gat) = abgaa {
324                                    ret = Some((gat.to_owned(), true));
325                                }
326                            })
327                        }
328                    }
329                });
330                if ret.is_none() {
331                    //ret is still None, so we will assume this is a simple type and pass it forward
332                    ret = Some((f.ty.to_owned(), false));
333                }
334            }
335            _ => {}
336        };
337    }
338    ret
339}
340
341/// Break apart a raw Property inner type (`T<K>` for `Property<T<K>>`):
342/// into a list of `rustc` resolvable identifiers, possibly namespace-nested.
343/// The macro template uses these paths when generating value coercion code.
344fn get_scoped_resolvable_types(t: &Type) -> (Vec<String>, String) {
345    let mut accum: Vec<String> = vec![];
346    recurse_get_scoped_resolvable_types(t, &mut accum);
347
348    //the recursion above was post-order, so we will assume
349    //the final element is root
350    let root_scoped_resolvable_type = accum.get(accum.len() - 1).unwrap().clone();
351
352    (accum, root_scoped_resolvable_type)
353}
354
355fn recurse_get_scoped_resolvable_types(t: &Type, accum: &mut Vec<String>) {
356    match t {
357        Type::Path(tp) => {
358            match tp.qself {
359                None => {
360                    let mut accumulated_scoped_resolvable_type = "".to_string();
361                    tp.path.segments.iter().for_each(|ps| {
362                        match &ps.arguments {
363                            PathArguments::AngleBracketed(abga) => {
364                                if accumulated_scoped_resolvable_type.ne("") {
365                                    accumulated_scoped_resolvable_type = accumulated_scoped_resolvable_type.clone() + "::"
366                                }
367                                let ident = ps.ident.to_token_stream().to_string();
368                                let turbofish_contents = ps.to_token_stream()
369                                    .to_string()
370                                    .replacen(&ident, "", 1)
371                                    .replace(" ", "");
372
373                                accumulated_scoped_resolvable_type =
374                                    accumulated_scoped_resolvable_type.clone() +
375                                        &ident +
376                                        "::" +
377                                        &turbofish_contents;
378
379                                abga.args.iter().for_each(|abgaa| {
380                                    match abgaa {
381                                        GenericArgument::Type(gat) => {
382                                            //break apart, for example, `Vec` from `Vec<(usize, Size)` >
383                                            recurse_get_scoped_resolvable_types(gat, accum);
384                                        },
385                                        //FUTURE: _might_ need to extract and deal with lifetimes, most notably where the "full string type" is used.
386                                        //      May be a non-issue, but this is where that data would need to be extracted.
387                                        //      Finally: might want to choose whether to require that any lifetimes used in Pax `Property<...>` are compatible with `'static`
388                                        _ => { }
389                                    };
390                                })
391                            },
392                            PathArguments::Parenthesized(_) => {unimplemented!("Parenthesized path arguments (for example, Fn types) not yet supported inside Pax `Property<...>`")},
393                            PathArguments::None => {
394                                //PathSegments without Args are vanilla segments, like
395                                //`std` or `collections`.  While visiting path segments, assemble our
396                                //accumulated_scoped_resolvable_type
397                                if accumulated_scoped_resolvable_type.ne("") {
398                                    accumulated_scoped_resolvable_type = accumulated_scoped_resolvable_type.clone() + "::"
399                                }
400                                accumulated_scoped_resolvable_type = accumulated_scoped_resolvable_type.clone() + &ps.to_token_stream().to_string();
401                            }
402                        }
403                    });
404
405                    accum.push(accumulated_scoped_resolvable_type);
406                }
407                _ => {
408                    unimplemented!("Self-types not yet supported with Pax `Property<...>`")
409                }
410            }
411        }
412        //For example, the contained tuple: `Property<(usize, Vec<String>)>`
413        Type::Tuple(t) => {
414            t.elems.iter().for_each(|tuple_elem| {
415                recurse_get_scoped_resolvable_types(tuple_elem, accum);
416            });
417        }
418        _ => {
419            unimplemented!("Unsupported Type::Path {}", t.to_token_stream().to_string());
420        }
421    }
422}
423
424fn index_to_ascii_lowercase(index: usize) -> char {
425    (b'a' + (index as u8)) as char
426}
427
428fn get_internal_definitions_from_tokens(data: &Data) -> InternalDefinitions {
429    let ret = match data {
430        Data::Struct(ref data) => {
431            match data.fields {
432                Fields::Named(ref fields) => {
433                    let mut spds = vec![];
434                    fields.named.iter().for_each(|f| {
435                        let field_name = f.ident.as_ref().unwrap();
436                        let _field_type = match get_field_type(f) {
437                            None => { /* noop */ }
438                            Some(ty) => {
439                                let type_name = quote!(#(ty.0)).to_string().replace(" ", "");
440
441                                let (scoped_resolvable_types, root_scoped_resolvable_type) =
442                                    get_scoped_resolvable_types(&ty.0);
443                                let pascal_identifier =
444                                    type_name.split("::").last().unwrap().to_string();
445                                spds.push(StaticPropertyDefinition {
446                                    original_type: type_name,
447                                    field_name: quote!(#field_name).to_string(),
448                                    scoped_resolvable_types,
449                                    root_scoped_resolvable_type,
450                                    pascal_identifier,
451                                    is_property_wrapped: ty.1,
452                                    is_enum: false,
453                                })
454                            }
455                        };
456                    });
457                    InternalDefinitions::Struct(spds)
458                }
459                _ => {
460                    unimplemented!("Pax may only be attached to `struct`s with named fields");
461                }
462            }
463        }
464        Data::Enum(ref data) => {
465            let mut evds = vec![];
466            data.variants.iter().for_each(|variant| {
467                let variant_name = variant.ident.to_string();
468                let mut variant_fields = vec![];
469                for (i, f) in variant.fields.iter().enumerate() {
470                    if let Some(ty) = get_field_type(f) {
471                        let original_type = quote!(#(ty.0)).to_string().replace(" ", "");
472                        let (scoped_resolvable_types, root_scoped_resolvable_type) =
473                            get_scoped_resolvable_types(&ty.0);
474                        let pascal_identifier =
475                            original_type.split("::").last().unwrap().to_string();
476                        variant_fields.push(StaticPropertyDefinition {
477                            original_type,
478                            field_name: index_to_ascii_lowercase(i).to_string(),
479                            scoped_resolvable_types,
480                            root_scoped_resolvable_type,
481                            pascal_identifier,
482                            is_property_wrapped: ty.1,
483                            is_enum: true,
484                        })
485                    }
486                }
487                evds.push(EnumVariantDefinition {
488                    variant_name,
489                    variant_fields,
490                });
491            });
492
493            InternalDefinitions::Enum(evds)
494        }
495
496        _ => {
497            unreachable!("Pax may only be attached to `struct`s")
498        }
499    };
500
501    ret
502}
503
504fn pax_full_component(
505    _raw_pax: String,
506    input_parsed: &DeriveInput,
507    is_main_component: bool,
508    initializer: Option<String>,
509    include_fix: Option<TokenStream>,
510    is_custom_interpolatable: bool,
511    is_custom_coercion_rules: bool,
512    can_derive_identity_roundtrip: bool,
513    _associated_pax_file_path: Option<PathBuf>,
514    engine_import_path: String,
515) -> proc_macro2::TokenStream {
516    let pascal_identifier = input_parsed.ident.to_string();
517    let is_enum = match &input_parsed.data {
518        Data::Enum(_) => true,
519        _ => false,
520    };
521
522    let internal_definitions = get_internal_definitions_from_tokens(&input_parsed.data);
523
524    let cartridge = if is_main_component {
525        render_cartridge(&pascal_identifier, initializer, &engine_import_path)
526    } else {
527        String::new()
528    };
529    let output = TemplateArgsDerivePax {
530        cartridge,
531        pascal_identifier,
532        internal_definitions,
533        is_custom_interpolatable,
534        is_custom_coercion_rules,
535        can_derive_identity_roundtrip,
536        _is_enum: is_enum,
537        engine_import_path,
538    }
539    .render_once()
540    .unwrap()
541    .to_string();
542
543    let ret: proc_macro2::TokenStream = TokenStream::from_str(&output).unwrap().into();
544    if !include_fix.is_none() {
545        quote! {
546            #include_fix
547            #ret
548        }
549    } else {
550        ret
551    }
552    .into()
553}
554
555struct Config {
556    is_main_component: bool,
557    initializer: Option<String>,
558    file_path: Option<String>,
559    svg_path: Option<String>,
560    inlined_contents: Option<String>,
561    custom_values: Option<Vec<String>>,
562    engine_import_path: Option<String>,
563    primitive_instance_import_path: Option<String>,
564    is_primitive: bool,
565    has_helpers: bool,
566}
567
568fn parse_config(attrs: &mut Vec<syn::Attribute>) -> Config {
569    let mut config = Config {
570        is_main_component: false,
571        initializer: None,
572        file_path: None,
573        svg_path: None,
574        inlined_contents: None,
575        custom_values: None,
576        primitive_instance_import_path: None,
577        engine_import_path: None,
578        is_primitive: false,
579        has_helpers: false,
580    };
581
582    // iterate through `derive macro helper attributes` to gather config & args
583    // remove the ones we use, don't remove the ones we don't
584    attrs.retain(|attr| {
585        match attr.path.get_ident() {
586            Some(s) if s == "init" => {
587                let path: syn::Path = attr
588                    .parse_args()
589                    .expect("#[init(path)] requires one Rust function path");
590                config.initializer = Some(quote! { #path }.to_string());
591                return false;
592            }
593            Some(s) if s == "file" => {
594                if let Ok(Meta::List(meta_list)) = attr.parse_meta() {
595                    if let Some(nested_meta) = meta_list.nested.first() {
596                        if let syn::NestedMeta::Lit(Lit::Str(file_str)) = nested_meta {
597                            config.file_path = Some(file_str.value());
598                            return false;
599                        }
600                    }
601                }
602            }
603            Some(s) if s == "svg" => {
604                if let Ok(Meta::List(meta_list)) = attr.parse_meta() {
605                    if let Some(nested_meta) = meta_list.nested.first() {
606                        if let syn::NestedMeta::Lit(Lit::Str(file_str)) = nested_meta {
607                            config.svg_path = Some(file_str.value());
608                            return false;
609                        }
610                    }
611                }
612            }
613            Some(s) if s == "engine_import_path" => {
614                if let Ok(Meta::List(meta_list)) = attr.parse_meta() {
615                    if let Some(nested_meta) = meta_list.nested.first() {
616                        if let syn::NestedMeta::Lit(Lit::Str(engine_import_path)) = nested_meta {
617                            config.engine_import_path = Some(engine_import_path.value());
618                            return false;
619                        }
620                    }
621                }
622            }
623            Some(s) if s == "primitive" => {
624                if let Ok(Meta::List(meta_list)) = attr.parse_meta() {
625                    if let Some(nested_meta) = meta_list.nested.first() {
626                        if let syn::NestedMeta::Lit(Lit::Str(file_str)) = nested_meta {
627                            config.primitive_instance_import_path = Some(file_str.value());
628                            config.is_primitive = true;
629                            return false;
630                        }
631                    }
632                }
633            }
634            Some(s) if s == "route_branch" => {
635                return false;
636            }
637            Some(s) if s == "inlined" => {
638                let tokens = attr.tokens.clone();
639                let mut content = proc_macro2::TokenStream::new();
640
641                for token in tokens {
642                    if let proc_macro2::TokenTree::Group(group) = token {
643                        if group.delimiter() == proc_macro2::Delimiter::Parenthesis {
644                            content.extend(group.stream());
645                        }
646                    }
647                }
648
649                if !content.is_empty() {
650                    config.inlined_contents = Some(content.to_string());
651                    return false;
652                }
653            }
654            Some(s) if s == "has_helpers" => {
655                config.has_helpers = true;
656                return false;
657            }
658            _ => {
659                if let Ok(Meta::Path(path)) = attr.parse_meta() {
660                    if path.is_ident("main") {
661                        config.is_main_component = true;
662                        return false;
663                    }
664                } else if let Ok(Meta::List(meta_list)) = attr.parse_meta() {
665                    if meta_list.path.is_ident("custom") {
666                        let values: Vec<String> = meta_list
667                            .nested
668                            .into_iter()
669                            .filter_map(|nested_meta| {
670                                if let syn::NestedMeta::Meta(Meta::Path(path)) = nested_meta {
671                                    path.get_ident().map(|ident| ident.to_string())
672                                } else {
673                                    None
674                                }
675                            })
676                            .collect();
677                        config.custom_values = Some(values);
678                        return false;
679                    }
680                }
681            }
682        }
683        true
684    });
685
686    config
687}
688
689fn validate_config(
690    input: &syn::DeriveInput,
691    config: &Config,
692) -> Result<(), proc_macro::TokenStream> {
693    let template_source_count = [
694        config.file_path.is_some(),
695        config.svg_path.is_some(),
696        config.inlined_contents.is_some(),
697    ]
698    .into_iter()
699    .filter(|present| *present)
700    .count();
701    if template_source_count > 1 {
702        return Err(syn::Error::new_spanned(
703            input.ident.clone(),
704            "`#[file(...)]`, `#[svg(...)]`, and `#[inlined(...)]` attributes cannot be used together",
705        )
706        .to_compile_error()
707        .into());
708    }
709    if template_source_count == 0 && config.is_main_component {
710        return Err(syn::Error::new_spanned(
711            input.ident.clone(),
712            "Main (application-root) components must specify a Pax template source, e.g. #[file(\"some-file.pax\")], #[svg(\"some-file.svg\")], or #[inlined(<SomePax />)]",
713        )
714        .to_compile_error()
715        .into());
716    }
717    if config.is_primitive && template_source_count > 0 {
718        const ERR: &str = "Primitives cannot have attached templates. Instead, specify a fully qualified Rust import path pointing to the `impl RenderNode` struct for this primitive.";
719        return Err(syn::Error::new_spanned(input.ident.clone(), ERR)
720            .to_compile_error()
721            .into());
722    }
723    Ok(())
724}
725
726#[proc_macro_attribute]
727pub fn pax(
728    args: proc_macro::TokenStream,
729    input: proc_macro::TokenStream,
730) -> proc_macro::TokenStream {
731    let _ = args;
732    source::pax(input)
733}
734
735#[doc(hidden)]
736#[proc_macro]
737pub fn __pax_resume(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
738    source::resume(input)
739}
740
741#[doc(hidden)]
742#[proc_macro_attribute]
743pub fn __pax_expand(
744    _args: proc_macro::TokenStream,
745    input: proc_macro::TokenStream,
746) -> proc_macro::TokenStream {
747    let mut input = parse_macro_input!(input as DeriveInput);
748
749    let pascal_identifier = input.ident.to_string();
750    if input
751        .attrs
752        .iter()
753        .any(|attr| attr.path.is_ident("application"))
754    {
755        return quote! { compile_error!("#[application(Type)] has been removed; use #[init(path)] with an ordinary fn(&InitContext) -> AppResult<Root>"); }.into();
756    }
757    let config = parse_config(&mut input.attrs);
758    if config.initializer.is_some() && !config.is_main_component {
759        return quote! { compile_error!("Move #[init(path)] into runnable! { root: YourRoot, init: path }, or use it with the legacy #[main] shorthand"); }
760            .into();
761    }
762    validate_config(&input, &config).unwrap();
763
764    let mut trait_impls = vec!["Clone", "Default", "Serialize", "Deserialize", "Debug"];
765
766    let mut is_custom_interpolatable = false;
767    let mut is_custom_coercion_rules = false;
768
769    let engine_import_path = match config.engine_import_path {
770        Some(prefix) => prefix,
771        None => "pax_kit::pax_engine".to_string(),
772    };
773
774    //wipe out the above derives if `#[custom(...)]` attrs are set
775    if let Some(custom) = config.custom_values {
776        let custom_str: Vec<&str> = custom.iter().map(String::as_str).collect();
777        trait_impls.retain(|v| !custom_str.contains(v));
778
779        if custom.contains(&"Interpolatable".to_string()) {
780            is_custom_interpolatable = true;
781        }
782        if custom.contains(&"CoercionRules".to_string()) {
783            is_custom_coercion_rules = true;
784        }
785    }
786
787    // Non-path fields are currently omitted by value conversion. A typed copy
788    // would retain them instead, so it is not an equivalent roundtrip. Custom
789    // Default may also perform work that conversion must continue to execute.
790    let all_fields_represented = match &input.data {
791        Data::Struct(data) => data.fields.iter().all(|f| get_field_type(f).is_some()),
792        Data::Enum(data) => data
793            .variants
794            .iter()
795            .all(|v| v.fields.iter().all(|f| get_field_type(f).is_some())),
796        _ => false,
797    };
798    let can_derive_identity_roundtrip = all_fields_represented && trait_impls.contains(&"Default");
799
800    let is_pax_file = config.file_path.is_some();
801    let is_pax_svg = config.svg_path.is_some();
802    let is_pax_inlined = config.inlined_contents.is_some();
803
804    let appended_tokens = if is_pax_file {
805        let file_name = config.file_path.unwrap();
806
807        let root = env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into());
808
809        let path = if Path::new(&root).join(&file_name).exists() {
810            Path::new(&root).join(&file_name)
811        } else {
812            Path::new(&root).join("src/").join(&file_name)
813        };
814
815        // generate_include to watch for changes in specified file, ensuring macro is re-evaluated when file changes
816        let name = Ident::new(&pascal_identifier, Span::call_site());
817        let include_fix = generate_include(&name, &path);
818        let associated_pax_file = Some(path.clone());
819        let file = File::open(path);
820        let mut content = String::new();
821        let _ = file.unwrap().read_to_string(&mut content);
822        pax_full_component(
823            content,
824            &input,
825            config.is_main_component,
826            config.initializer.clone(),
827            Some(include_fix),
828            is_custom_interpolatable,
829            is_custom_coercion_rules,
830            can_derive_identity_roundtrip,
831            associated_pax_file,
832            engine_import_path,
833        )
834    } else if is_pax_svg {
835        let file_name = config.svg_path.unwrap();
836
837        let root = env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into());
838
839        let path = if Path::new(&root).join(&file_name).exists() {
840            Path::new(&root).join(&file_name)
841        } else {
842            Path::new(&root).join("src/").join(&file_name)
843        };
844
845        // generate_include to watch for changes in specified file, ensuring macro is re-evaluated when file changes
846        let name = Ident::new(&pascal_identifier, Span::call_site());
847        let include_fix = generate_include(&name, &path);
848        pax_full_component(
849            String::new(),
850            &input,
851            config.is_main_component,
852            config.initializer.clone(),
853            Some(include_fix),
854            is_custom_interpolatable,
855            is_custom_coercion_rules,
856            can_derive_identity_roundtrip,
857            None,
858            engine_import_path,
859        )
860    } else if is_pax_inlined {
861        let contents = config.inlined_contents.unwrap();
862
863        pax_full_component(
864            contents.to_owned(),
865            &input,
866            config.is_main_component,
867            config.initializer.clone(),
868            None,
869            is_custom_interpolatable,
870            is_custom_coercion_rules,
871            can_derive_identity_roundtrip,
872            None,
873            engine_import_path,
874        )
875    } else if config.is_primitive {
876        pax_primitive(
877            &input,
878            config.primitive_instance_import_path.unwrap(),
879            is_custom_interpolatable,
880            is_custom_coercion_rules,
881            can_derive_identity_roundtrip,
882            engine_import_path,
883        )
884    } else {
885        pax_struct_only_component(
886            &input,
887            is_custom_interpolatable,
888            is_custom_coercion_rules,
889            can_derive_identity_roundtrip,
890            engine_import_path,
891        )
892    };
893
894    if let Data::Enum(data) = &input.data {
895        if data
896            .variants
897            .iter()
898            .flat_map(|v| v.fields.iter())
899            .any(|f| f.attrs.iter().any(|a| a.path.is_ident("pax")))
900        {
901            return quote! { compile_error!("#[pax(rust)] is supported on struct fields only"); }
902                .into();
903        }
904    }
905    // Rust-only fields own dependencies; they are not template values or baked data.
906    if let Data::Struct(data) = &mut input.data {
907        for field in &mut data.fields {
908            if is_rust_field(field) {
909                field.attrs.retain(|attr| !attr.path.is_ident("pax"));
910                field.attrs.push(syn::parse_quote!(#[serde(skip)]));
911            } else if field.attrs.iter().any(|attr| attr.path.is_ident("pax")) {
912                return quote! { compile_error!("the field attribute must be #[pax(rust)]"); }
913                    .into();
914            }
915        }
916    }
917    let derives: proc_macro2::TokenStream = trait_impls
918        .into_iter()
919        .flat_map(|ident| {
920            let syn_ident = syn::Ident::new(ident, Span::call_site());
921            if ["Serialize", "Deserialize"].contains(&ident) {
922                // fully qualify serde dependencies
923                quote! {pax_engine::serde::#syn_ident,}
924            } else {
925                quote! {#syn_ident,}
926            }
927        })
928        .collect();
929
930    let ident = &input.ident;
931    let helper_functions_impl = if !config.has_helpers {
932        quote! {
933            impl pax_engine::api::HelperFunctions for #ident {
934                fn register_all_functions() {
935                    // Do nothing
936                }
937            }
938        }
939    } else {
940        quote! {}
941    };
942
943    let output = quote! {
944        // TODO make this value represented in PaxValue instead (map of properties), and impl to/from that value
945        impl pax_engine::api::ImplToFromPaxAny for #ident {}
946
947        #[derive(#derives)]
948        #[serde(crate = "pax_engine::serde")]
949        #input
950        #appended_tokens
951
952        #helper_functions_impl
953    };
954    output.into()
955}
956
957// Needed because Cargo wouldn't otherwise watch for changes in pax files.
958// By include_str!ing the file contents,
959// (Trick borrowed from Pest: github.com/pest-parser/pest)
960fn generate_include(name: &Ident, path: &PathBuf) -> TokenStream {
961    let const_name = Ident::new(&format!("_PAX_FILE_{}", name), Span::call_site());
962    let path_str = path.to_str().expect("expected non-unicode path");
963    quote! {
964        #[allow(non_upper_case_globals)]
965        const #const_name: &'static str = include_str!(#path_str);
966    }
967}
968/// Registers public associated functions in an impl block as PAXEL helpers.
969///
970/// Private and restricted-visibility functions remain ordinary Rust methods and
971/// are not exposed to PAXEL.
972#[proc_macro_attribute]
973pub fn helpers(
974    _attr: proc_macro::TokenStream,
975    item: proc_macro::TokenStream,
976) -> proc_macro::TokenStream {
977    let input = parse_macro_input!(item as ItemImpl);
978    expand_helpers(input)
979        .unwrap_or_else(syn::Error::into_compile_error)
980        .into()
981}
982
983fn public_associated_helpers(input: &ItemImpl) -> syn::Result<Vec<&ImplItemMethod>> {
984    input
985        .items
986        .iter()
987        .filter_map(|item| match item {
988            ImplItem::Method(method) if matches!(method.vis, syn::Visibility::Public(_)) => {
989                Some(method)
990            }
991            _ => None,
992        })
993        .map(|method| {
994            if method
995                .sig
996                .inputs
997                .iter()
998                .any(|arg| matches!(arg, FnArg::Receiver(_)))
999            {
1000                Err(syn::Error::new_spanned(
1001                    method.sig.clone(),
1002                    "Helpers macro can only be used on associated functions (methods that don't take self)",
1003                ))
1004            } else {
1005                Ok(method)
1006            }
1007        })
1008        .collect()
1009}
1010
1011fn expand_helpers(input: ItemImpl) -> syn::Result<proc_macro2::TokenStream> {
1012    let struct_name = &input.self_ty;
1013
1014    let mut register_functions = vec![];
1015
1016    for method in public_associated_helpers(&input)? {
1017        let func_name = &method.sig.ident;
1018        let arg_count = method.sig.inputs.len();
1019        let param_checks = generate_param_checks(&method.sig.inputs);
1020        let func_call = generate_function_call(&method.sig, struct_name);
1021
1022        register_functions.push(quote! {
1023                register_function(
1024                    stringify!(#struct_name).to_string(),
1025                    stringify!(#func_name).to_string(),
1026                    Arc::new(move |args: Vec<PaxValue>| -> Result<PaxValue, String> {
1027                        if args.len() != #arg_count {
1028                            return Err(format!("Expected {} arguments for function {}", #arg_count, stringify!(#func_name)));
1029                        }
1030                        #param_checks
1031                        #func_call
1032                    })
1033                );
1034            });
1035    }
1036
1037    let expanded = quote! {
1038        #input
1039
1040        impl pax_engine::api::HelperFunctions for #struct_name {
1041            fn register_all_functions() {
1042                use std::sync::Arc;
1043                use pax_engine::api::{PaxValue, register_function};
1044                #(#register_functions)*
1045            }
1046        }
1047    };
1048
1049    Ok(expanded)
1050}
1051
1052#[cfg(test)]
1053mod helper_visibility_tests {
1054    use super::*;
1055
1056    #[test]
1057    fn only_public_associated_functions_are_registered() {
1058        let input: ItemImpl = syn::parse_quote! {
1059            impl Example {
1060                pub fn visible(value: f64) -> f64 { value }
1061                fn private(value: f64) -> f64 { value }
1062                pub(crate) fn crate_visible(value: f64) -> f64 { value }
1063            }
1064        };
1065
1066        let names = public_associated_helpers(&input)
1067            .unwrap()
1068            .into_iter()
1069            .map(|method| method.sig.ident.to_string())
1070            .collect::<Vec<_>>();
1071        assert_eq!(names, vec!["visible"]);
1072    }
1073
1074    #[test]
1075    fn public_instance_methods_are_rejected() {
1076        let input: ItemImpl = syn::parse_quote! {
1077            impl Example {
1078                pub fn instance(&self) {}
1079                fn private_instance(&self) {}
1080            }
1081        };
1082
1083        assert!(public_associated_helpers(&input).is_err());
1084    }
1085}
1086
1087fn generate_param_checks(inputs: &Punctuated<FnArg, Token![,]>) -> proc_macro2::TokenStream {
1088    let checks = inputs.iter().enumerate().filter_map(|(i, arg)| {
1089        if let FnArg::Typed(PatType { ty, .. }) = arg {
1090            let ty_string = quote!(#ty).to_string();
1091            let arg_name = format_ident!("arg_{}", i);
1092            Some(quote! {
1093                let #arg_name = <#ty as pax_engine::api::CoercionRules>::try_coerce(args[#i].clone())
1094                    .map_err(|_| format!("Failed to coerce argument {} to {}", #i, #ty_string))?;
1095            })
1096        } else {
1097            None
1098        }
1099    });
1100
1101    quote! { #(#checks)* }
1102}
1103
1104fn generate_function_call(sig: &Signature, struct_name: &Box<Type>) -> proc_macro2::TokenStream {
1105    let func_name = &sig.ident;
1106    let args = sig.inputs.iter().enumerate().filter_map(|(i, arg)| {
1107        if let FnArg::Typed(_) = arg {
1108            let arg_name = format_ident!("arg_{}", i);
1109            Some(quote! { #arg_name })
1110        } else {
1111            None
1112        }
1113    });
1114
1115    quote! {
1116        let result = #struct_name::#func_name(#(#args),*);
1117        Ok(<_ as pax_engine::api::ToPaxValue>::to_pax_value(result))
1118    }
1119}