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
11pub type ResolvedImport<'a> = (&'a str, &'a str);
15
16pub 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 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 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 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 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 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
272fn 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 #[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 #[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 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 #[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 #[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 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 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 #[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 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 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 assert!(!out.contains("pub fn render_text()"));
572 }
573}