Skip to main content

fncc_core/
codegen.rs

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