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