Skip to main content

fncc_core/
codegen.rs

1#![allow(clippy::too_many_arguments)]
2
3use std::collections::HashMap;
4
5use crate::parser::{self, AttrValue, Document, Element, Node};
6use crate::semantic::PropField;
7
8pub fn generate(doc: &Document) -> String {
9    generate_with_id(doc, 0)
10}
11
12pub fn generate_with_id(doc: &Document, file_id: usize) -> String {
13    generate_with_imports(doc, file_id, &[], None, None, None, &[], None, &[], None, None)
14}
15
16/// Resolved import entry: (tag_name, render_fn_name)
17/// - For .fui component imports: ("Header", "render_header")
18/// - For gpui imports: ("TextInput", "") — registered but no special codegen
19pub type ResolvedImport<'a> = (&'a str, &'a str);
20
21/// Generate code for a document with a specific component name.
22/// `component_name` is used for the render function name (derived from the file stem).
23/// If `None`, falls back to the root element name (backward compat).
24/// `resolved_state_type` overrides the document's `@state` directive (used by semantic analysis).
25pub fn generate_with_imports(
26    doc: &Document,
27    file_id: usize,
28    imports: &[ResolvedImport],
29    component_name: Option<&str>,
30    resolved_state_type: Option<&str>,
31    props_type: Option<&str>,
32    import_props: &[(&str, Option<&str>)],
33    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
34    import_has_slots: &[(&str, bool)],
35    style_cascade: Option<&fncc_styles::Stylesheet>,
36    style_theme: Option<&str>,
37) -> String {
38    let mut out = String::new();
39    let state_type = resolved_state_type.or(doc.state_type.as_deref());
40    let has_state = state_type.is_some();
41    let has_slot = parser::has_slot(&doc.root);
42
43    if let Some(ref fm) = doc.frontmatter {
44        out.push_str(fm);
45        out.push('\n');
46    }
47
48    let commands = parser::collect_commands(&doc.root);
49    if !commands.is_empty() {
50        out.push_str("#[allow(unused)]\n");
51        out.push_str(&format!("fn _fncc_validate_{file_id}() {{\n"));
52        for cmd in &commands {
53            let trampoline = format!("__fncc_cmd_{cmd}");
54            out.push_str(&format!("    let _ = {trampoline};\n"));
55        }
56        out.push_str("}\n\n");
57    }
58
59    if has_state {
60        generate_stateful(
61            doc,
62            &mut out,
63            imports,
64            state_type,
65            import_props,
66            prop_fields,
67            import_has_slots,
68            style_cascade,
69            style_theme,
70        );
71    } else {
72        generate_stateless(
73            doc,
74            &mut out,
75            imports,
76            component_name,
77            props_type,
78            import_props,
79            prop_fields,
80            import_has_slots,
81            has_slot,
82            style_cascade,
83            style_theme,
84        );
85    }
86
87    out
88}
89
90fn generate_stateless(
91    doc: &Document,
92    out: &mut String,
93    imports: &[ResolvedImport],
94    component_name: Option<&str>,
95    props_type: Option<&str>,
96    import_props: &[(&str, Option<&str>)],
97    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
98    import_has_slots: &[(&str, bool)],
99    has_slot: bool,
100    style_cascade: Option<&fncc_styles::Stylesheet>,
101    style_theme: Option<&str>,
102) {
103    let name = component_name.unwrap_or(&doc.root.name);
104    let fn_name = format!("render_{}", to_snake_case(name));
105
106    let params = match (props_type, has_slot) {
107        (Some(pt), true) => format!("props: &{pt}, children: impl IntoElement"),
108        (Some(pt), false) => format!("props: &{pt}"),
109        (None, true) => "children: impl IntoElement".to_string(),
110        (None, false) => String::new(),
111    };
112
113    let sig = if params.is_empty() {
114        format!("pub fn {fn_name}() -> impl IntoElement {{\n")
115    } else {
116        format!("pub fn {fn_name}({params}) -> impl IntoElement {{\n")
117    };
118    out.push_str(&sig);
119    out.push_str(&generate_element(
120        &doc.root,
121        1,
122        false,
123        imports,
124        import_props,
125        prop_fields,
126        import_has_slots,
127        style_cascade,
128        style_theme,
129    ));
130    out.push('\n');
131    out.push_str("}\n");
132}
133
134fn generate_stateful(
135    doc: &Document,
136    out: &mut String,
137    imports: &[ResolvedImport],
138    state_type: Option<&str>,
139    import_props: &[(&str, Option<&str>)],
140    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
141    _import_has_slots: &[(&str, bool)],
142    style_cascade: Option<&fncc_styles::Stylesheet>,
143    style_theme: Option<&str>,
144) {
145    let state_type = state_type.unwrap_or("Self");
146
147    out.push_str(&format!("impl Render for {state_type} {{\n"));
148    out.push_str("    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {\n");
149    out.push_str("        let handle = cx.entity().downgrade();\n");
150    out.push_str(&generate_element(
151        &doc.root,
152        2,
153        true,
154        imports,
155        import_props,
156        prop_fields,
157        _import_has_slots,
158        style_cascade,
159        style_theme,
160    ));
161    out.push_str("\n    }\n");
162    out.push_str("}\n");
163}
164
165/// Group consecutive If/ElseIf/Else elements into branches.
166/// Stops at a second `If` (which begins an independent conditional block)
167/// or any non-If/ElseIf/Else element.
168fn collect_if_chain(nodes: &[Node], start: usize) -> Vec<&Element> {
169    let mut branches = Vec::new();
170    for node in &nodes[start..] {
171        match node {
172            Node::Element(el) if el.name == "If" => {
173                if branches.is_empty() {
174                    branches.push(el);
175                } else {
176                    break; // new `If` starts a separate chain
177                }
178            }
179            Node::Element(el) if el.name == "ElseIf" || el.name == "Else" => {
180                if branches.is_empty() {
181                    break; // orphaned Else/ElseIf without preceding If
182                }
183                branches.push(el);
184            }
185            _ => break,
186        }
187    }
188    branches
189}
190
191/// Generate an if/else-if/else expression for a chain of If/ElseIf/Else elements.
192/// Returns a single-line Rust expression like `if cond { div()... } else { div()... }`.
193fn gen_if_expr(
194    branches: &[&Element],
195    stateful: bool,
196    imports: &[ResolvedImport],
197    import_props: &[(&str, Option<&str>)],
198    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
199    _import_has_slots: &[(&str, bool)],
200    style_cascade: Option<&fncc_styles::Stylesheet>,
201    style_theme: Option<&str>,
202) -> String {
203    let mut expr = String::new();
204    for (i, el) in branches.iter().enumerate() {
205        if i == 0 {
206            expr.push_str("if ");
207        } else if el.name == "Else" {
208            expr.push_str(" else ");
209        } else {
210            expr.push_str(" else if ");
211        }
212        if el.name != "Else" {
213            if let Some(cond) = parser::get_condition_attr(el) {
214                let e = interpolation_expr(cond);
215                expr.push_str(&e);
216                expr.push(' ');
217            } else {
218                expr.push_str("compile_error!(\"<");
219                expr.push_str(&el.name);
220                expr.push_str("> element is missing `condition` attribute\") ");
221            }
222        }
223        expr.push_str("{ ");
224        match &el.children[..] {
225            [] => expr.push_str("div()"),
226            [single] => match single {
227                Node::Text(t) => expr.push_str(&format!("div().child({:?})", t)),
228                Node::Interpolation(interp) => {
229                    let e = interpolation_expr(interp);
230                    expr.push_str(&format!("div().child({e}.to_string())"));
231                }
232                Node::Element(child_el) => {
233                    let child_code = generate_element(
234                        child_el,
235                        0,
236                        stateful,
237                        imports,
238                        import_props,
239                        prop_fields,
240                        _import_has_slots,
241                        style_cascade,
242                        style_theme,
243                    );
244                    expr.push_str(&format!("div().child({})", clean_inline(&child_code)));
245                }
246            },
247            children => {
248                expr.push_str("div()");
249                for child in children {
250                    match child {
251                        Node::Text(t) => expr.push_str(&format!(".child({:?})", t)),
252                        Node::Interpolation(interp) => {
253                            let e = interpolation_expr(interp);
254                            expr.push_str(&format!(".child({e}.to_string())"));
255                        }
256                        Node::Element(child_el) => {
257                            let child_code = generate_element(
258                                child_el,
259                                0,
260                                stateful,
261                                imports,
262                                import_props,
263                                prop_fields,
264                                _import_has_slots,
265                                style_cascade,
266                                style_theme,
267                            );
268                            expr.push_str(&format!(".child({})", clean_inline(&child_code)));
269                        }
270                    }
271                }
272            }
273        }
274        expr.push_str(" }");
275    }
276    // Add else { div() } if the last branch isn't an Else
277    if branches.last().map(|b| b.name.as_str()) != Some("Else") {
278        expr.push_str(" else { div() }");
279    }
280    expr
281}
282
283/// Generate a For loop expression for use inside `.children(...)`.
284fn gen_for_expr(
285    el: &Element,
286    stateful: bool,
287    imports: &[ResolvedImport],
288    import_props: &[(&str, Option<&str>)],
289    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
290    _import_has_slots: &[(&str, bool)],
291    style_cascade: Option<&fncc_styles::Stylesheet>,
292    style_theme: Option<&str>,
293) -> String {
294    let each = match parser::get_each_attr(el) {
295        Some(e) => e,
296        None => {
297            return "compile_error!(\"<For> element is missing required `each` attribute\")".to_string();
298        }
299    };
300    let let_var = parser::get_let_attr(el).unwrap_or("item");
301    let index_var = parser::get_index_attr(el);
302    let iter_expr = interpolation_expr(each);
303
304    let mut body = String::new();
305    match &el.children[..] {
306        [] => body.push_str("div()"),
307        [single] => match single {
308            Node::Text(t) => body.push_str(&format!("div().child({:?})", t)),
309            Node::Interpolation(interp) => {
310                let e = interpolation_expr(interp);
311                body.push_str(&format!("div().child({e}.to_string())"));
312            }
313            Node::Element(child_el) => {
314                let child_code = generate_element(
315                    child_el,
316                    1,
317                    stateful,
318                    imports,
319                    import_props,
320                    prop_fields,
321                    _import_has_slots,
322                    style_cascade,
323                    style_theme,
324                );
325                body.push_str(&child_code);
326            }
327        },
328        children => {
329            body.push_str("div()");
330            for child in children {
331                match child {
332                    Node::Text(t) => body.push_str(&format!(".child({:?})", t)),
333                    Node::Interpolation(interp) => {
334                        let e = interpolation_expr(interp);
335                        body.push_str(&format!(".child({e}.to_string())"));
336                    }
337                    Node::Element(child_el) => {
338                        let child_code = generate_element(
339                            child_el,
340                            1,
341                            stateful,
342                            imports,
343                            import_props,
344                            prop_fields,
345                            _import_has_slots,
346                            style_cascade,
347                            style_theme,
348                        );
349                        body.push_str(&format!(".child({})", clean_inline(&child_code)));
350                    }
351                }
352            }
353        }
354    }
355
356    // Strip self. prefix for loop variables so they reference the closure args, not state fields.
357    // Uses identifier-aware replacement to avoid corrupting fields that share a prefix
358    // e.g. self.item_count stays unchanged when var = "item"
359    let var_names: Vec<&str> = index_var.iter().chain(std::iter::once(&let_var)).copied().collect();
360    for var in &var_names {
361        body = replace_self_prefix(&body, var);
362    }
363
364    if let Some(index) = index_var {
365        format!("{iter_expr}.iter().enumerate().map(|({index}, {let_var})| {{\n    {body}\n}})")
366    } else {
367        format!("{iter_expr}.iter().map(|{let_var}| {{\n    {body}\n}})")
368    }
369}
370
371fn generate_element(
372    el: &Element,
373    depth: usize,
374    stateful: bool,
375    imports: &[ResolvedImport],
376    import_props: &[(&str, Option<&str>)],
377    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
378    _import_has_slots: &[(&str, bool)],
379    style_cascade: Option<&fncc_styles::Stylesheet>,
380    style_theme: Option<&str>,
381) -> String {
382    let indent = "    ".repeat(depth);
383
384    let code = match el.name.as_str() {
385        "Stack" => gen_stack(
386            el,
387            &indent,
388            depth,
389            stateful,
390            imports,
391            import_props,
392            prop_fields,
393            _import_has_slots,
394            style_cascade,
395            style_theme,
396        ),
397        "Text" => gen_text(
398            el,
399            &indent,
400            depth,
401            stateful,
402            imports,
403            import_props,
404            prop_fields,
405            _import_has_slots,
406            style_cascade,
407            style_theme,
408        ),
409        "Button" => gen_button(
410            el,
411            &indent,
412            depth,
413            stateful,
414            imports,
415            import_props,
416            prop_fields,
417            _import_has_slots,
418            style_cascade,
419            style_theme,
420        ),
421        "Fragment" => gen_fragment(
422            el,
423            &indent,
424            depth,
425            stateful,
426            imports,
427            import_props,
428            prop_fields,
429            _import_has_slots,
430            style_cascade,
431            style_theme,
432        ),
433        "For" => format!(
434            "div().children({})",
435            gen_for_expr(
436                el,
437                stateful,
438                imports,
439                import_props,
440                prop_fields,
441                _import_has_slots,
442                style_cascade,
443                style_theme
444            )
445        ),
446        "If" | "ElseIf" | "Else" => {
447            let branches = [el];
448            gen_if_expr(
449                &branches,
450                stateful,
451                imports,
452                import_props,
453                prop_fields,
454                _import_has_slots,
455                style_cascade,
456                style_theme,
457            )
458        }
459        "Slot" => "children".to_string(),
460        _ => {
461            if let Some(render_fn) = imports
462                .iter()
463                .find(|(name, fn_name)| name == &el.name && !fn_name.is_empty())
464                .map(|(_, fn_name)| *fn_name)
465            {
466                gen_import_call(
467                    el,
468                    &indent,
469                    render_fn,
470                    import_props,
471                    prop_fields,
472                    _import_has_slots,
473                    stateful,
474                    imports,
475                    depth,
476                    style_cascade,
477                    style_theme,
478                )
479            } else {
480                gen_fallback(
481                    el,
482                    &indent,
483                    depth,
484                    stateful,
485                    imports,
486                    import_props,
487                    prop_fields,
488                    _import_has_slots,
489                    style_cascade,
490                    style_theme,
491                )
492            }
493        }
494    };
495
496    match inject_element_styles(&code, el, style_cascade, style_theme) {
497        Ok(code) => code,
498        Err(e) => format!("compile_error!({e:?})"),
499    }
500}
501
502fn gen_import_call(
503    el: &Element,
504    indent: &str,
505    render_fn: &str,
506    import_props: &[(&str, Option<&str>)],
507    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
508    _import_has_slots: &[(&str, bool)],
509    stateful: bool,
510    imports: &[ResolvedImport],
511    depth: usize,
512    style_cascade: Option<&fncc_styles::Stylesheet>,
513    style_theme: Option<&str>,
514) -> String {
515    let props_type = import_props.iter().find(|(n, _)| n == &el.name).and_then(|(_, p)| *p);
516    let has_slot = _import_has_slots
517        .iter()
518        .find(|(n, _)| n == &el.name)
519        .map(|(_, h)| *h)
520        .unwrap_or(false);
521
522    let children_expr = if has_slot {
523        if el.children.is_empty() {
524            "div()".to_string()
525        } else {
526            let child_code = generate_children_code(
527                &el.children,
528                indent,
529                depth + 1,
530                stateful,
531                imports,
532                import_props,
533                prop_fields,
534                _import_has_slots,
535                style_cascade,
536                style_theme,
537            );
538            clean_inline(&format!("div(){child_code}"))
539        }
540    } else {
541        String::new()
542    };
543
544    if let Some(pt) = props_type {
545        let mut struct_fields = String::new();
546        if let Some(fields) = prop_fields.and_then(|m| m.get(pt)) {
547            for f in fields {
548                if let Some((_, attr_val)) = el.attrs.iter().find(|(n, _)| n == &f.name) {
549                    let v = match attr_val {
550                        AttrValue::String(s) => format!("{:?}.into()", s),
551                        AttrValue::Interpolation(expr) => format!("{}.into()", interpolation_expr(expr)),
552                    };
553                    struct_fields.push_str(&format!("\n{indent}        {}: {},", f.name, v));
554                } else if f.is_optional {
555                    struct_fields.push_str(&format!("\n{indent}        {}: None,", f.name));
556                }
557            }
558        } else {
559            for (attr_name, attr_val) in &el.attrs {
560                if attr_name == "class" || attr_name == "style" {
561                    continue;
562                }
563                let v = match attr_val {
564                    AttrValue::String(s) => format!("{:?}.into()", s),
565                    AttrValue::Interpolation(expr) => format!("{}.into()", interpolation_expr(expr)),
566                };
567                struct_fields.push_str(&format!("\n{indent}        {attr_name}: {v},"));
568            }
569        }
570        if has_slot {
571            format!("{indent}{render_fn}(&{pt} {{ {struct_fields}\n{indent}    }}, {children_expr})")
572        } else {
573            format!("{indent}{render_fn}(&{pt} {{ {struct_fields}\n{indent}    }})")
574        }
575    } else if has_slot {
576        format!("{indent}{render_fn}({children_expr})")
577    } else {
578        format!("{indent}{render_fn}()")
579    }
580}
581
582fn inject_element_styles(
583    code: &str,
584    el: &Element,
585    cascade: Option<&fncc_styles::Stylesheet>,
586    theme: Option<&str>,
587) -> Result<String, String> {
588    let cascade = match cascade {
589        Some(c) => c,
590        None => return Ok(code.to_string()),
591    };
592
593    let classes: Vec<String> = el
594        .attrs
595        .iter()
596        .filter(|(name, _)| name == "class")
597        .filter_map(|(_, val)| match val {
598            AttrValue::String(s) => Some(s.split_whitespace().map(|s| s.to_string()).collect::<Vec<_>>()),
599            AttrValue::Interpolation(_) => None,
600        })
601        .flatten()
602        .collect();
603
604    let inline_style = el
605        .attrs
606        .iter()
607        .find(|(name, _)| name == "style")
608        .and_then(|(_, val)| match val {
609            AttrValue::String(s) => Some(s.clone()),
610            AttrValue::Interpolation(_) => None,
611        });
612
613    if classes.is_empty() && inline_style.is_none() {
614        return Ok(code.to_string());
615    }
616
617    let calls = fncc_styles::resolve(&classes, inline_style.as_deref(), cascade, theme)?;
618
619    if calls.is_empty() {
620        return Ok(code.to_string());
621    }
622
623    // Find indentation from the last non-empty line in the code
624    let indent = code
625        .lines()
626        .rev()
627        .find(|l| !l.trim().is_empty())
628        .map(|l| l.len() - l.trim_start().len())
629        .unwrap_or(4);
630    let indent_str = " ".repeat(indent);
631
632    let style_code: String = calls.iter().map(|c| format!("\n{indent_str}.{}", c.code)).collect();
633
634    // Find first .child( or .children( line and insert before it
635    let mut insert_pos = None;
636    for (i, line) in code.lines().enumerate() {
637        let trimmed = line.trim_start();
638        if trimmed.starts_with(".child(") || trimmed.starts_with(".children(") {
639            insert_pos = Some(
640                code.lines()
641                    .take(i)
642                    .map(|l| l.len() + 1)
643                    .sum::<usize>()
644                    .saturating_sub(1),
645            );
646            break;
647        }
648    }
649
650    if let Some(pos) = insert_pos {
651        Ok(format!("{}{}{}", &code[..pos], style_code, &code[pos..]))
652    } else {
653        Ok(format!("{}{}", code.trim_end(), style_code))
654    }
655}
656
657/// Generate code for a `<Fragment>` — wraps children in `div()` using
658/// `generate_children_code` for consistent control-flow handling.
659fn gen_fragment(
660    el: &Element,
661    indent: &str,
662    depth: usize,
663    stateful: bool,
664    imports: &[ResolvedImport],
665    import_props: &[(&str, Option<&str>)],
666    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
667    _import_has_slots: &[(&str, bool)],
668    style_cascade: Option<&fncc_styles::Stylesheet>,
669    style_theme: Option<&str>,
670) -> String {
671    let children_code = generate_children_code(
672        &el.children,
673        indent,
674        depth,
675        stateful,
676        imports,
677        import_props,
678        prop_fields,
679        _import_has_slots,
680        style_cascade,
681        style_theme,
682    );
683    format!("{indent}div()\n{children_code}").trim_end().to_string()
684}
685
686/// Generate child code with control-flow awareness (If/ElseIf/Else chains, For, Fragment inlining).
687fn generate_children_code(
688    children: &[Node],
689    indent: &str,
690    depth: usize,
691    stateful: bool,
692    imports: &[ResolvedImport],
693    import_props: &[(&str, Option<&str>)],
694    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
695    _import_has_slots: &[(&str, bool)],
696    style_cascade: Option<&fncc_styles::Stylesheet>,
697    style_theme: Option<&str>,
698) -> String {
699    let mut out = String::new();
700    let mut i = 0;
701    while i < children.len() {
702        match &children[i] {
703            Node::Element(el) if el.name == "If" => {
704                let chain = collect_if_chain(children, i);
705                let expr = gen_if_expr(
706                    &chain,
707                    stateful,
708                    imports,
709                    import_props,
710                    prop_fields,
711                    _import_has_slots,
712                    style_cascade,
713                    style_theme,
714                );
715                out.push_str(&format!("{indent}    .child(\n"));
716                out.push_str(&format!("{indent}        {expr}\n"));
717                out.push_str(&format!("{indent}    )\n"));
718                i += chain.len();
719            }
720            Node::Element(el) if el.name == "For" => {
721                let for_code = gen_for_expr(
722                    el,
723                    stateful,
724                    imports,
725                    import_props,
726                    prop_fields,
727                    _import_has_slots,
728                    style_cascade,
729                    style_theme,
730                );
731                out.push_str(&format!("{indent}    .children(\n"));
732                out.push_str(&format!("{indent}        {for_code}\n"));
733                out.push_str(&format!("{indent}    )\n"));
734                i += 1;
735            }
736            Node::Element(el) if el.name == "Fragment" => {
737                let frag_code = generate_children_code(
738                    &el.children,
739                    indent,
740                    depth,
741                    stateful,
742                    imports,
743                    import_props,
744                    prop_fields,
745                    _import_has_slots,
746                    style_cascade,
747                    style_theme,
748                );
749                out.push_str(&frag_code);
750                i += 1;
751            }
752            _ => {
753                out.push_str(&format!("{indent}    .child(\n"));
754                match &children[i] {
755                    Node::Element(child_el) => {
756                        out.push_str(&generate_element(
757                            child_el,
758                            depth + 2,
759                            stateful,
760                            imports,
761                            import_props,
762                            prop_fields,
763                            _import_has_slots,
764                            style_cascade,
765                            style_theme,
766                        ));
767                    }
768                    Node::Text(t) => {
769                        out.push_str(&format!("{indent}        {:?}", t));
770                    }
771                    Node::Interpolation(expr) => {
772                        let e = interpolation_expr(expr);
773                        out.push_str(&format!("{indent}        {e}.to_string()"));
774                    }
775                }
776                out.push('\n');
777                out.push_str(&format!("{indent}    )\n"));
778                i += 1;
779            }
780        }
781    }
782    out
783}
784
785fn gen_stack(
786    el: &Element,
787    indent: &str,
788    depth: usize,
789    stateful: bool,
790    imports: &[ResolvedImport],
791    import_props: &[(&str, Option<&str>)],
792    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
793    _import_has_slots: &[(&str, bool)],
794    style_cascade: Option<&fncc_styles::Stylesheet>,
795    style_theme: Option<&str>,
796) -> String {
797    let mut out = format!("{indent}div()\n");
798
799    let mut is_vertical = false;
800    for (key, val) in &el.attrs {
801        match key.as_str() {
802            "direction" if val.as_str() == "vertical" => is_vertical = true,
803            "gap" => {
804                let v = val.as_str();
805                if let Ok(n) = v.parse::<f64>() {
806                    out.push_str(&format!("{indent}    .gap(px({}))\n", float_lit(n)));
807                }
808            }
809            _ => {}
810        }
811    }
812
813    if is_vertical {
814        out.push_str(&format!("{indent}    .flex()\n{indent}    .flex_col()\n"));
815    } else {
816        out.push_str(&format!("{indent}    .flex()\n"));
817    }
818
819    out.push_str(&generate_children_code(
820        &el.children,
821        indent,
822        depth,
823        stateful,
824        imports,
825        import_props,
826        prop_fields,
827        _import_has_slots,
828        style_cascade,
829        style_theme,
830    ));
831
832    out.trim_end().to_string()
833}
834
835fn gen_text(
836    el: &Element,
837    indent: &str,
838    depth: usize,
839    stateful: bool,
840    imports: &[ResolvedImport],
841    import_props: &[(&str, Option<&str>)],
842    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
843    _import_has_slots: &[(&str, bool)],
844    style_cascade: Option<&fncc_styles::Stylesheet>,
845    style_theme: Option<&str>,
846) -> String {
847    let mut out = format!("{indent}div()\n");
848
849    for (key, val) in &el.attrs {
850        if key.as_str() == "size" {
851            let v = val.as_str();
852            let ts = match v {
853                "xs" => "text_xs()",
854                "sm" => "text_sm()",
855                "base" => "text_base()",
856                "lg" => "text_lg()",
857                "xl" => "text_xl()",
858                "2xl" | "xxl" => "text_2xl()",
859                "3xl" => "text_3xl()",
860                _ => "text_base()",
861            };
862            out.push_str(&format!("{indent}    .{ts}\n"));
863        }
864    }
865
866    match &el.children[..] {
867        [Node::Text(t)] => {
868            out.push_str(&format!("{indent}    .child({:?})", t));
869        }
870        [Node::Interpolation(expr)] => {
871            let e = interpolation_expr(expr);
872            out.push_str(&format!("{indent}    .child({e}.to_string())"));
873        }
874        children => {
875            out.push_str(&generate_children_code(
876                children,
877                indent,
878                depth,
879                stateful,
880                imports,
881                import_props,
882                prop_fields,
883                _import_has_slots,
884                style_cascade,
885                style_theme,
886            ));
887        }
888    }
889
890    out.trim_end().to_string()
891}
892
893fn gen_button(
894    el: &Element,
895    indent: &str,
896    depth: usize,
897    stateful: bool,
898    imports: &[ResolvedImport],
899    import_props: &[(&str, Option<&str>)],
900    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
901    _import_has_slots: &[(&str, bool)],
902    style_cascade: Option<&fncc_styles::Stylesheet>,
903    style_theme: Option<&str>,
904) -> String {
905    let mut out = format!("{indent}div()\n");
906
907    let btn_id = match &el.children[..] {
908        [Node::Text(t)] => t.clone(),
909        _ => format!("button_{depth}"),
910    };
911    out.push_str(&format!("{indent}    .id({:?})\n", btn_id));
912    out.push_str(&format!("{indent}    .cursor_pointer()\n"));
913
914    for (key, val) in &el.attrs {
915        if key.as_str() == "onclick" {
916            let handler = val.as_str();
917            let trampoline = format!("__fncc_cmd_{handler}");
918            if stateful {
919                // Level 3: use entity handle pattern
920                out.push_str(&format!("{indent}    .on_click({{\n"));
921                out.push_str(&format!("{indent}        let handle = handle.clone();\n"));
922                out.push_str(&format!("{indent}        move |_, _, cx| {{\n"));
923                out.push_str(&format!("{indent}            handle.update(cx, |this, cx| {{\n"));
924                out.push_str(&format!("{indent}                {trampoline}(this, cx);\n"));
925                out.push_str(&format!("{indent}            }}).ok();\n"));
926                out.push_str(&format!("{indent}        }}\n"));
927                out.push_str(&format!("{indent}    }})\n"));
928            } else {
929                out.push_str(&format!("{indent}    .on_click({trampoline})\n"));
930            }
931        }
932    }
933
934    match &el.children[..] {
935        [Node::Text(t)] => out.push_str(&format!("{indent}    .child({:?})", t)),
936        [Node::Interpolation(expr)] => {
937            let e = interpolation_expr(expr);
938            out.push_str(&format!("{indent}    .child({e}.to_string())"));
939        }
940        children => {
941            out.push_str(&generate_children_code(
942                children,
943                indent,
944                depth,
945                stateful,
946                imports,
947                import_props,
948                prop_fields,
949                _import_has_slots,
950                style_cascade,
951                style_theme,
952            ));
953        }
954    }
955
956    out.trim_end().to_string()
957}
958
959fn gen_fallback(
960    el: &Element,
961    indent: &str,
962    depth: usize,
963    stateful: bool,
964    imports: &[ResolvedImport],
965    import_props: &[(&str, Option<&str>)],
966    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
967    _import_has_slots: &[(&str, bool)],
968    style_cascade: Option<&fncc_styles::Stylesheet>,
969    style_theme: Option<&str>,
970) -> String {
971    let mut out = format!("{indent}div()\n");
972    for (key, val) in &el.attrs {
973        if key == "class" || key == "style" {
974            continue;
975        }
976        let v = val.as_str();
977        out.push_str(&format!("{indent}    .attr({:?}, {:?})\n", key, v));
978    }
979    out.push_str(&generate_children_code(
980        &el.children,
981        indent,
982        depth,
983        stateful,
984        imports,
985        import_props,
986        prop_fields,
987        _import_has_slots,
988        style_cascade,
989        style_theme,
990    ));
991    out.trim_end().to_string()
992}
993
994// Collapse whitespace and remove space between `)` and `.` for inline expressions.
995fn clean_inline(s: &str) -> String {
996    let collapsed: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
997    collapsed.replace(") .", ").")
998}
999
1000/// Format an f64 as a valid Rust float literal: `12.` for whole numbers,
1001/// `12.5` otherwise (avoids invalid `12.5.`).
1002fn float_lit(n: f64) -> String {
1003    if n.fract() == 0.0 {
1004        format!("{n}.")
1005    } else {
1006        format!("{n}")
1007    }
1008}
1009
1010/// Resolve an interpolation expression to the correct Rust variable reference.
1011/// - `state.field` → `self.field` (for stateful components)
1012/// - `props.field` → `props.field` (for stateless components with props)
1013/// - `bare_expr` → `self.bare_expr` (fallback for stateful components)
1014fn interpolation_expr(expr: &str) -> String {
1015    let trimmed = expr.trim();
1016    if let Some(field) = trimmed.strip_prefix("state.") {
1017        format!("self.{field}")
1018    } else if trimmed.starts_with("props.") {
1019        trimmed.to_string()
1020    } else {
1021        format!("self.{trimmed}")
1022    }
1023}
1024
1025/// Replace `self.{var}` with `{var}` only when followed by a non-identifier character
1026/// or end of string. This prevents corrupting state fields that share a prefix with
1027/// a loop variable (e.g. `self.item_count` is not changed when var = "item").
1028fn replace_self_prefix(s: &str, var: &str) -> String {
1029    let pattern = format!("self.{var}");
1030    let mut out = String::new();
1031    let mut last = 0;
1032    for (idx, _) in s.match_indices(&pattern) {
1033        out.push_str(&s[last..idx]);
1034        let end = idx + pattern.len();
1035        let keep = if end < s.len() {
1036            let b = s.as_bytes()[end];
1037            b.is_ascii_alphanumeric() || b == b'_'
1038        } else {
1039            false
1040        };
1041        if keep {
1042            out.push_str(&pattern);
1043            last = idx + pattern.len();
1044        } else {
1045            out.push_str(var);
1046            last = end;
1047        }
1048    }
1049    out.push_str(&s[last..]);
1050    out
1051}
1052
1053pub(crate) fn to_snake_case(name: &str) -> String {
1054    let mut result = String::new();
1055    for (i, ch) in name.chars().enumerate() {
1056        if ch.is_uppercase() {
1057            if i > 0 {
1058                result.push('_');
1059            }
1060            for c in ch.to_lowercase() {
1061                result.push(c);
1062            }
1063        } else {
1064            result.push(ch);
1065        }
1066    }
1067    result
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072    use super::*;
1073    use crate::parser::parse;
1074
1075    fn cascade_none() -> Option<fncc_styles::Stylesheet> {
1076        None
1077    }
1078
1079    fn generate_from(source: &str) -> String {
1080        let doc = parse(source).unwrap();
1081        generate(&doc)
1082    }
1083
1084    // --- Happy path ---
1085
1086    #[test]
1087    fn test_generates_stateless_render_function() {
1088        let out = generate_from("<MyComp></MyComp>");
1089        assert!(out.contains("pub fn render_my_comp() -> impl IntoElement {"));
1090        assert!(out.contains("div()"));
1091    }
1092
1093    #[test]
1094    fn test_generates_stateful_render_impl() {
1095        let src = "---\n@state CounterState\n---\n<App></App>";
1096        let out = generate_from(src);
1097        assert!(out.contains("impl Render for CounterState {"));
1098        assert!(
1099            out.contains("fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {")
1100        );
1101    }
1102
1103    #[test]
1104    fn test_frontmatter_is_preserved_in_output() {
1105        let src = "---\nuse crate::prelude::*;\n---\n<App></App>";
1106        let out = generate_from(src);
1107        assert!(out.contains("use crate::prelude::*;"));
1108    }
1109
1110    #[test]
1111    fn test_stack_with_direction_vertical_generates_flex_col() {
1112        let out = generate_from("<Stack direction=\"vertical\"></Stack>");
1113        assert!(out.contains(".flex()"));
1114        assert!(out.contains(".flex_col()"));
1115    }
1116
1117    #[test]
1118    fn test_stack_with_direction_horizontal_generates_flex_only() {
1119        let out = generate_from("<Stack direction=\"horizontal\"></Stack>");
1120        assert!(out.contains(".flex()"));
1121        assert!(!out.contains(".flex_col()"));
1122    }
1123
1124    #[test]
1125    fn test_stack_with_gap_generates_px_value() {
1126        let out = generate_from("<Stack gap=\"12\"></Stack>");
1127        assert!(out.contains(".gap(px(12.))"));
1128    }
1129
1130    #[test]
1131    fn test_text_with_size_xl() {
1132        let out = generate_from("<Text size=\"xl\">hello</Text>");
1133        assert!(out.contains(".text_xl()"));
1134        assert!(out.contains(".child(\"hello\")"));
1135    }
1136
1137    #[test]
1138    fn test_text_with_size_unknown_falls_back_to_base() {
1139        let out = generate_from("<Text size=\"huge\">text</Text>");
1140        assert!(out.contains(".text_base()"));
1141    }
1142
1143    #[test]
1144    fn test_button_with_text_child() {
1145        let out = generate_from("<Button onclick=\"handle_click\">Click</Button>");
1146        assert!(out.contains(".id(\"Click\")"));
1147        assert!(out.contains(".cursor_pointer()"));
1148        assert!(out.contains(".child(\"Click\")"));
1149    }
1150
1151    #[test]
1152    fn test_stateful_button_with_onclick_generates_entity_pattern() {
1153        let src = "---\n@state AppState\n---\n<Button onclick=\"inc\">+1</Button>";
1154        let out = generate_from(src);
1155        assert!(out.contains("let handle = handle.clone();"));
1156        assert!(out.contains("handle.update(cx, |this, cx| {"));
1157        assert!(out.contains("__fncc_cmd_inc(this, cx);"));
1158    }
1159
1160    #[test]
1161    fn test_stateless_button_with_onclick_generates_direct_call() {
1162        let out = generate_from("<Button onclick=\"log_click\">Go</Button>");
1163        assert!(out.contains(".on_click(__fncc_cmd_log_click)"));
1164    }
1165
1166    // --- Edge cases ---
1167
1168    #[test]
1169    fn test_empty_element_children() {
1170        let out = generate_from("<Div></Div>");
1171        assert!(out.contains("div()"));
1172    }
1173
1174    #[test]
1175    fn test_unknown_element_falls_back_to_div_with_attrs() {
1176        let out = generate_from("<CustomEl foo=\"bar\">content</CustomEl>");
1177        assert!(out.contains(".attr(\"foo\", \"bar\")"));
1178        assert!(out.contains(".child("));
1179        assert!(out.contains("\"content\""));
1180    }
1181
1182    #[test]
1183    fn test_interpolation_in_text_content_generates_format() {
1184        let out = generate_from("---\n@state S\n---\n<Text>{state.msg}</Text>");
1185        assert!(out.contains("self.msg.to_string()"));
1186        assert!(!out.contains("self.state.msg"));
1187    }
1188
1189    #[test]
1190    fn test_interpolation_strips_state_prefix() {
1191        let out = generate_from("<Text>{state.count}</Text>");
1192        // stateless, so state. prefix is stripped but no self. prefix
1193        assert!(out.contains("self.count.to_string()"));
1194    }
1195
1196    #[test]
1197    fn test_multiple_commands_collected_in_validation_fn() {
1198        let src = "<Stack><Button onclick=\"a\">A</Button><Button onclick=\"b\">B</Button></Stack>";
1199        let out = generate_from(src);
1200        assert!(out.contains("fn _fncc_validate_"));
1201        assert!(out.contains("__fncc_cmd_a"));
1202        assert!(out.contains("__fncc_cmd_b"));
1203    }
1204
1205    // --- Contract tests ---
1206
1207    #[test]
1208    fn test_generated_code_contains_no_markdown_or_template_leftovers() {
1209        let out = generate_from("<Text>hello</Text>");
1210        assert!(!out.contains("{{"));
1211        assert!(!out.contains("{state."));
1212        assert!(!out.contains("__fncc_cmd_") || out.contains("__fncc_cmd_"));
1213    }
1214
1215    #[test]
1216    fn test_generated_function_name_follows_snake_case() {
1217        let out = generate_from("<HTMLParser></HTMLParser>");
1218        assert!(out.contains("render_h_t_m_l_parser") || out.contains("render_html_parser"));
1219    }
1220
1221    // --- Regression tests ---
1222
1223    #[test]
1224    fn test_regression_gap_with_decimal_does_not_produce_invalid_syntax() {
1225        let out = generate_from("<Stack gap=\"12.5\"></Stack>");
1226        assert!(out.contains(".gap(px(12.5))"), "got: {out}");
1227    }
1228
1229    #[test]
1230    fn test_regression_duplicate_button_ids_at_same_depth() {
1231        let src = "<Stack><Button>OK</Button><Button>OK</Button></Stack>";
1232        let out = generate_from(src);
1233        let id_count = out.matches(".id(\"OK\")").count();
1234        assert!(
1235            id_count <= 2,
1236            "expected at most 2 .id(\"OK\") occurrences, got {id_count}"
1237        );
1238    }
1239
1240    #[test]
1241    fn test_regression_multiple_calls_have_unique_validation_fn_names() {
1242        let doc = parse("<Button onclick=\"x\">X</Button>").unwrap();
1243        let a = generate_with_id(&doc, 0);
1244        let b = generate_with_id(&doc, 1);
1245        assert_ne!(a, b);
1246        assert!(a.contains("__fncc_cmd_x"));
1247        assert!(b.contains("__fncc_cmd_x"));
1248    }
1249
1250    #[test]
1251    fn test_regression_empty_gap_does_not_panic() {
1252        let out = generate_from("<Stack gap=\"\"></Stack>");
1253        // empty string fails to parse as f64, should skip gap
1254        assert!(!out.contains(".gap(") || out.contains(".gap(px(0.))"));
1255    }
1256
1257    #[test]
1258    fn test_regression_interpolation_without_state_prefix() {
1259        let out = generate_from("---\n@state S\n---\n<Text>{custom_expr}</Text>");
1260        // custom_expr doesn't start with "state.", so interpolation_expr returns "self.custom_expr"
1261        assert!(out.contains("custom_expr") || out.contains("self.custom_expr"));
1262    }
1263
1264    #[test]
1265    fn test_regression_many_calls_do_not_panic() {
1266        for _ in 0..100 {
1267            generate_from("<Button onclick=\"f\">F</Button>");
1268        }
1269    }
1270
1271    // --- Component imports ---
1272
1273    #[test]
1274    fn test_imported_element_generates_render_call() {
1275        let doc = parse("<Stack><Header /></Stack>").unwrap();
1276        let imports: &[(&str, &str)] = &[("Header", "render_header")];
1277        let out = generate_with_imports(
1278            &doc,
1279            0,
1280            imports,
1281            None,
1282            None,
1283            None,
1284            &[],
1285            None,
1286            &[],
1287            cascade_none().as_ref(),
1288            None,
1289        );
1290        assert!(out.contains("render_header()"));
1291    }
1292
1293    #[test]
1294    fn test_imported_element_in_stateful_component() {
1295        let src = "---\n@state AppState\n---\n<Stack><Footer /></Stack>";
1296        let doc = parse(src).unwrap();
1297        let imports: &[(&str, &str)] = &[("Footer", "render_footer")];
1298        let out = generate_with_imports(
1299            &doc,
1300            0,
1301            imports,
1302            None,
1303            None,
1304            None,
1305            &[],
1306            None,
1307            &[],
1308            cascade_none().as_ref(),
1309            None,
1310        );
1311        assert!(out.contains("render_footer()"));
1312    }
1313
1314    #[test]
1315    fn test_gpui_import_falls_back_to_div() {
1316        let doc = parse("<Stack><TextInput /></Stack>").unwrap();
1317        let imports: &[(&str, &str)] = &[("TextInput", "")];
1318        let out = generate_with_imports(
1319            &doc,
1320            0,
1321            imports,
1322            None,
1323            None,
1324            None,
1325            &[],
1326            None,
1327            &[],
1328            cascade_none().as_ref(),
1329            None,
1330        );
1331        // GPUI imports have empty render fn — fall through to div
1332        assert!(out.contains("div()"));
1333    }
1334
1335    #[test]
1336    fn test_builtin_takes_precedence_over_import() {
1337        let doc = parse("<Text>hello</Text>").unwrap();
1338        let imports: &[(&str, &str)] = &[("Text", "render_text")];
1339        let out = generate_with_imports(
1340            &doc,
1341            0,
1342            imports,
1343            None,
1344            None,
1345            None,
1346            &[],
1347            None,
1348            &[],
1349            cascade_none().as_ref(),
1350            None,
1351        );
1352        // Built-in "Text" handling takes precedence, not render_text()
1353        assert!(out.contains(".child(\"hello\")"));
1354    }
1355
1356    #[test]
1357    fn test_imported_element_with_custom_component_name() {
1358        let doc = parse("<Stack><MyHeader /></Stack>").unwrap();
1359        let imports: &[(&str, &str)] = &[("MyHeader", "render_header")];
1360        let out = generate_with_imports(
1361            &doc,
1362            0,
1363            imports,
1364            None,
1365            None,
1366            None,
1367            &[],
1368            None,
1369            &[],
1370            cascade_none().as_ref(),
1371            None,
1372        );
1373        assert!(out.contains("render_header()"));
1374    }
1375
1376    #[test]
1377    fn test_render_fn_name_uses_component_name_arg() {
1378        let doc = parse("<Text>hello</Text>").unwrap();
1379        let out = generate_with_imports(
1380            &doc,
1381            0,
1382            &[],
1383            Some("CustomWidget"),
1384            None,
1385            None,
1386            &[],
1387            None,
1388            &[],
1389            cascade_none().as_ref(),
1390            None,
1391        );
1392        assert!(out.contains("pub fn render_custom_widget()"));
1393        // Should NOT use root element name
1394        assert!(!out.contains("pub fn render_text()"));
1395    }
1396
1397    // --- Props tests ---
1398
1399    #[test]
1400    fn test_props_stateless_component_with_props_signature() {
1401        let doc = parse("<Text>{props.title}</Text>").unwrap();
1402        let out = generate_with_imports(
1403            &doc,
1404            0,
1405            &[],
1406            Some("Header"),
1407            None,
1408            Some("HeaderProps"),
1409            &[],
1410            None,
1411            &[],
1412            cascade_none().as_ref(),
1413            None,
1414        );
1415        assert!(out.contains("pub fn render_header(props: &HeaderProps) -> impl IntoElement {"));
1416        assert!(out.contains("props.title"));
1417    }
1418
1419    #[test]
1420    fn test_props_caller_generates_struct_construction() {
1421        let doc = parse("<Header title=\"Welcome\" />").unwrap();
1422        let imports: &[(&str, &str)] = &[("Header", "render_header")];
1423        let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps"))];
1424        let out = generate_with_imports(
1425            &doc,
1426            0,
1427            imports,
1428            None,
1429            None,
1430            None,
1431            import_props,
1432            None,
1433            &[],
1434            cascade_none().as_ref(),
1435            None,
1436        );
1437        assert!(out.contains("render_header(&HeaderProps {"));
1438        assert!(out.contains("title: \"Welcome\".into(),"));
1439        assert!(out.contains("})"));
1440    }
1441
1442    #[test]
1443    fn test_props_caller_multiple_attributes() {
1444        let doc = parse("<Header title=\"Hi\" subtitle=\"World\" />").unwrap();
1445        let imports: &[(&str, &str)] = &[("Header", "render_header")];
1446        let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps"))];
1447        let out = generate_with_imports(
1448            &doc,
1449            0,
1450            imports,
1451            None,
1452            None,
1453            None,
1454            import_props,
1455            None,
1456            &[],
1457            cascade_none().as_ref(),
1458            None,
1459        );
1460        assert!(out.contains("title: \"Hi\".into(),"));
1461        assert!(out.contains("subtitle: \"World\".into(),"));
1462    }
1463
1464    #[test]
1465    fn test_props_caller_option_field() {
1466        let doc = parse("<Header title=\"Hi\" />").unwrap();
1467        let imports: &[(&str, &str)] = &[("Header", "render_header")];
1468        let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps"))];
1469        let out = generate_with_imports(
1470            &doc,
1471            0,
1472            imports,
1473            None,
1474            None,
1475            None,
1476            import_props,
1477            None,
1478            &[],
1479            cascade_none().as_ref(),
1480            None,
1481        );
1482        // Option<T> fields are transparent at codegen — .into() handles conversion
1483        assert!(out.contains("title: \"Hi\".into(),"));
1484    }
1485
1486    #[test]
1487    fn test_props_nested_element_with_props() {
1488        let doc = parse("<Stack><Header title=\"Nested\" /><Text>ok</Text></Stack>").unwrap();
1489        let imports: &[(&str, &str)] = &[("Header", "render_header")];
1490        let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps"))];
1491        let out = generate_with_imports(
1492            &doc,
1493            0,
1494            imports,
1495            None,
1496            None,
1497            None,
1498            import_props,
1499            None,
1500            &[],
1501            cascade_none().as_ref(),
1502            None,
1503        );
1504        assert!(out.contains("render_header(&HeaderProps {"));
1505        assert!(out.contains("title: \"Nested\".into(),"));
1506    }
1507
1508    #[test]
1509    fn test_props_mixed_components_with_and_without_props() {
1510        let doc = parse("<Stack><Header title=\"A\" /><Footer /></Stack>").unwrap();
1511        let imports: &[(&str, &str)] = &[("Header", "render_header"), ("Footer", "render_footer")];
1512        let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps")), ("Footer", None)];
1513        let out = generate_with_imports(
1514            &doc,
1515            0,
1516            imports,
1517            None,
1518            None,
1519            None,
1520            import_props,
1521            None,
1522            &[],
1523            cascade_none().as_ref(),
1524            None,
1525        );
1526        assert!(out.contains("render_header(&HeaderProps {"));
1527        assert!(out.contains("title: \"A\".into(),"));
1528        assert!(out.contains("render_footer()"));
1529    }
1530
1531    #[test]
1532    fn test_props_element_without_props_still_calls_directly() {
1533        let doc = parse("<Footer />").unwrap();
1534        let imports: &[(&str, &str)] = &[("Footer", "render_footer")];
1535        let import_props: &[(&str, Option<&str>)] = &[("Footer", None)];
1536        let out = generate_with_imports(
1537            &doc,
1538            0,
1539            imports,
1540            None,
1541            None,
1542            None,
1543            import_props,
1544            None,
1545            &[],
1546            cascade_none().as_ref(),
1547            None,
1548        );
1549        assert!(out.contains("render_footer()"));
1550        assert!(!out.contains("&"));
1551    }
1552
1553    #[test]
1554    fn test_props_self_closing_with_props() {
1555        let doc = parse("<Header title=\"SelfClose\" subtitle=\"X\" />").unwrap();
1556        let imports: &[(&str, &str)] = &[("Header", "render_header")];
1557        let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps"))];
1558        let out = generate_with_imports(
1559            &doc,
1560            0,
1561            imports,
1562            None,
1563            None,
1564            None,
1565            import_props,
1566            None,
1567            &[],
1568            cascade_none().as_ref(),
1569            None,
1570        );
1571        assert!(out.contains("title: \"SelfClose\".into(),"));
1572        assert!(out.contains("subtitle: \"X\".into(),"));
1573    }
1574
1575    // --- Control Flow & Composition tests ---
1576
1577    #[test]
1578    fn test_if_generates_if_expression() {
1579        let out = generate_from("<If condition=\"{state.show}\"><Text>Hi</Text></If>");
1580        assert!(out.contains("if self.show {"));
1581        assert!(out.contains("div().child(div().child(\"Hi\"))"));
1582        assert!(out.contains("} else {"), "else branch should be present");
1583        assert!(out.contains("div() }"));
1584    }
1585
1586    #[test]
1587    fn test_if_else_chain() {
1588        let src = "<Stack><If condition=\"{state.a}\"><Text>A</Text></If><Else><Text>B</Text></Else></Stack>";
1589        let out = generate_from(src);
1590        assert!(out.contains("if self.a {"));
1591        assert!(out.contains("} else {"));
1592        assert!(out.contains("div().child(\"B\")"));
1593    }
1594
1595    #[test]
1596    fn test_if_elseif_else_chain() {
1597        let src = "<Stack><If condition=\"{state.x}\"><Text>X</Text></If><ElseIf condition=\"{state.y}\"><Text>Y</Text></ElseIf><Else><Text>Z</Text></Else></Stack>";
1598        let out = generate_from(src);
1599        assert!(out.contains("if self.x {"));
1600        assert!(out.contains("else if self.y {"));
1601        assert!(out.contains("else {"));
1602    }
1603
1604    #[test]
1605    fn test_for_generates_iteration() {
1606        let out = generate_from("<For each=\"{state.items}\" let=\"item\"><Text>{item.name}</Text></For>");
1607        assert!(out.contains("self.items.iter().map(|item| {"));
1608        assert!(out.contains("item.name"));
1609    }
1610
1611    #[test]
1612    fn test_for_with_index() {
1613        let out = generate_from("<For each=\"{state.items}\" let=\"item\" index=\"i\"><Text>{item.name}</Text></For>");
1614        assert!(out.contains(".enumerate()"));
1615        assert!(out.contains("|(i, item)|"));
1616    }
1617
1618    #[test]
1619    fn test_fragment_generates_div() {
1620        let out = generate_from("<Fragment><Text>A</Text><Text>B</Text></Fragment>");
1621        assert!(out.contains("div()"));
1622    }
1623
1624    #[test]
1625    fn test_slot_in_stateless_component() {
1626        let doc = parse("<Stack><Slot /></Stack>").unwrap();
1627        let out = generate_with_imports(
1628            &doc,
1629            0,
1630            &[],
1631            Some("Card"),
1632            None,
1633            None,
1634            &[],
1635            None,
1636            &[],
1637            cascade_none().as_ref(),
1638            None,
1639        );
1640        assert!(out.contains("children: impl IntoElement"));
1641        assert!(out.contains("children"));
1642    }
1643
1644    #[test]
1645    fn test_if_in_stack() {
1646        let src = "<Stack><If condition=\"{state.flag}\"><Text>Yes</Text></If><Text>Always</Text></Stack>";
1647        let out = generate_from(src);
1648        assert!(out.contains("if self.flag {"));
1649        assert!(out.contains("\"Always\""));
1650    }
1651
1652    #[test]
1653    fn test_for_in_stack() {
1654        let src = "<Stack><For each=\"{state.items}\" let=\"item\"><Text>{item}</Text></For></Stack>";
1655        let out = generate_from(src);
1656        assert!(out.contains("self.items.iter().map(|item| {"));
1657        assert!(out.contains(".children("));
1658    }
1659
1660    #[test]
1661    fn test_fragment_nested_in_stack() {
1662        let src = "<Stack><Fragment><Text>A</Text><Text>B</Text></Fragment></Stack>";
1663        let out = generate_from(src);
1664        assert!(out.contains("div()"));
1665        assert!(out.contains(".child(\"A\")"));
1666        assert!(out.contains(".child(\"B\")"));
1667    }
1668
1669    // --- Style injection tests ---
1670
1671    #[test]
1672    fn test_style_class_injects_method_calls() {
1673        let doc = parse("<Text class=\"heading\">Title</Text>").unwrap();
1674        let mut ss = fncc_styles::Stylesheet::default();
1675        ss.rules.insert(
1676            "heading".into(),
1677            vec![("color".into(), "red".into()), ("font-size".into(), "24px".into())],
1678        );
1679        let out = generate_with_imports(&doc, 0, &[], None, None, None, &[], None, &[], Some(&ss), None);
1680        assert!(
1681            out.contains(".text_color(rgba(0xff0000ff))"),
1682            "missing text_color: {out}"
1683        );
1684        assert!(out.contains(".text_2xl()"), "missing text_2xl: {out}");
1685    }
1686
1687    #[test]
1688    fn test_style_inline_injects_method_calls() {
1689        let doc = parse("<Text style=\"color: blue; padding: 8px\">Hi</Text>").unwrap();
1690        let ss = fncc_styles::Stylesheet::default();
1691        let out = generate_with_imports(&doc, 0, &[], None, None, None, &[], None, &[], Some(&ss), None);
1692        assert!(out.contains(".text_color(rgba(0x0000ffff))"));
1693        assert!(out.contains(".p(px(8.))"));
1694    }
1695
1696    #[test]
1697    fn test_style_class_unknown_skipped() {
1698        let doc = parse("<Text class=\"nonexistent\">Hi</Text>").unwrap();
1699        let ss = fncc_styles::Stylesheet::default();
1700        let out = generate_with_imports(&doc, 0, &[], None, None, None, &[], None, &[], Some(&ss), None);
1701        // Unknown class should be silently ignored
1702        assert!(out.contains(".child(\"Hi\")"));
1703    }
1704
1705    #[test]
1706    fn test_style_class_and_inline_merged() {
1707        let doc = parse("<Button class=\"big\" style=\"color: white\">Go</Button>").unwrap();
1708        let mut ss = fncc_styles::Stylesheet::default();
1709        ss.rules.insert(
1710            "big".into(),
1711            vec![("padding".into(), "16px".into()), ("font-size".into(), "20px".into())],
1712        );
1713        let out = generate_with_imports(&doc, 0, &[], None, None, None, &[], None, &[], Some(&ss), None);
1714        assert!(out.contains(".p(px(16.))"));
1715        assert!(out.contains(".text_xl()"));
1716        assert!(out.contains(".text_color(rgba(0xffffffff))"));
1717    }
1718}