Skip to main content

ntui_macros/
lib.rs

1use proc_macro::TokenStream;
2use quote::{format_ident, quote};
3use syn::parse::{Parse, ParseStream};
4use syn::{FnArg, ItemFn, Type, parse_macro_input};
5
6// ---------- #[component] ----------
7
8/// Turns `fn Name(props: &NameProps, hooks: &mut Hooks) -> Element { .. }`
9/// (or the props-less form `fn Name(hooks: &mut Hooks) -> Element`) into a
10/// unit struct implementing ntui::Component.
11#[proc_macro_attribute]
12pub fn component(_attr: TokenStream, item: TokenStream) -> TokenStream {
13    let f = parse_macro_input!(item as ItemFn);
14    if !f.sig.generics.params.is_empty() || f.sig.asyncness.is_some() {
15        return syn::Error::new_spanned(
16            &f.sig,
17            "#[component] functions cannot be generic or async",
18        )
19        .to_compile_error()
20        .into();
21    }
22    let name = &f.sig.ident;
23    let vis = &f.vis;
24    let attrs = &f.attrs;
25    let body = &f.block;
26    let inputs: Vec<&FnArg> = f.sig.inputs.iter().collect();
27    let (props_ty, props_pat, hooks_pat): (Type, Box<syn::Pat>, Box<syn::Pat>) =
28        match inputs.as_slice() {
29            [FnArg::Typed(p), FnArg::Typed(h)] => {
30                let Type::Reference(r) = &*p.ty else {
31                    return syn::Error::new_spanned(
32                        &p.ty,
33                        "props argument must be a reference (&MyProps)",
34                    )
35                    .to_compile_error()
36                    .into();
37                };
38                ((*r.elem).clone(), p.pat.clone(), h.pat.clone())
39            }
40            [FnArg::Typed(h)] => (
41                syn::parse_quote!(()),
42                syn::parse_quote!(_props),
43                h.pat.clone(),
44            ),
45            _ => {
46                return syn::Error::new_spanned(
47                &f.sig,
48                "#[component] fn must take (props: &P, hooks: &mut Hooks) or (hooks: &mut Hooks)",
49            )
50            .to_compile_error()
51            .into();
52            }
53        };
54    quote! {
55        #[allow(non_camel_case_types)]
56        #(#attrs)*
57        #vis struct #name;
58
59        impl ::ntui::Component for #name {
60            type Props = #props_ty;
61            fn render(#props_pat: &#props_ty, #hooks_pat: &mut ::ntui::Hooks) -> ::ntui::Element #body
62        }
63    }
64    .into()
65}
66
67// ---------- element! ----------
68
69struct ElementNode {
70    name: syn::Ident,
71    props: Vec<(syn::Ident, syn::Expr)>,
72    key: Option<syn::Expr>,
73    children: Vec<Child>,
74}
75
76enum Child {
77    Node(ElementNode),
78    Splice(syn::Expr),
79}
80
81impl Parse for ElementNode {
82    fn parse(input: ParseStream) -> syn::Result<Self> {
83        let name: syn::Ident = input.parse()?;
84        let mut props = Vec::new();
85        let mut key = None;
86        if input.peek(syn::token::Paren) {
87            let content;
88            syn::parenthesized!(content in input);
89            while !content.is_empty() {
90                let pname: syn::Ident = content.parse()?;
91                content.parse::<syn::Token![:]>()?;
92                let expr: syn::Expr = content.parse()?;
93                if pname == "key" {
94                    key = Some(expr);
95                } else {
96                    props.push((pname, expr));
97                }
98                if content.peek(syn::Token![,]) {
99                    content.parse::<syn::Token![,]>()?;
100                }
101            }
102        }
103        let mut children = Vec::new();
104        if input.peek(syn::token::Brace) {
105            let content;
106            syn::braced!(content in input);
107            while !content.is_empty() {
108                if content.peek(syn::Token![#]) {
109                    content.parse::<syn::Token![#]>()?;
110                    let inner;
111                    syn::parenthesized!(inner in content);
112                    children.push(Child::Splice(inner.parse()?));
113                } else {
114                    children.push(Child::Node(content.parse()?));
115                }
116            }
117        }
118        Ok(ElementNode {
119            name,
120            props,
121            key,
122            children,
123        })
124    }
125}
126
127/// JSX-alike: `element! { View(gap: 1) { Text(content: "hi") #(iter) } }`.
128/// Hosts: View, Text, Fragment, ContextProvider(value: ..). Anything else is a
129/// component; its props type is `{Name}Props` by convention (Default-filled).
130///
131/// Identifiers prefixed with `__` are reserved inside `element!` expansions.
132///
133/// The expansion emits fully-qualified `::ntui::` paths (proc macros cannot use
134/// `$crate`), so the library must be reachable as `::ntui` — i.e. depended on as
135/// `ntui`; renaming the dependency breaks codegen.
136#[proc_macro]
137pub fn element(input: TokenStream) -> TokenStream {
138    let node = parse_macro_input!(input as ElementNode);
139    gen_node(&node).into()
140}
141
142/// True for an integer literal with no explicit type suffix (`1`, not
143/// `1u16` or `1.0`), including a leading-minus form (`-1`).
144fn is_unsuffixed_int_lit(expr: &syn::Expr) -> bool {
145    fn lit_is_bare_int(lit: &syn::Lit) -> bool {
146        matches!(lit, syn::Lit::Int(i) if i.suffix().is_empty())
147    }
148    match expr {
149        syn::Expr::Lit(syn::ExprLit { lit, .. }) => lit_is_bare_int(lit),
150        syn::Expr::Unary(syn::ExprUnary {
151            op: syn::UnOp::Neg(_),
152            expr,
153            ..
154        }) => is_unsuffixed_int_lit(expr),
155        syn::Expr::Paren(syn::ExprParen { expr, .. }) => is_unsuffixed_int_lit(expr),
156        _ => false,
157    }
158}
159
160fn gen_node(node: &ElementNode) -> proc_macro2::TokenStream {
161    let name = &node.name;
162    let fields = node.props.iter().map(|(k, v)| {
163        // Unsuffixed integer literals (e.g. `gap: 1`) must be emitted without
164        // an `Into::into` wrapper: routing them through a generic `Into<T>`
165        // call makes the literal's type an unresolved trait-selection
166        // variable, which falls back to `i32` before the `Into<u16>` (etc.)
167        // bound is checked, breaking inference. Bare struct-literal field
168        // assignment lets the field's concrete type flow directly to the
169        // literal instead, so it infers correctly.
170        if is_unsuffixed_int_lit(v) {
171            quote! { #k: #v, }
172        } else {
173            quote! { #k: ::core::convert::Into::into(#v), }
174        }
175    });
176    let children = gen_children(&node.children);
177    let name_str = name.to_string();
178    let base = match name_str.as_str() {
179        "View" => quote! {
180            {
181                // The `..Default::default()` spread is structural (it lets
182                // callers omit any subset of ViewProps fields), even when a
183                // particular `element!` call happens to specify every field.
184                #[allow(clippy::needless_update)]
185                let __props = ::ntui::ViewProps { #(#fields)* ..::core::default::Default::default() };
186                ::ntui::Element::view(__props, #children)
187            }
188        },
189        "Text" => quote! {
190            {
191                #[allow(clippy::needless_update)]
192                let __props = ::ntui::TextProps { #(#fields)* ..::core::default::Default::default() };
193                ::ntui::Element::text(__props)
194            }
195        },
196        "Fragment" => quote! { ::ntui::Element::fragment(#children) },
197        "ContextProvider" => {
198            let Some((_, v)) = node.props.iter().find(|(k, _)| k == "value") else {
199                return syn::Error::new_spanned(
200                    &node.name,
201                    "ContextProvider requires a `value:` prop",
202                )
203                .to_compile_error();
204            };
205            quote! { ::ntui::Element::provider(#v, #children) }
206        }
207        _ => {
208            if node.props.is_empty() {
209                quote! { ::ntui::Element::component::<#name>(::core::default::Default::default()) }
210            } else {
211                let props_ty = format_ident!("{}Props", name);
212                quote! {
213                    {
214                        // Same rationale as the View/Text branches above: the
215                        // spread is structural, not redundant, even when a
216                        // given call fills every field of `{Name}Props`.
217                        #[allow(clippy::needless_update)]
218                        let __props = #props_ty { #(#fields)* ..::core::default::Default::default() };
219                        ::ntui::Element::component::<#name>(__props)
220                    }
221                }
222            }
223        }
224    };
225    match &node.key {
226        Some(k) => quote! { #base.with_key(#k) },
227        None => base,
228    }
229}
230
231fn gen_children(children: &[Child]) -> proc_macro2::TokenStream {
232    let stmts = children.iter().map(|c| match c {
233        Child::Node(n) => {
234            let t = gen_node(n);
235            quote! { __children.push(#t); }
236        }
237        Child::Splice(e) => quote! { __children.extend(#e); },
238    });
239    quote! {{
240        let mut __children: ::std::vec::Vec<::ntui::Element> = ::std::vec::Vec::new();
241        #(#stmts)*
242        __children
243    }}
244}