Skip to main content

fncc_core/
codegen.rs

1use crate::parser::{AttrValue, Document, Element, Node};
2
3pub fn generate(doc: &Document) -> String {
4    generate_with_id(doc, 0)
5}
6
7pub fn generate_with_id(doc: &Document, file_id: usize) -> String {
8    generate_with_imports(doc, file_id, &[], None)
9}
10
11/// Resolved import entry: (tag_name, render_fn_name)
12/// - For .fui component imports: ("Header", "render_header")
13/// - For gpui imports: ("TextInput", "") — registered but no special codegen
14pub type ResolvedImport<'a> = (&'a str, &'a str);
15
16/// Generate code for a document with a specific component name.
17/// `component_name` is used for the render function name (derived from the file stem).
18/// If `None`, falls back to the root element name (backward compat).
19pub fn generate_with_imports(
20    doc: &Document,
21    file_id: usize,
22    imports: &[ResolvedImport],
23    component_name: Option<&str>,
24) -> String {
25    let mut out = String::new();
26    let has_state = doc.state_type.is_some();
27
28    if let Some(ref fm) = doc.frontmatter {
29        out.push_str(fm);
30        out.push('\n');
31    }
32
33    // collect referenced command names for validation
34    let commands = collect_commands(&doc.root);
35    if !commands.is_empty() {
36        out.push_str("#[allow(unused)]\n");
37        out.push_str(&format!("fn _fncc_validate_{file_id}() {{\n"));
38        for cmd in &commands {
39            let trampoline = format!("__fncc_cmd_{cmd}");
40            out.push_str(&format!("    let _ = {trampoline};\n"));
41        }
42        out.push_str("}\n\n");
43    }
44
45    if has_state {
46        generate_stateful(doc, &mut out, imports);
47    } else {
48        generate_stateless(doc, &mut out, imports, component_name);
49    }
50
51    out
52}
53
54fn generate_stateless(doc: &Document, out: &mut String, imports: &[ResolvedImport], component_name: Option<&str>) {
55    let name = component_name.unwrap_or(&doc.root.name);
56    let fn_name = format!("render_{}", to_snake_case(name));
57    out.push_str(&format!("pub fn {fn_name}() -> impl IntoElement {{\n"));
58    out.push_str(&generate_element(&doc.root, 1, false, imports));
59    out.push('\n');
60    out.push_str("}\n");
61}
62
63fn generate_stateful(doc: &Document, out: &mut String, imports: &[ResolvedImport]) {
64    let state_type = doc.state_type.as_deref().unwrap_or("Self");
65
66    out.push_str(&format!("impl Render for {state_type} {{\n"));
67    out.push_str("    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {\n");
68    out.push_str("        let handle = cx.entity().downgrade();\n");
69    out.push_str(&generate_element(&doc.root, 2, true, imports));
70    out.push_str("\n    }\n");
71    out.push_str("}\n");
72}
73
74fn generate_element(el: &Element, depth: usize, stateful: bool, imports: &[ResolvedImport]) -> String {
75    let indent = "    ".repeat(depth);
76
77    // Built-in elements
78    match el.name.as_str() {
79        "Stack" => return gen_stack(el, &indent, depth, stateful, imports),
80        "Text" => return gen_text(el, &indent, depth, stateful, imports),
81        "Button" => return gen_button(el, &indent, depth, stateful, imports),
82        _ => {}
83    }
84
85    // .fui component imports (have a non-empty render function name)
86    if let Some(render_fn) = imports
87        .iter()
88        .find(|(name, fn_name)| name == &el.name && !fn_name.is_empty())
89        .map(|(_, fn_name)| *fn_name)
90    {
91        return format!("{indent}{render_fn}()");
92    }
93
94    // Fallback for gpui imports and unknown elements
95    gen_fallback(el, &indent, depth, stateful, imports)
96}
97
98fn gen_stack(el: &Element, indent: &str, depth: usize, stateful: bool, imports: &[ResolvedImport]) -> String {
99    let mut out = format!("{indent}div()\n");
100
101    let mut is_vertical = false;
102    for (key, val) in &el.attrs {
103        match key.as_str() {
104            "direction" if val.as_str() == "vertical" => is_vertical = true,
105            "gap" => {
106                let v = val.as_str();
107                if let Ok(n) = v.parse::<f64>() {
108                    out.push_str(&format!("{indent}    .gap(px({n}.))\n"));
109                }
110            }
111            _ => {}
112        }
113    }
114
115    if is_vertical {
116        out.push_str(&format!("{indent}    .flex()\n{indent}    .flex_col()\n"));
117    } else {
118        out.push_str(&format!("{indent}    .flex()\n"));
119    }
120
121    for child in &el.children {
122        out.push_str(&format!("{indent}    .child(\n"));
123        match child {
124            Node::Element(child_el) => {
125                out.push_str(&generate_element(child_el, depth + 2, stateful, imports));
126            }
127            Node::Text(t) => {
128                out.push_str(&format!("{indent}        \"{t}\""));
129            }
130            Node::Interpolation(expr) => {
131                let e = strip_state_prefix(expr);
132                out.push_str(&format!("{indent}        format!(\"{{}}\", self.{e})"));
133            }
134        }
135        out.push('\n');
136        out.push_str(&format!("{indent}    )\n"));
137    }
138
139    out.trim_end().to_string()
140}
141
142fn gen_text(el: &Element, indent: &str, depth: usize, stateful: bool, imports: &[ResolvedImport]) -> String {
143    let mut out = format!("{indent}div()\n");
144
145    for (key, val) in &el.attrs {
146        if key.as_str() == "size" {
147            let v = val.as_str();
148            let ts = match v {
149                "xs" => "text_xs()",
150                "sm" => "text_sm()",
151                "base" => "text_base()",
152                "lg" => "text_lg()",
153                "xl" => "text_xl()",
154                "2xl" | "xxl" => "text_2xl()",
155                "3xl" => "text_3xl()",
156                _ => "text_base()",
157            };
158            out.push_str(&format!("{indent}    .{ts}\n"));
159        }
160    }
161
162    match &el.children[..] {
163        [Node::Text(t)] => {
164            out.push_str(&format!("{indent}    .child(\"{t}\")"));
165        }
166        [Node::Interpolation(expr)] => {
167            let e = strip_state_prefix(expr);
168            out.push_str(&format!("{indent}    .child(format!(\"{{}}\", self.{e}))"));
169        }
170        children => {
171            for child in children {
172                match child {
173                    Node::Text(t) => out.push_str(&format!("{indent}    .child(\"{t}\")\n")),
174                    Node::Interpolation(expr) => {
175                        let e = strip_state_prefix(expr);
176                        out.push_str(&format!("{indent}    .child(format!(\"{{}}\", self.{e}))\n"));
177                    }
178                    Node::Element(child_el) => {
179                        out.push_str(&format!("{indent}    .child(\n"));
180                        out.push_str(&generate_element(child_el, depth + 1, stateful, imports));
181                        out.push_str(&format!("\n{indent}    )\n"));
182                    }
183                }
184            }
185        }
186    }
187
188    out.trim_end().to_string()
189}
190
191fn gen_button(el: &Element, indent: &str, depth: usize, stateful: bool, imports: &[ResolvedImport]) -> String {
192    let mut out = format!("{indent}div()\n");
193
194    let btn_id = match &el.children[..] {
195        [Node::Text(t)] => t.clone(),
196        _ => format!("button_{depth}"),
197    };
198    out.push_str(&format!("{indent}    .id(\"{btn_id}\")\n"));
199    out.push_str(&format!("{indent}    .cursor_pointer()\n"));
200
201    for (key, val) in &el.attrs {
202        if key.as_str() == "onclick" {
203            let handler = val.as_str();
204            let trampoline = format!("__fncc_cmd_{handler}");
205            if stateful {
206                // Level 3: use entity handle pattern
207                out.push_str(&format!("{indent}    .on_click({{\n"));
208                out.push_str(&format!("{indent}        let handle = handle.clone();\n"));
209                out.push_str(&format!("{indent}        move |_, _, cx| {{\n"));
210                out.push_str(&format!("{indent}            handle.update(cx, |this, cx| {{\n"));
211                out.push_str(&format!("{indent}                {trampoline}(this, cx);\n"));
212                out.push_str(&format!("{indent}            }}).ok();\n"));
213                out.push_str(&format!("{indent}        }}\n"));
214                out.push_str(&format!("{indent}    }})\n"));
215            } else {
216                out.push_str(&format!("{indent}    .on_click({trampoline})\n"));
217            }
218        }
219    }
220
221    match &el.children[..] {
222        [Node::Text(t)] => out.push_str(&format!("{indent}    .child(\"{t}\")")),
223        [Node::Interpolation(expr)] => {
224            let e = strip_state_prefix(expr);
225            out.push_str(&format!("{indent}    .child(format!(\"{{}}\", self.{e}))"));
226        }
227        children => {
228            for child in children {
229                match child {
230                    Node::Text(t) => out.push_str(&format!("{indent}    .child(\"{t}\")\n")),
231                    Node::Element(child_el) => {
232                        out.push_str(&format!("{indent}    .child(\n"));
233                        out.push_str(&generate_element(child_el, depth + 1, stateful, imports));
234                        out.push_str(&format!("\n{indent}    )\n"));
235                    }
236                    Node::Interpolation(expr) => {
237                        let e = strip_state_prefix(expr);
238                        out.push_str(&format!("{indent}    .child(format!(\"{{}}\", self.{e}))\n"));
239                    }
240                }
241            }
242        }
243    }
244
245    out.trim_end().to_string()
246}
247
248fn gen_fallback(el: &Element, indent: &str, depth: usize, stateful: bool, imports: &[ResolvedImport]) -> String {
249    let mut out = format!("{indent}div()\n");
250    for (key, val) in &el.attrs {
251        let v = val.as_str();
252        out.push_str(&format!("{indent}    .attr(\"{key}\", \"{v}\")\n"));
253    }
254    for child in &el.children {
255        out.push_str(&format!("{indent}    .child(\n"));
256        match child {
257            Node::Element(child_el) => {
258                out.push_str(&generate_element(child_el, depth + 2, stateful, imports));
259            }
260            Node::Text(t) => out.push_str(&format!("{indent}        \"{t}\"")),
261            Node::Interpolation(expr) => {
262                let e = strip_state_prefix(expr);
263                out.push_str(&format!("{indent}        format!(\"{{}}\", self.{e})"));
264            }
265        }
266        out.push('\n');
267        out.push_str(&format!("{indent}    )\n"));
268    }
269    out.trim_end().to_string()
270}
271
272/// Removes `state.` prefix from interpolation expressions
273/// e.g. "state.count" → "count"
274fn strip_state_prefix(expr: &str) -> &str {
275    expr.trim().strip_prefix("state.").unwrap_or(expr.trim())
276}
277
278pub(crate) fn to_snake_case(name: &str) -> String {
279    let mut result = String::new();
280    for (i, ch) in name.chars().enumerate() {
281        if ch.is_uppercase() {
282            if i > 0 {
283                result.push('_');
284            }
285            for c in ch.to_lowercase() {
286                result.push(c);
287            }
288        } else {
289            result.push(ch);
290        }
291    }
292    result
293}
294
295fn collect_commands(el: &Element) -> Vec<String> {
296    let mut cmds = Vec::new();
297    for (key, val) in &el.attrs {
298        if key == "onclick"
299            && let AttrValue::String(name) = val
300            && !cmds.contains(name)
301        {
302            cmds.push(name.clone());
303        }
304    }
305    for child in &el.children {
306        if let Node::Element(child_el) = child {
307            cmds.extend(collect_commands(child_el));
308        }
309    }
310    cmds
311}
312
313impl AttrValue {
314    fn as_str(&self) -> &str {
315        match self {
316            AttrValue::String(s) => s,
317            AttrValue::Interpolation(s) => s,
318        }
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::parser::parse;
326
327    fn generate_from(source: &str) -> String {
328        let doc = parse(source).unwrap();
329        generate(&doc)
330    }
331
332    // --- Happy path ---
333
334    #[test]
335    fn test_generates_stateless_render_function() {
336        let out = generate_from("<MyComp></MyComp>");
337        assert!(out.contains("pub fn render_my_comp() -> impl IntoElement {"));
338        assert!(out.contains("div()"));
339    }
340
341    #[test]
342    fn test_generates_stateful_render_impl() {
343        let src = "---\n@state CounterState\n---\n<App></App>";
344        let out = generate_from(src);
345        assert!(out.contains("impl Render for CounterState {"));
346        assert!(
347            out.contains("fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {")
348        );
349    }
350
351    #[test]
352    fn test_frontmatter_is_preserved_in_output() {
353        let src = "---\nuse crate::prelude::*;\n---\n<App></App>";
354        let out = generate_from(src);
355        assert!(out.contains("use crate::prelude::*;"));
356    }
357
358    #[test]
359    fn test_stack_with_direction_vertical_generates_flex_col() {
360        let out = generate_from("<Stack direction=\"vertical\"></Stack>");
361        assert!(out.contains(".flex()"));
362        assert!(out.contains(".flex_col()"));
363    }
364
365    #[test]
366    fn test_stack_with_direction_horizontal_generates_flex_only() {
367        let out = generate_from("<Stack direction=\"horizontal\"></Stack>");
368        assert!(out.contains(".flex()"));
369        assert!(!out.contains(".flex_col()"));
370    }
371
372    #[test]
373    fn test_stack_with_gap_generates_px_value() {
374        let out = generate_from("<Stack gap=\"12\"></Stack>");
375        assert!(out.contains(".gap(px(12.))"));
376    }
377
378    #[test]
379    fn test_text_with_size_xl() {
380        let out = generate_from("<Text size=\"xl\">hello</Text>");
381        assert!(out.contains(".text_xl()"));
382        assert!(out.contains(".child(\"hello\")"));
383    }
384
385    #[test]
386    fn test_text_with_size_unknown_falls_back_to_base() {
387        let out = generate_from("<Text size=\"huge\">text</Text>");
388        assert!(out.contains(".text_base()"));
389    }
390
391    #[test]
392    fn test_button_with_text_child() {
393        let out = generate_from("<Button onclick=\"handle_click\">Click</Button>");
394        assert!(out.contains(".id(\"Click\")"));
395        assert!(out.contains(".cursor_pointer()"));
396        assert!(out.contains(".child(\"Click\")"));
397    }
398
399    #[test]
400    fn test_stateful_button_with_onclick_generates_entity_pattern() {
401        let src = "---\n@state AppState\n---\n<Button onclick=\"inc\">+1</Button>";
402        let out = generate_from(src);
403        assert!(out.contains("let handle = handle.clone();"));
404        assert!(out.contains("handle.update(cx, |this, cx| {"));
405        assert!(out.contains("__fncc_cmd_inc(this, cx);"));
406    }
407
408    #[test]
409    fn test_stateless_button_with_onclick_generates_direct_call() {
410        let out = generate_from("<Button onclick=\"log_click\">Go</Button>");
411        assert!(out.contains(".on_click(__fncc_cmd_log_click)"));
412    }
413
414    // --- Edge cases ---
415
416    #[test]
417    fn test_empty_element_children() {
418        let out = generate_from("<Div></Div>");
419        assert!(out.contains("div()"));
420    }
421
422    #[test]
423    fn test_unknown_element_falls_back_to_div_with_attrs() {
424        let out = generate_from("<CustomEl foo=\"bar\">content</CustomEl>");
425        assert!(out.contains(".attr(\"foo\", \"bar\")"));
426        assert!(out.contains(".child("));
427        assert!(out.contains("\"content\""));
428    }
429
430    #[test]
431    fn test_interpolation_in_text_content_generates_format() {
432        let out = generate_from("---\n@state S\n---\n<Text>{state.msg}</Text>");
433        assert!(out.contains("format!(\"{}\", self.msg)"));
434        assert!(!out.contains("self.state.msg"));
435    }
436
437    #[test]
438    fn test_interpolation_strips_state_prefix() {
439        let out = generate_from("<Text>{state.count}</Text>");
440        // stateless, so state. prefix is stripped but no self. prefix
441        assert!(out.contains("format!(\"{}\", self.count)"));
442    }
443
444    #[test]
445    fn test_multiple_commands_collected_in_validation_fn() {
446        let src = "<Stack><Button onclick=\"a\">A</Button><Button onclick=\"b\">B</Button></Stack>";
447        let out = generate_from(src);
448        assert!(out.contains("fn _fncc_validate_"));
449        assert!(out.contains("__fncc_cmd_a"));
450        assert!(out.contains("__fncc_cmd_b"));
451    }
452
453    // --- Contract tests ---
454
455    #[test]
456    fn test_generated_code_contains_no_markdown_or_template_leftovers() {
457        let out = generate_from("<Text>hello</Text>");
458        assert!(!out.contains("{{"));
459        assert!(!out.contains("{state."));
460        assert!(!out.contains("__fncc_cmd_") || out.contains("__fncc_cmd_"));
461    }
462
463    #[test]
464    fn test_generated_function_name_follows_snake_case() {
465        let out = generate_from("<HTMLParser></HTMLParser>");
466        assert!(out.contains("render_h_t_m_l_parser") || out.contains("render_html_parser"));
467    }
468
469    // --- Regression tests ---
470
471    #[test]
472    fn test_regression_gap_with_decimal_does_not_produce_invalid_syntax() {
473        let out = generate_from("<Stack gap=\"12.5\"></Stack>");
474        assert!(out.contains(".gap(px(12.5))") || out.contains(".gap(px(12.5.))"));
475    }
476
477    #[test]
478    fn test_regression_duplicate_button_ids_at_same_depth() {
479        let src = "<Stack><Button>OK</Button><Button>OK</Button></Stack>";
480        let out = generate_from(src);
481        let id_count = out.matches(".id(\"OK\")").count();
482        assert!(
483            id_count <= 2,
484            "expected at most 2 .id(\"OK\") occurrences, got {id_count}"
485        );
486    }
487
488    #[test]
489    fn test_regression_multiple_calls_have_unique_validation_fn_names() {
490        let doc = parse("<Button onclick=\"x\">X</Button>").unwrap();
491        let a = generate_with_id(&doc, 0);
492        let b = generate_with_id(&doc, 1);
493        assert_ne!(a, b);
494        assert!(a.contains("__fncc_cmd_x"));
495        assert!(b.contains("__fncc_cmd_x"));
496    }
497
498    #[test]
499    fn test_regression_empty_gap_does_not_panic() {
500        let out = generate_from("<Stack gap=\"\"></Stack>");
501        // empty string fails to parse as f64, should skip gap
502        assert!(!out.contains(".gap(") || out.contains(".gap(px(0.))"));
503    }
504
505    #[test]
506    fn test_regression_interpolation_without_state_prefix() {
507        let out = generate_from("---\n@state S\n---\n<Text>{custom_expr}</Text>");
508        // custom_expr doesn't start with "state.", so strip_state_prefix leaves it as-is
509        // But stateful codegen adds "self." prefix? Let's check...
510        assert!(out.contains("custom_expr") || out.contains("self.custom_expr"));
511    }
512
513    #[test]
514    fn test_regression_many_calls_do_not_panic() {
515        for _ in 0..100 {
516            generate_from("<Button onclick=\"f\">F</Button>");
517        }
518    }
519
520    // --- Component imports ---
521
522    #[test]
523    fn test_imported_element_generates_render_call() {
524        let doc = parse("<Stack><Header /></Stack>").unwrap();
525        let imports: &[(&str, &str)] = &[("Header", "render_header")];
526        let out = generate_with_imports(&doc, 0, imports, None);
527        assert!(out.contains("render_header()"));
528    }
529
530    #[test]
531    fn test_imported_element_in_stateful_component() {
532        let src = "---\n@state AppState\n---\n<Stack><Footer /></Stack>";
533        let doc = parse(src).unwrap();
534        let imports: &[(&str, &str)] = &[("Footer", "render_footer")];
535        let out = generate_with_imports(&doc, 0, imports, None);
536        assert!(out.contains("render_footer()"));
537    }
538
539    #[test]
540    fn test_gpui_import_falls_back_to_div() {
541        let doc = parse("<Stack><TextInput /></Stack>").unwrap();
542        let imports: &[(&str, &str)] = &[("TextInput", "")];
543        let out = generate_with_imports(&doc, 0, imports, None);
544        // GPUI imports have empty render fn — fall through to div
545        assert!(out.contains("div()"));
546    }
547
548    #[test]
549    fn test_builtin_takes_precedence_over_import() {
550        let doc = parse("<Text>hello</Text>").unwrap();
551        let imports: &[(&str, &str)] = &[("Text", "render_text")];
552        let out = generate_with_imports(&doc, 0, imports, None);
553        // Built-in "Text" handling takes precedence, not render_text()
554        assert!(out.contains(".child(\"hello\")"));
555    }
556
557    #[test]
558    fn test_imported_element_with_custom_component_name() {
559        let doc = parse("<Stack><MyHeader /></Stack>").unwrap();
560        let imports: &[(&str, &str)] = &[("MyHeader", "render_header")];
561        let out = generate_with_imports(&doc, 0, imports, None);
562        assert!(out.contains("render_header()"));
563    }
564
565    #[test]
566    fn test_render_fn_name_uses_component_name_arg() {
567        let doc = parse("<Text>hello</Text>").unwrap();
568        let out = generate_with_imports(&doc, 0, &[], Some("CustomWidget"));
569        assert!(out.contains("pub fn render_custom_widget()"));
570        // Should NOT use root element name
571        assert!(!out.contains("pub fn render_text()"));
572    }
573}