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