tui-lipan-macro 0.1.0

Procedural macros for the tui-lipan TUI framework (ui!, rsx!, mockup!).
Documentation
use proc_macro2::{Span, TokenStream as TokenStream2};
use quote::{format_ident, quote};
use syn::{Expr, Ident, Result};

use crate::rsx_ast::{Child, Entry, IfChild, Node, NodeOrExpr, RsxInput};

impl RsxInput {
    pub(crate) fn expand(self, lipan: &TokenStream2) -> TokenStream2 {
        let root = self.root.expand_value(lipan);
        quote! { ::core::convert::Into::<#lipan::Element>::into(#root) }
    }
}

impl NodeOrExpr {
    fn expand_value(&self, lipan: &TokenStream2) -> TokenStream2 {
        match self {
            Self::Node(node) => node.expand_value(lipan),
            Self::Expr(expr) => quote! { #expr },
        }
    }
}

impl Node {
    fn type_name(&self) -> String {
        self.path
            .segments
            .last()
            .map(|segment| segment.ident.to_string())
            .unwrap_or_default()
    }

    fn expand_value(&self, lipan: &TokenStream2) -> TokenStream2 {
        match self.expand_value_impl(lipan) {
            Ok(tokens) => tokens,
            Err(err) => err.to_compile_error(),
        }
    }

