Skip to main content

fncc_core/
codegen.rs

1use std::collections::HashMap;
2
3use crate::parser::{self, AttrValue, Document, Element, Node};
4use crate::semantic::PropField;
5
6pub fn generate(doc: &Document) -> String {
7    generate_with_id(doc, 0)
8}
9
10pub fn generate_with_id(doc: &Document, file_id: usize) -> String {
11    generate_with_imports(doc, file_id, &[], None, None, None, &[], None)
12}
13
14/// Resolved import entry: (tag_name, render_fn_name)
15/// - For .fui component imports: ("Header", "render_header")
16/// - For gpui imports: ("TextInput", "") — registered but no special codegen
17pub type ResolvedImport<'a> = (&'a str, &'a str);
18
19/// Generate code for a document with a specific component name.
20/// `component_name` is used for the render function name (derived from the file stem).
21/// If `None`, falls back to the root element name (backward compat).
22/// `resolved_state_type` overrides the document's `@state` directive (used by semantic analysis).
23pub fn generate_with_imports(
24    doc: &Document,
25    file_id: usize,
26    imports: &[ResolvedImport],
27    component_name: Option<&str>,
28    resolved_state_type: Option<&str>,
29    props_type: Option<&str>,
30    import_props: &[(&str, Option<&str>)],
31    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
32) -> String {
33    let mut out = String::new();
34    let state_type = resolved_state_type.or(doc.state_type.as_deref());
35    let has_state = state_type.is_some();
36
37    if let Some(ref fm) = doc.frontmatter {
38        out.push_str(fm);
39        out.push('\n');
40    }
41
42    // collect referenced command names for validation
43    let commands = parser::collect_commands(&doc.root);
44    if !commands.is_empty() {
45        out.push_str("#[allow(unused)]\n");
46        out.push_str(&format!("fn _fncc_validate_{file_id}() {{\n"));
47        for cmd in &commands {
48            let trampoline = format!("__fncc_cmd_{cmd}");
49            out.push_str(&format!("    let _ = {trampoline};\n"));
50        }
51        out.push_str("}\n\n");
52    }
53
54    if has_state {
55        generate_stateful(doc, &mut out, imports, state_type, import_props, prop_fields);
56    } else {
57        generate_stateless(
58            doc,
59            &mut out,
60            imports,
61            component_name,
62            props_type,
63            import_props,
64            prop_fields,
65        );
66    }
67
68    out
69}
70
71fn generate_stateless(
72    doc: &Document,
73    out: &mut String,
74    imports: &[ResolvedImport],
75    component_name: Option<&str>,
76    props_type: Option<&str>,
77    import_props: &[(&str, Option<&str>)],
78    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
79) {
80    let name = component_name.unwrap_or(&doc.root.name);
81    let fn_name = format!("render_{}", to_snake_case(name));
82    if let Some(pt) = props_type {
83        out.push_str(&format!("pub fn {fn_name}(props: &{pt}) -> impl IntoElement {{\n"));
84    } else {
85        out.push_str(&format!("pub fn {fn_name}() -> impl IntoElement {{\n"));
86    }
87    out.push_str(&generate_element(
88        &doc.root,
89        1,
90        false,
91        imports,
92        import_props,
93        prop_fields,
94    ));
95    out.push('\n');
96    out.push_str("}\n");
97}
98
99fn generate_stateful(
100    doc: &Document,
101    out: &mut String,
102    imports: &[ResolvedImport],
103    state_type: Option<&str>,
104    import_props: &[(&str, Option<&str>)],
105    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
106) {
107    let state_type = state_type.unwrap_or("Self");
108
109    out.push_str(&format!("impl Render for {state_type} {{\n"));
110    out.push_str("    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {\n");
111    out.push_str("        let handle = cx.entity().downgrade();\n");
112    out.push_str(&generate_element(
113        &doc.root,
114        2,
115        true,
116        imports,
117        import_props,
118        prop_fields,
119    ));
120    out.push_str("\n    }\n");
121    out.push_str("}\n");
122}
123
124fn generate_element(
125    el: &Element,
126    depth: usize,
127    stateful: bool,
128    imports: &[ResolvedImport],
129    import_props: &[(&str, Option<&str>)],
130    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
131) -> String {
132    let indent = "    ".repeat(depth);
133
134    // Built-in elements
135    match el.name.as_str() {
136        "Stack" => return gen_stack(el, &indent, depth, stateful, imports, import_props, prop_fields),
137        "Text" => return gen_text(el, &indent, depth, stateful, imports, import_props, prop_fields),
138        "Button" => return gen_button(el, &indent, depth, stateful, imports, import_props, prop_fields),
139        _ => {}
140    }
141
142    // .fui component imports (have a non-empty render function name)
143    if let Some(render_fn) = imports
144        .iter()
145        .find(|(name, fn_name)| name == &el.name && !fn_name.is_empty())
146        .map(|(_, fn_name)| *fn_name)
147    {
148        let props_type = import_props.iter().find(|(n, _)| n == &el.name).and_then(|(_, p)| *p);
149        return if let Some(pt) = props_type {
150            let mut struct_fields = String::new();
151            if let Some(fields) = prop_fields.and_then(|m| m.get(pt)) {
152                for f in fields {
153                    if let Some((_, attr_val)) = el.attrs.iter().find(|(n, _)| n == &f.name) {
154                        let v = match attr_val {
155                            AttrValue::String(s) => format!("{:?}.into()", s),
156                            AttrValue::Interpolation(expr) => {
157                                format!("{}.into()", interpolation_expr(expr))
158                            }
159                        };
160                        struct_fields.push_str(&format!("\n{indent}        {}: {},", f.name, v));
161                    } else if f.is_optional {
162                        struct_fields.push_str(&format!("\n{indent}        {}: None,", f.name));
163                    }
164                }
165            } else {
166                for (attr_name, attr_val) in &el.attrs {
167                    let v = match attr_val {
168                        AttrValue::String(s) => format!("{:?}.into()", s),
169                        AttrValue::Interpolation(expr) => {
170                            format!("{}.into()", interpolation_expr(expr))
171                        }
172                    };
173                    struct_fields.push_str(&format!("\n{indent}        {attr_name}: {v},"));
174                }
175            }
176            format!("{indent}{render_fn}(&{pt} {{ {struct_fields}\n{indent}    }})")
177        } else {
178            format!("{indent}{render_fn}()")
179        };
180    }
181
182    // Fallback for gpui imports and unknown elements
183    gen_fallback(el, &indent, depth, stateful, imports, import_props, prop_fields)
184}
185
186fn gen_stack(
187    el: &Element,
188    indent: &str,
189    depth: usize,
190    stateful: bool,
191    imports: &[ResolvedImport],
192    import_props: &[(&str, Option<&str>)],
193    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
194) -> String {
195    let mut out = format!("{indent}div()\n");
196
197    let mut is_vertical = false;
198    for (key, val) in &el.attrs {
199        match key.as_str() {
200            "direction" if val.as_str() == "vertical" => is_vertical = true,
201            "gap" => {
202                let v = val.as_str();
203                if let Ok(n) = v.parse::<f64>() {
204                    out.push_str(&format!("{indent}    .gap(px({n}.))\n"));
205                }
206            }
207            _ => {}
208        }
209    }
210
211    if is_vertical {
212        out.push_str(&format!("{indent}    .flex()\n{indent}    .flex_col()\n"));
213    } else {
214        out.push_str(&format!("{indent}    .flex()\n"));
215    }
216
217    for child in &el.children {
218        out.push_str(&format!("{indent}    .child(\n"));
219        match child {
220            Node::Element(child_el) => {
221                out.push_str(&generate_element(
222                    child_el,
223                    depth + 2,
224                    stateful,
225                    imports,
226                    import_props,
227                    prop_fields,
228                ));
229            }
230            Node::Text(t) => {
231                out.push_str(&format!("{indent}        \"{t}\""));
232            }
233            Node::Interpolation(expr) => {
234                let e = interpolation_expr(expr);
235                out.push_str(&format!("{indent}        {e}.to_string()"));
236            }
237        }
238        out.push('\n');
239        out.push_str(&format!("{indent}    )\n"));
240    }
241
242    out.trim_end().to_string()
243}
244
245fn gen_text(
246    el: &Element,
247    indent: &str,
248    depth: usize,
249    stateful: bool,
250    imports: &[ResolvedImport],
251    import_props: &[(&str, Option<&str>)],
252    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
253) -> String {
254    let mut out = format!("{indent}div()\n");
255
256    for (key, val) in &el.attrs {
257        if key.as_str() == "size" {
258            let v = val.as_str();
259            let ts = match v {
260                "xs" => "text_xs()",
261                "sm" => "text_sm()",
262                "base" => "text_base()",
263                "lg" => "text_lg()",
264                "xl" => "text_xl()",
265                "2xl" | "xxl" => "text_2xl()",
266                "3xl" => "text_3xl()",
267                _ => "text_base()",
268            };
269            out.push_str(&format!("{indent}    .{ts}\n"));
270        }
271    }
272
273    match &el.children[..] {
274        [Node::Text(t)] => {
275            out.push_str(&format!("{indent}    .child(\"{t}\")"));
276        }
277        [Node::Interpolation(expr)] => {
278            let e = interpolation_expr(expr);
279            out.push_str(&format!("{indent}    .child({e}.to_string())"));
280        }
281        children => {
282            for child in children {
283                match child {
284                    Node::Text(t) => out.push_str(&format!("{indent}    .child(\"{t}\")\n")),
285                    Node::Interpolation(expr) => {
286                        let e = interpolation_expr(expr);
287                        out.push_str(&format!("{indent}    .child({e}.to_string())\n"));
288                    }
289                    Node::Element(child_el) => {
290                        out.push_str(&format!("{indent}    .child(\n"));
291                        out.push_str(&generate_element(
292                            child_el,
293                            depth + 1,
294                            stateful,
295                            imports,
296                            import_props,
297                            prop_fields,
298                        ));
299                        out.push_str(&format!("\n{indent}    )\n"));
300                    }
301                }
302            }
303        }
304    }
305
306    out.trim_end().to_string()
307}
308
309fn gen_button(
310    el: &Element,
311    indent: &str,
312    depth: usize,
313    stateful: bool,
314    imports: &[ResolvedImport],
315    import_props: &[(&str, Option<&str>)],
316    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
317) -> String {
318    let mut out = format!("{indent}div()\n");
319
320    let btn_id = match &el.children[..] {
321        [Node::Text(t)] => t.clone(),
322        _ => format!("button_{depth}"),
323    };
324    out.push_str(&format!("{indent}    .id(\"{btn_id}\")\n"));
325    out.push_str(&format!("{indent}    .cursor_pointer()\n"));
326
327    for (key, val) in &el.attrs {
328        if key.as_str() == "onclick" {
329            let handler = val.as_str();
330            let trampoline = format!("__fncc_cmd_{handler}");
331            if stateful {
332                // Level 3: use entity handle pattern
333                out.push_str(&format!("{indent}    .on_click({{\n"));
334                out.push_str(&format!("{indent}        let handle = handle.clone();\n"));
335                out.push_str(&format!("{indent}        move |_, _, cx| {{\n"));
336                out.push_str(&format!("{indent}            handle.update(cx, |this, cx| {{\n"));
337                out.push_str(&format!("{indent}                {trampoline}(this, cx);\n"));
338                out.push_str(&format!("{indent}            }}).ok();\n"));
339                out.push_str(&format!("{indent}        }}\n"));
340                out.push_str(&format!("{indent}    }})\n"));
341            } else {
342                out.push_str(&format!("{indent}    .on_click({trampoline})\n"));
343            }
344        }
345    }
346
347    match &el.children[..] {
348        [Node::Text(t)] => out.push_str(&format!("{indent}    .child(\"{t}\")")),
349        [Node::Interpolation(expr)] => {
350            let e = interpolation_expr(expr);
351            out.push_str(&format!("{indent}    .child({e}.to_string())"));
352        }
353        children => {
354            for child in children {
355                match child {
356                    Node::Text(t) => out.push_str(&format!("{indent}    .child(\"{t}\")\n")),
357                    Node::Element(child_el) => {
358                        out.push_str(&format!("{indent}    .child(\n"));
359                        out.push_str(&generate_element(
360                            child_el,
361                            depth + 1,
362                            stateful,
363                            imports,
364                            import_props,
365                            prop_fields,
366                        ));
367                        out.push_str(&format!("\n{indent}    )\n"));
368                    }
369                    Node::Interpolation(expr) => {
370                        let e = interpolation_expr(expr);
371                        out.push_str(&format!("{indent}    .child({e}.to_string())\n"));
372                    }
373                }
374            }
375        }
376    }
377
378    out.trim_end().to_string()
379}
380
381fn gen_fallback(
382    el: &Element,
383    indent: &str,
384    depth: usize,
385    stateful: bool,
386    imports: &[ResolvedImport],
387    import_props: &[(&str, Option<&str>)],
388    prop_fields: Option<&HashMap<String, Vec<PropField>>>,
389) -> String {
390    let mut out = format!("{indent}div()\n");
391    for (key, val) in &el.attrs {
392        let v = val.as_str();
393        out.push_str(&format!("{indent}    .attr(\"{key}\", \"{v}\")\n"));
394    }
395    for child in &el.children {
396        out.push_str(&format!("{indent}    .child(\n"));
397        match child {
398            Node::Element(child_el) => {
399                out.push_str(&generate_element(
400                    child_el,
401                    depth + 2,
402                    stateful,
403                    imports,
404                    import_props,
405                    prop_fields,
406                ));
407            }
408            Node::Text(t) => out.push_str(&format!("{indent}        \"{t}\"")),
409            Node::Interpolation(expr) => {
410                let e = interpolation_expr(expr);
411                out.push_str(&format!("{indent}        {e}.to_string()"));
412            }
413        }
414        out.push('\n');
415        out.push_str(&format!("{indent}    )\n"));
416    }
417    out.trim_end().to_string()
418}
419
420/// Resolve an interpolation expression to the correct Rust variable reference.
421/// - `state.field` → `self.field` (for stateful components)
422/// - `props.field` → `props.field` (for stateless components with props)
423/// - `bare_expr` → `self.bare_expr` (fallback for stateful components)
424fn interpolation_expr(expr: &str) -> String {
425    let trimmed = expr.trim();
426    if let Some(field) = trimmed.strip_prefix("state.") {
427        format!("self.{field}")
428    } else if trimmed.starts_with("props.") {
429        trimmed.to_string()
430    } else {
431        format!("self.{trimmed}")
432    }
433}
434
435pub(crate) fn to_snake_case(name: &str) -> String {
436    let mut result = String::new();
437    for (i, ch) in name.chars().enumerate() {
438        if ch.is_uppercase() {
439            if i > 0 {
440                result.push('_');
441            }
442            for c in ch.to_lowercase() {
443                result.push(c);
444            }
445        } else {
446            result.push(ch);
447        }
448    }
449    result
450}
451
452impl AttrValue {
453    fn as_str(&self) -> &str {
454        match self {
455            AttrValue::String(s) => s,
456            AttrValue::Interpolation(s) => s,
457        }
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::parser::parse;
465
466    fn generate_from(source: &str) -> String {
467        let doc = parse(source).unwrap();
468        generate(&doc)
469    }
470
471    // --- Happy path ---
472
473    #[test]
474    fn test_generates_stateless_render_function() {
475        let out = generate_from("<MyComp></MyComp>");
476        assert!(out.contains("pub fn render_my_comp() -> impl IntoElement {"));
477        assert!(out.contains("div()"));
478    }
479
480    #[test]
481    fn test_generates_stateful_render_impl() {
482        let src = "---\n@state CounterState\n---\n<App></App>";
483        let out = generate_from(src);
484        assert!(out.contains("impl Render for CounterState {"));
485        assert!(
486            out.contains("fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {")
487        );
488    }
489
490    #[test]
491    fn test_frontmatter_is_preserved_in_output() {
492        let src = "---\nuse crate::prelude::*;\n---\n<App></App>";
493        let out = generate_from(src);
494        assert!(out.contains("use crate::prelude::*;"));
495    }
496
497    #[test]
498    fn test_stack_with_direction_vertical_generates_flex_col() {
499        let out = generate_from("<Stack direction=\"vertical\"></Stack>");
500        assert!(out.contains(".flex()"));
501        assert!(out.contains(".flex_col()"));
502    }
503
504    #[test]
505    fn test_stack_with_direction_horizontal_generates_flex_only() {
506        let out = generate_from("<Stack direction=\"horizontal\"></Stack>");
507        assert!(out.contains(".flex()"));
508        assert!(!out.contains(".flex_col()"));
509    }
510
511    #[test]
512    fn test_stack_with_gap_generates_px_value() {
513        let out = generate_from("<Stack gap=\"12\"></Stack>");
514        assert!(out.contains(".gap(px(12.))"));
515    }
516
517    #[test]
518    fn test_text_with_size_xl() {
519        let out = generate_from("<Text size=\"xl\">hello</Text>");
520        assert!(out.contains(".text_xl()"));
521        assert!(out.contains(".child(\"hello\")"));
522    }
523
524    #[test]
525    fn test_text_with_size_unknown_falls_back_to_base() {
526        let out = generate_from("<Text size=\"huge\">text</Text>");
527        assert!(out.contains(".text_base()"));
528    }
529
530    #[test]
531    fn test_button_with_text_child() {
532        let out = generate_from("<Button onclick=\"handle_click\">Click</Button>");
533        assert!(out.contains(".id(\"Click\")"));
534        assert!(out.contains(".cursor_pointer()"));
535        assert!(out.contains(".child(\"Click\")"));
536    }
537
538    #[test]
539    fn test_stateful_button_with_onclick_generates_entity_pattern() {
540        let src = "---\n@state AppState\n---\n<Button onclick=\"inc\">+1</Button>";
541        let out = generate_from(src);
542        assert!(out.contains("let handle = handle.clone();"));
543        assert!(out.contains("handle.update(cx, |this, cx| {"));
544        assert!(out.contains("__fncc_cmd_inc(this, cx);"));
545    }
546
547    #[test]
548    fn test_stateless_button_with_onclick_generates_direct_call() {
549        let out = generate_from("<Button onclick=\"log_click\">Go</Button>");
550        assert!(out.contains(".on_click(__fncc_cmd_log_click)"));
551    }
552
553    // --- Edge cases ---
554
555    #[test]
556    fn test_empty_element_children() {
557        let out = generate_from("<Div></Div>");
558        assert!(out.contains("div()"));
559    }
560
561    #[test]
562    fn test_unknown_element_falls_back_to_div_with_attrs() {
563        let out = generate_from("<CustomEl foo=\"bar\">content</CustomEl>");
564        assert!(out.contains(".attr(\"foo\", \"bar\")"));
565        assert!(out.contains(".child("));
566        assert!(out.contains("\"content\""));
567    }
568
569    #[test]
570    fn test_interpolation_in_text_content_generates_format() {
571        let out = generate_from("---\n@state S\n---\n<Text>{state.msg}</Text>");
572        assert!(out.contains("self.msg.to_string()"));
573        assert!(!out.contains("self.state.msg"));
574    }
575
576    #[test]
577    fn test_interpolation_strips_state_prefix() {
578        let out = generate_from("<Text>{state.count}</Text>");
579        // stateless, so state. prefix is stripped but no self. prefix
580        assert!(out.contains("self.count.to_string()"));
581    }
582
583    #[test]
584    fn test_multiple_commands_collected_in_validation_fn() {
585        let src = "<Stack><Button onclick=\"a\">A</Button><Button onclick=\"b\">B</Button></Stack>";
586        let out = generate_from(src);
587        assert!(out.contains("fn _fncc_validate_"));
588        assert!(out.contains("__fncc_cmd_a"));
589        assert!(out.contains("__fncc_cmd_b"));
590    }
591
592    // --- Contract tests ---
593
594    #[test]
595    fn test_generated_code_contains_no_markdown_or_template_leftovers() {
596        let out = generate_from("<Text>hello</Text>");
597        assert!(!out.contains("{{"));
598        assert!(!out.contains("{state."));
599        assert!(!out.contains("__fncc_cmd_") || out.contains("__fncc_cmd_"));
600    }
601
602    #[test]
603    fn test_generated_function_name_follows_snake_case() {
604        let out = generate_from("<HTMLParser></HTMLParser>");
605        assert!(out.contains("render_h_t_m_l_parser") || out.contains("render_html_parser"));
606    }
607
608    // --- Regression tests ---
609
610    #[test]
611    fn test_regression_gap_with_decimal_does_not_produce_invalid_syntax() {
612        let out = generate_from("<Stack gap=\"12.5\"></Stack>");
613        assert!(out.contains(".gap(px(12.5))") || out.contains(".gap(px(12.5.))"));
614    }
615
616    #[test]
617    fn test_regression_duplicate_button_ids_at_same_depth() {
618        let src = "<Stack><Button>OK</Button><Button>OK</Button></Stack>";
619        let out = generate_from(src);
620        let id_count = out.matches(".id(\"OK\")").count();
621        assert!(
622            id_count <= 2,
623            "expected at most 2 .id(\"OK\") occurrences, got {id_count}"
624        );
625    }
626
627    #[test]
628    fn test_regression_multiple_calls_have_unique_validation_fn_names() {
629        let doc = parse("<Button onclick=\"x\">X</Button>").unwrap();
630        let a = generate_with_id(&doc, 0);
631        let b = generate_with_id(&doc, 1);
632        assert_ne!(a, b);
633        assert!(a.contains("__fncc_cmd_x"));
634        assert!(b.contains("__fncc_cmd_x"));
635    }
636
637    #[test]
638    fn test_regression_empty_gap_does_not_panic() {
639        let out = generate_from("<Stack gap=\"\"></Stack>");
640        // empty string fails to parse as f64, should skip gap
641        assert!(!out.contains(".gap(") || out.contains(".gap(px(0.))"));
642    }
643
644    #[test]
645    fn test_regression_interpolation_without_state_prefix() {
646        let out = generate_from("---\n@state S\n---\n<Text>{custom_expr}</Text>");
647        // custom_expr doesn't start with "state.", so interpolation_expr returns "self.custom_expr"
648        assert!(out.contains("custom_expr") || out.contains("self.custom_expr"));
649    }
650
651    #[test]
652    fn test_regression_many_calls_do_not_panic() {
653        for _ in 0..100 {
654            generate_from("<Button onclick=\"f\">F</Button>");
655        }
656    }
657
658    // --- Component imports ---
659
660    #[test]
661    fn test_imported_element_generates_render_call() {
662        let doc = parse("<Stack><Header /></Stack>").unwrap();
663        let imports: &[(&str, &str)] = &[("Header", "render_header")];
664        let out = generate_with_imports(&doc, 0, imports, None, None, None, &[], None);
665        assert!(out.contains("render_header()"));
666    }
667
668    #[test]
669    fn test_imported_element_in_stateful_component() {
670        let src = "---\n@state AppState\n---\n<Stack><Footer /></Stack>";
671        let doc = parse(src).unwrap();
672        let imports: &[(&str, &str)] = &[("Footer", "render_footer")];
673        let out = generate_with_imports(&doc, 0, imports, None, None, None, &[], None);
674        assert!(out.contains("render_footer()"));
675    }
676
677    #[test]
678    fn test_gpui_import_falls_back_to_div() {
679        let doc = parse("<Stack><TextInput /></Stack>").unwrap();
680        let imports: &[(&str, &str)] = &[("TextInput", "")];
681        let out = generate_with_imports(&doc, 0, imports, None, None, None, &[], None);
682        // GPUI imports have empty render fn — fall through to div
683        assert!(out.contains("div()"));
684    }
685
686    #[test]
687    fn test_builtin_takes_precedence_over_import() {
688        let doc = parse("<Text>hello</Text>").unwrap();
689        let imports: &[(&str, &str)] = &[("Text", "render_text")];
690        let out = generate_with_imports(&doc, 0, imports, None, None, None, &[], None);
691        // Built-in "Text" handling takes precedence, not render_text()
692        assert!(out.contains(".child(\"hello\")"));
693    }
694
695    #[test]
696    fn test_imported_element_with_custom_component_name() {
697        let doc = parse("<Stack><MyHeader /></Stack>").unwrap();
698        let imports: &[(&str, &str)] = &[("MyHeader", "render_header")];
699        let out = generate_with_imports(&doc, 0, imports, None, None, None, &[], None);
700        assert!(out.contains("render_header()"));
701    }
702
703    #[test]
704    fn test_render_fn_name_uses_component_name_arg() {
705        let doc = parse("<Text>hello</Text>").unwrap();
706        let out = generate_with_imports(&doc, 0, &[], Some("CustomWidget"), None, None, &[], None);
707        assert!(out.contains("pub fn render_custom_widget()"));
708        // Should NOT use root element name
709        assert!(!out.contains("pub fn render_text()"));
710    }
711
712    // --- Props tests ---
713
714    #[test]
715    fn test_props_stateless_component_with_props_signature() {
716        let doc = parse("<Text>{props.title}</Text>").unwrap();
717        let out = generate_with_imports(&doc, 0, &[], Some("Header"), None, Some("HeaderProps"), &[], None);
718        assert!(out.contains("pub fn render_header(props: &HeaderProps) -> impl IntoElement {"));
719        assert!(out.contains("props.title"));
720    }
721
722    #[test]
723    fn test_props_caller_generates_struct_construction() {
724        let doc = parse("<Header title=\"Welcome\" />").unwrap();
725        let imports: &[(&str, &str)] = &[("Header", "render_header")];
726        let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps"))];
727        let out = generate_with_imports(&doc, 0, imports, None, None, None, import_props, None);
728        assert!(out.contains("render_header(&HeaderProps {"));
729        assert!(out.contains("title: \"Welcome\".into(),"));
730        assert!(out.contains("})"));
731    }
732
733    #[test]
734    fn test_props_caller_multiple_attributes() {
735        let doc = parse("<Header title=\"Hi\" subtitle=\"World\" />").unwrap();
736        let imports: &[(&str, &str)] = &[("Header", "render_header")];
737        let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps"))];
738        let out = generate_with_imports(&doc, 0, imports, None, None, None, import_props, None);
739        assert!(out.contains("title: \"Hi\".into(),"));
740        assert!(out.contains("subtitle: \"World\".into(),"));
741    }
742
743    #[test]
744    fn test_props_caller_option_field() {
745        let doc = parse("<Header title=\"Hi\" />").unwrap();
746        let imports: &[(&str, &str)] = &[("Header", "render_header")];
747        let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps"))];
748        let out = generate_with_imports(&doc, 0, imports, None, None, None, import_props, None);
749        // Option<T> fields are transparent at codegen — .into() handles conversion
750        assert!(out.contains("title: \"Hi\".into(),"));
751    }
752
753    #[test]
754    fn test_props_nested_element_with_props() {
755        let doc = parse("<Stack><Header title=\"Nested\" /><Text>ok</Text></Stack>").unwrap();
756        let imports: &[(&str, &str)] = &[("Header", "render_header")];
757        let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps"))];
758        let out = generate_with_imports(&doc, 0, imports, None, None, None, import_props, None);
759        assert!(out.contains("render_header(&HeaderProps {"));
760        assert!(out.contains("title: \"Nested\".into(),"));
761    }
762
763    #[test]
764    fn test_props_mixed_components_with_and_without_props() {
765        let doc = parse("<Stack><Header title=\"A\" /><Footer /></Stack>").unwrap();
766        let imports: &[(&str, &str)] = &[("Header", "render_header"), ("Footer", "render_footer")];
767        let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps")), ("Footer", None)];
768        let out = generate_with_imports(&doc, 0, imports, None, None, None, import_props, None);
769        assert!(out.contains("render_header(&HeaderProps {"));
770        assert!(out.contains("title: \"A\".into(),"));
771        assert!(out.contains("render_footer()"));
772    }
773
774    #[test]
775    fn test_props_element_without_props_still_calls_directly() {
776        let doc = parse("<Footer />").unwrap();
777        let imports: &[(&str, &str)] = &[("Footer", "render_footer")];
778        let import_props: &[(&str, Option<&str>)] = &[("Footer", None)];
779        let out = generate_with_imports(&doc, 0, imports, None, None, None, import_props, None);
780        assert!(out.contains("render_footer()"));
781        assert!(!out.contains("&"));
782    }
783
784    #[test]
785    fn test_props_self_closing_with_props() {
786        let doc = parse("<Header title=\"SelfClose\" subtitle=\"X\" />").unwrap();
787        let imports: &[(&str, &str)] = &[("Header", "render_header")];
788        let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps"))];
789        let out = generate_with_imports(&doc, 0, imports, None, None, None, import_props, None);
790        assert!(out.contains("title: \"SelfClose\".into(),"));
791        assert!(out.contains("subtitle: \"X\".into(),"));
792    }
793}