Skip to main content

ferroui_static_view_locator_macros/
lib.rs

1//! ferroui-static-view-locator-macros
2//!
3//! The attributes of ferroui-static-view-locator: [`static_view_locator`] (`[StaticViewLocator]`)
4//! and [`static_view_mapping`] (`[StaticViewMapping]`). Use them through
5//! `ferroui_static_view_locator`, which re-exports them.
6//!
7//! The attributes do not generate the locator: a proc macro sees one item and cannot find the
8//! view models and views of the crate. The build script of the crate does
9//! (`ferroui-static-view-locator-build`), and writes one file per locator below `OUT_DIR`;
10//! [`static_view_locator`] checks its options, writes the struct, and includes that file, whose
11//! `impl` blocks extend the struct (the `partial class` of the reference).
12
13use proc_macro::TokenStream;
14use proc_macro2::{Span, TokenStream as TokenStream2};
15use quote::quote;
16use syn::parse::{ParseStream, Parser};
17use syn::punctuated::Punctuated;
18use syn::spanned::Spanned;
19use syn::{bracketed, Attribute, Item, LitBool, LitStr, Token, Type};
20
21/// The resolution modes of `generated_adapter_resolution_mode` (`ViewResolutionMode`).
22const RESOLUTION_MODES: &[&str] =
23    &["Exact", "ExactThenBaseTypes", "ExactThenInterfaces", "ExactThenBaseTypesThenInterfaces", "ExactThenInterfacesThenBaseTypes"];
24
25/// The boolean options of [`static_view_locator`].
26const BOOLEAN_OPTIONS: &[&str] =
27    &["generate_view_factory_methods", "generate_runtime_type_fallback_methods", "generate_view_locator", "generate_data_template"];
28
29/// Makes a struct a view locator generated at build time (`[StaticViewLocator]`).
30///
31/// ```ignore
32/// #[static_view_locator(
33///     generate_view_locator = true,
34///     generate_data_template = true,
35///     generate_runtime_type_fallback_methods = false,
36///     generated_adapter_resolution_mode = ExactThenBaseTypesThenInterfaces,
37///     data_template_match_types = [ViewModelBase],
38/// )]
39/// #[derive(Default)]
40/// pub struct ViewLocator;
41/// ```
42///
43/// | Option | Default | Behaviour |
44/// |---|---|---|
45/// | `generate_view_factory_methods` | `false` | `fn try_create_view_exact(TypeId) -> Option<Ref<Control>>`, unless the locator declares it |
46/// | `generate_runtime_type_fallback_methods` | `true` | the legacy fallback helpers, when a legacy or declared `build` needs them |
47/// | `generate_view_locator` | `false` | `IViewLocator` of ReactiveUI and the automatic `IControlViewFor` mappings (feature `reactiveui`) |
48/// | `generate_data_template` | `false` | `IDataTemplate` with `build`, `match_` and the build hooks |
49/// | `generated_adapter_resolution_mode` | `ExactThenBaseTypesThenInterfaces` | the fallback precedence of the generated adapters |
50/// | `view_model_mapping_contracts` | empty | traits with one view model type that views implement (`Contract<VM>`, or an associated type) |
51/// | `data_template_match_types` | empty | the types (classes or traits) a resolved view model must be to match |
52/// | `id` | the name of the struct | the name of the generated file, for two locators of one name in a crate |
53///
54/// The struct must be a top-level item of a module the build script reads (not in a function and
55/// not under `#[cfg(test)]`), and the crate must run the generator in its build script.
56#[proc_macro_attribute]
57pub fn static_view_locator(attribute: TokenStream, item: TokenStream) -> TokenStream {
58    expand_locator(attribute.into(), item.into()).unwrap_or_else(syn::Error::into_compile_error).into()
59}
60
61/// Maps a view model type to a view type on a locator (`[StaticViewMapping(typeof(VM), typeof(V))]`):
62/// the final override of the mapping of the view model, for types that do not follow the naming
63/// rules. Repeatable; the types are named as in the module of the locator.
64///
65/// ```ignore
66/// #[static_view_locator]
67/// #[static_view_mapping(LoginViewModel, LogInView)]
68/// #[static_view_mapping(DashboardModel, DashboardScreen)]
69/// pub struct ViewLocator;
70/// ```
71///
72/// [`static_view_locator`] reads the mappings below it; a mapping written above it is left in
73/// place by this attribute, which only checks it.
74#[proc_macro_attribute]
75pub fn static_view_mapping(attribute: TokenStream, item: TokenStream) -> TokenStream {
76    let checked = parse_mapping.parse2(attribute.into());
77    match checked {
78        Ok(_) => item,
79        Err(error) => {
80            let mut tokens: TokenStream2 = error.into_compile_error();
81            tokens.extend(TokenStream2::from(item));
82            tokens.into()
83        }
84    }
85}
86
87fn expand_locator(attribute: TokenStream2, item: TokenStream2) -> syn::Result<TokenStream2> {
88    let (id, mut named) = parse_options.parse2(attribute)?;
89    let mut item: Item = syn::parse2(item)?;
90    let Item::Struct(locator) = &mut item else {
91        return Err(syn::Error::new(
92            item.span(),
93            "SVL0005: #[static_view_locator] extends a struct; the generated members are an `impl` block of the struct",
94        ));
95    };
96    let mut kept: Vec<Attribute> = Vec::with_capacity(locator.attrs.len());
97    for attribute in locator.attrs.drain(..) {
98        if is_named(&attribute, "static_view_mapping") {
99            let (view_model, view) = attribute.parse_args_with(parse_mapping)?;
100            named.push(view_model);
101            named.push(view);
102        } else if is_named(&attribute, "static_view_locator") {
103            return Err(syn::Error::new(attribute.span(), "#[static_view_locator] is stated more than once"));
104        } else {
105            kept.push(attribute);
106        }
107    }
108    locator.attrs = kept;
109    let file = format!("{}.rs", id.unwrap_or_else(|| locator.ident.to_string()));
110    // The types the options name are used by the generated source through the build script only:
111    // importing them here resolves them where they are written (a misspelled type is an error of
112    // the compiler at the attribute) and uses the imports they are named by.
113    let uses = named.iter().filter_map(type_path).map(|path| quote! { use #path as _; });
114    Ok(quote! {
115        #item
116        #[allow(unused_imports)]
117        const _: () = {
118            #(#uses)*
119        };
120        include!(concat!(env!("OUT_DIR"), "/static_view_locator/", #file));
121    })
122}
123
124/// The path of a named type without its generic arguments (`a::Widget` of `a::Widget<i32>`), for a
125/// `use`; `None` for a type that is not a path or is a primitive type.
126fn type_path(type_: &Type) -> Option<syn::Path> {
127    let Type::Path(type_path) = type_ else { return None };
128    if type_path.qself.is_some() {
129        return None;
130    }
131    let mut path = type_path.path.clone();
132    for segment in &mut path.segments {
133        segment.arguments = syn::PathArguments::None;
134    }
135    let primitive = path.segments.len() == 1 && path.segments[0].ident.to_string().starts_with(|character: char| character.is_ascii_lowercase());
136    (!primitive).then_some(path)
137}
138
139/// Whether `attribute` is the attribute `name` (`#[name(..)]` or `#[path::name(..)]`).
140fn is_named(attribute: &Attribute, name: &str) -> bool {
141    attribute.path().segments.last().is_some_and(|segment| segment.ident == name)
142}
143
144/// The two types of `#[static_view_mapping(VM, View)]`.
145fn parse_mapping(input: ParseStream) -> syn::Result<(Type, Type)> {
146    let types = Punctuated::<Type, Token![,]>::parse_terminated(input)?;
147    if types.len() != 2 {
148        return Err(syn::Error::new(
149            if types.is_empty() { Span::call_site() } else { types.span() },
150            "#[static_view_mapping] takes the view model type and the view type: #[static_view_mapping(SettingsViewModel, SettingsView)]",
151        ));
152    }
153    let mut types = types.into_iter();
154    Ok((types.next().expect("two types"), types.next().expect("two types")))
155}
156
157/// The options of `#[static_view_locator(..)]`, checked: the `id` when one is stated, and the types
158/// the options name.
159fn parse_options(input: ParseStream) -> syn::Result<(Option<String>, Vec<Type>)> {
160    let mut id = None;
161    let mut named = Vec::new();
162    let mut seen: Vec<String> = Vec::new();
163    while !input.is_empty() {
164        let name: syn::Ident = input.parse()?;
165        let key = name.to_string();
166        if seen.contains(&key) {
167            return Err(syn::Error::new(name.span(), format!("the option `{key}` is stated more than once")));
168        }
169        input.parse::<Token![=]>()?;
170        match key.as_str() {
171            "id" => {
172                let value: LitStr = input.parse()?;
173                let text = value.value();
174                if text.is_empty() || !text.chars().all(|character| character.is_ascii_alphanumeric() || character == '_') {
175                    return Err(syn::Error::new(value.span(), "the id of a locator is a name of letters, digits and underscores"));
176                }
177                id = Some(text);
178            }
179            key if BOOLEAN_OPTIONS.contains(&key) => {
180                input.parse::<LitBool>()?;
181            }
182            "generated_adapter_resolution_mode" => {
183                let path: syn::Path = input.parse()?;
184                let mode = path.segments.last().map(|segment| segment.ident.to_string()).unwrap_or_default();
185                if !RESOLUTION_MODES.contains(&mode.as_str()) {
186                    return Err(syn::Error::new(path.span(), format!("the resolution mode is one of {}", RESOLUTION_MODES.join(", "))));
187                }
188            }
189            "view_model_mapping_contracts" | "data_template_match_types" => {
190                let content;
191                bracketed!(content in input);
192                named.extend(Punctuated::<Type, Token![,]>::parse_terminated(&content)?);
193            }
194            _ => {
195                return Err(syn::Error::new(
196                    name.span(),
197                    format!(
198                        "unknown option `{key}`; the options are id, {}, generated_adapter_resolution_mode, view_model_mapping_contracts and data_template_match_types",
199                        BOOLEAN_OPTIONS.join(", ")
200                    ),
201                ))
202            }
203        }
204        seen.push(key);
205        if input.is_empty() {
206            break;
207        }
208        input.parse::<Token![,]>()?;
209    }
210    Ok((id, named))
211}