    fn expand_value_impl(&self, lipan: &TokenStream2) -> Result<TokenStream2> {
        let ty = self.type_name();
        let ctor_keys = constructor_keys_for_type(&ty);

        let mut ctor_args: Vec<Option<TokenStream2>> = vec![None; ctor_keys.len()];
        let mut key_expr: Option<(Ident, Expr)> = None;
        let mut constraint_exprs: Vec<(Ident, Expr)> = Vec::new();

        let node_ident = format_ident!("__rsx_node");
        let add_method = add_method_for_parent(&ty);

        let mut stmts: Vec<TokenStream2> = Vec::new();

        let child_count = self
            .entries
            .iter()
            .filter(|entry| matches!(entry, Entry::Child(_)))
            .count();

        if let Some(limit) = max_children_for_type(&ty)
            && child_count > limit
        {
            let hint = if limit == 0 {
                format!("`{ty}` does not accept child nodes")
            } else {
                "Note: Use VStack or HStack for multiple children".to_string()
            };
            return Err(syn::Error::new_spanned(
                &self.path,
                format!(
                    "`{}` can only have {} child{}, but {} were provided\n{}",
                    ty,
                    limit,
                    if limit == 1 { "" } else { "ren" },
                    child_count,
                    hint,
                ),
            ));
        }

        for entry in &self.entries {
            match entry {
                Entry::Prop(name, value) => {
                    let name_str = name.to_string();

                    if name_str == "key" {
                        let NodeOrExpr::Expr(expr) = value.as_ref() else {
                            return Err(syn::Error::new(
                                name.span(),
                                "`key:` must be a Rust expression",
                            ));
                        };
                        key_expr = Some((name.clone(), expr.as_ref().clone()));
                        continue;
                    }

                    if is_element_constraint(&name_str) {
                        let NodeOrExpr::Expr(expr) = value.as_ref() else {
                            return Err(syn::Error::new(
                                name.span(),
                                format!("`{}:` must be a Rust expression", name_str),
                            ));
                        };
                        constraint_exprs.push((name.clone(), expr.as_ref().clone()));
                        continue;
                    }

                    if let Some(pos) = ctor_keys.iter().position(|key| *key == name_str.as_str())
                        && ctor_args[pos].is_none()
                    {
                        ctor_args[pos] = Some(value.expand_value(lipan));
                        continue;
                    }

                    let method = map_prop_method(name);
                    let value = value.expand_value(lipan);
                    stmts.push(quote! { #node_ident = #node_ident.#method(#value); });
                }
                Entry::Child(child) => {
                    stmts.push(child.expand_apply(lipan, &node_ident, &add_method)?);
                }
            }
        }

        let path = &self.path;

        let base = if !ctor_keys.is_empty() {
            let mut resolved = Vec::with_capacity(ctor_keys.len());
            for (key, arg) in ctor_keys.iter().zip(ctor_args.iter()) {
                let Some(arg) = arg else {
                    return Err(syn::Error::new_spanned(
                        path,
                        format!("`{ty}` requires `{key}: ...` property"),
                    ));
                };
                resolved.push(quote! { #arg });
            }
            quote! { #path::new(#(#resolved),*) }
        } else {
            quote! { #path::new() }
        };

        let has_element_modifiers = key_expr.is_some() || !constraint_exprs.is_empty();

        let finish = if has_element_modifiers {
            if matches!(ty.as_str(), "Tab" | "ListItem")
                && let Some((ident, _)) = &key_expr
            {
                return Err(syn::Error::new(
                    ident.span(),
                    format!("`key:` is not supported for `{ty}`"),
                ));
            }

            let mut chain = quote! { ::core::convert::Into::<#lipan::Element>::into(#node_ident) };

            for (name, expr) in constraint_exprs {
                chain = quote! { #chain.#name(#expr) };
            }

            if let Some((_key_ident, key_expr)) = key_expr {
                chain = quote! { #chain.key(#key_expr) };
            }

            chain
        } else {
            quote! { #node_ident }
        };

        Ok(quote! {{
            let mut #node_ident = #base;
            #(#stmts)*
            #finish
        }})
    }
}

impl Child {
    fn expand_apply(
        &self,
        lipan: &TokenStream2,
        node_ident: &Ident,
        add_method: &Ident,
    ) -> Result<TokenStream2> {
        Ok(match self {
            Self::Node(node) => {
                let value = node.expand_value(lipan);
                quote! { #node_ident = #node_ident.#add_method(#value); }
            }
            Self::Expr(expr) => {
                quote! { #node_ident = #node_ident.#add_method(#expr); }
            }
            Self::For(for_child) => {
                let pat = &for_child.pat;
                let expr = &for_child.expr;

                let mut body = Vec::new();
                for child in &for_child.body {
                    body.push(child.expand_apply(lipan, node_ident, add_method)?);
                }

                quote! {
                    for #pat in #expr {
                        #(#body)*
                    }
                }
            }
            Self::If(if_child) => expand_if_child(if_child, lipan, node_ident, add_method)?,
        })
    }
}

fn expand_if_child(
    if_child: &IfChild,
    lipan: &TokenStream2,
    node_ident: &Ident,
    add_method: &Ident,
) -> Result<TokenStream2> {
    let cond = &if_child.cond;

    let mut then_body = Vec::new();
    for child in &if_child.then_body {
        then_body.push(child.expand_apply(lipan, node_ident, add_method)?);
    }

    Ok(if let Some(else_body) = &if_child.else_body {
        let mut else_tokens = Vec::new();
        for child in else_body {
            else_tokens.push(child.expand_apply(lipan, node_ident, add_method)?);
        }

        quote! {
            if #cond {
                #(#then_body)*
            } else {
                #(#else_tokens)*
            }
        }
    } else {
        quote! {
            if #cond {
                #(#then_body)*
            }
        }
    })
}

fn map_prop_method(name: &Ident) -> Ident {
    match name.to_string().as_str() {
        "alignment" => Ident::new("align", name.span()),
        "spacing" => Ident::new("gap", name.span()),
        _ => name.clone(),
    }
}

fn add_method_for_parent(parent_ty: &str) -> Ident {
    match parent_ty {
        "Tabs" => Ident::new("tab", Span::call_site()),
        "List" => Ident::new("item", Span::call_site()),
        "Accordion" => Ident::new("item", Span::call_site()),
        "DraggableTabBar" => Ident::new("tab", Span::call_site()),
        "AccordionItem" => Ident::new("content", Span::call_site()),
        _ => Ident::new("child", Span::call_site()),
    }
}

fn is_element_constraint(name: &str) -> bool {
    matches!(
        name,
        "min_width" | "max_width" | "min_height" | "max_height"
    )
}

fn constructor_keys_for_type(ty: &str) -> &'static [&'static str] {
    match ty {
        "Text" => &["content"],
        "Button" => &["label"],
        "Input" => &["value"],
        "Tab" => &["label"],
        "ListItem" => &["text"],
        "Checkbox" => &["checked"],
        "ProgressBar" => &["progress"],
        "TextArea" => &["value"],
        "Radio" => &["options"],
        "Grid" => &["columns"],
        "Heatmap" => &["data"],
        "Sparkline" => &["data"],
        "Tree" => &["root"],
        "FileTree" => &["root"],
        "Modal" => &[],
        "Toast" => &["message"],
        "Tooltip" => &["text"],
        "ThemeProvider" => &["theme"],
        "Badge" => &["content"],
        "Slider" => &["value"],
        "Image" => &["src"],
        "Divider" => &["orientation"],
        "Splitter" => &["orientation"],
        "AsciiCanvas" => &["lines"],
        "AccordionItem" => &["title"],
        "ContextMenu" => &["trigger"],
        "DocumentView" => &["value"],
        "DraggableTab" => &["label"],
        "Flowchart" => &["direction"],
        "HexArea" => &["bytes"],
        "PaginationBar" => &["state"],
        "DiffView" => &[],
        _ => &[],
    }
}

fn max_children_for_type(ty: &str) -> Option<usize> {
    match ty {
        "Center" | "Frame" | "Group" | "Portal" => Some(1),
        "CenterPin" => Some(0),
        "AccordionItem" | "Badge" | "DragSource" | "DropTarget" | "Modal" | "MouseRegion"
        | "ThemeProvider" | "Tooltip" => Some(1),
        "VStack" | "HStack" | "ZStack" | "List" | "Tabs" | "Grid" | "Tree" | "ScrollView"
        | "Splitter" => None,
        "Accordion" | "DraggableTabBar" | "StatusBar" => None,
        "Text" | "BigText" | "Button" | "Input" | "TextArea" | "Checkbox" | "ProgressBar"
        | "Slider" | "Spinner" | "Divider" | "Spacer" | "Tab" | "ListItem" | "LogView"
        | "Terminal" | "FileTree" | "Image" | "AsciiCanvas" => Some(0),
        "Breadcrumb" | "Chart" | "ComboBox" | "ContextMenu" | "DatePicker" | "DiffView"
        | "DocumentView" | "DraggableTab" | "Flowchart" | "Graph" | "Heatmap" | "HexArea"
        | "ManagedTerminal" | "MultiSelect" | "PaginationBar" | "Popover" | "Radio"
        | "SearchPalette" | "Select" | "SequenceDiagram" | "Sparkline" | "Table" | "Toast" => {
            Some(0)
        }
        _ => None,
    }
}