1#![allow(clippy::too_many_arguments)]
2
3use std::collections::HashMap;
4
5use crate::parser::{self, AttrValue, Document, Element, Node};
6use crate::semantic::PropField;
7
8pub fn generate(doc: &Document) -> String {
9 generate_with_id(doc, 0)
10}
11
12pub fn generate_with_id(doc: &Document, file_id: usize) -> String {
13 generate_with_imports(doc, file_id, &[], None, None, None, &[], None, &[], None, None)
14}
15
16pub type ResolvedImport<'a> = (&'a str, &'a str);
20
21pub fn generate_with_imports(
26 doc: &Document,
27 file_id: usize,
28 imports: &[ResolvedImport],
29 component_name: Option<&str>,
30 resolved_state_type: Option<&str>,
31 props_type: Option<&str>,
32 import_props: &[(&str, Option<&str>)],
33 prop_fields: Option<&HashMap<String, Vec<PropField>>>,
34 import_has_slots: &[(&str, bool)],
35 style_cascade: Option<&fncc_styles::Stylesheet>,
36 style_theme: Option<&str>,
37) -> String {
38 generate_with_imports_and_route_params(
39 doc,
40 file_id,
41 imports,
42 component_name,
43 resolved_state_type,
44 props_type,
45 import_props,
46 prop_fields,
47 import_has_slots,
48 style_cascade,
49 style_theme,
50 &[],
51 )
52}
53
54fn count_router_outlets(el: &Element) -> usize {
55 let mut count = if el.name == "RouterOutlet" { 1 } else { 0 };
56 for child in &el.children {
57 if let Node::Element(child_el) = child {
58 count += count_router_outlets(child_el);
59 }
60 }
61 count
62}
63
64fn format_interpolation(e: &str) -> String {
65 if e.contains(".render(") || e.contains("render_") || e.contains("children") {
66 e.to_string()
67 } else {
68 format!("{}.to_string()", e)
69 }
70}
71
72pub fn generate_with_imports_and_route_params(
73 doc: &Document,
74 file_id: usize,
75 imports: &[ResolvedImport],
76 component_name: Option<&str>,
77 resolved_state_type: Option<&str>,
78 props_type: Option<&str>,
79 import_props: &[(&str, Option<&str>)],
80 prop_fields: Option<&HashMap<String, Vec<PropField>>>,
81 import_has_slots: &[(&str, bool)],
82 style_cascade: Option<&fncc_styles::Stylesheet>,
83 style_theme: Option<&str>,
84 route_params: &[String],
85) -> String {
86 let router_outlet_count = count_router_outlets(&doc.root);
87 if router_outlet_count > 1 {
88 panic!(
89 "duplicate RouterOutlet usage is not allowed: found {} RouterOutlet elements",
90 router_outlet_count
91 );
92 }
93
94 let mut out = String::new();
95 let state_type = resolved_state_type.or(doc.state_type.as_deref());
96 let has_state = state_type.is_some();
97 let has_slot = parser::has_slot(&doc.root);
98
99 if let Some(ref fm) = doc.frontmatter {
100 out.push_str(fm);
101 out.push('\n');
102 }
103
104 let commands = parser::collect_commands(&doc.root);
105 if !commands.is_empty() {
106 out.push_str("#[allow(unused)]\n");
107 out.push_str(&format!("fn _fncc_validate_{file_id}() {{\n"));
108 for cmd in &commands {
109 let trampoline = format!("__fncc_cmd_{cmd}");
110 out.push_str(&format!(" let _ = {trampoline};\n"));
111 }
112 out.push_str("}\n\n");
113 }
114
115 if has_state {
116 generate_stateful(
117 doc,
118 &mut out,
119 imports,
120 state_type,
121 import_props,
122 prop_fields,
123 import_has_slots,
124 style_cascade,
125 style_theme,
126 );
127 } else {
128 generate_stateless(
129 doc,
130 &mut out,
131 imports,
132 component_name,
133 props_type,
134 import_props,
135 prop_fields,
136 import_has_slots,
137 has_slot,
138 style_cascade,
139 style_theme,
140 route_params,
141 );
142 }
143
144 out
145}
146
147fn generate_stateless(
148 doc: &Document,
149 out: &mut String,
150 imports: &[ResolvedImport],
151 component_name: Option<&str>,
152 props_type: Option<&str>,
153 import_props: &[(&str, Option<&str>)],
154 prop_fields: Option<&HashMap<String, Vec<PropField>>>,
155 import_has_slots: &[(&str, bool)],
156 has_slot: bool,
157 style_cascade: Option<&fncc_styles::Stylesheet>,
158 style_theme: Option<&str>,
159 route_params: &[String],
160) {
161 let name = component_name.unwrap_or(&doc.root.name);
162 let fn_name = format!("render_{}", to_snake_case(name));
163
164 if !route_params.is_empty()
165 && let Some(pt) = props_type
166 {
167 panic!(
168 "routes combining dynamic parameters and props are not supported: screen '{}' has params {:?} and props type '{}'",
169 name, route_params, pt
170 );
171 }
172
173 let mut param_parts = Vec::new();
174 for p in route_params {
175 param_parts.push(format!("{p}: &str"));
176 }
177 if let Some(pt) = props_type {
178 param_parts.push(format!("props: &{pt}"));
179 }
180 if has_slot {
181 param_parts.push("children: impl IntoElement".to_string());
182 }
183 let params = param_parts.join(", ");
184
185 let sig = if params.is_empty() {
186 format!("pub fn {fn_name}() -> impl IntoElement {{\n")
187 } else {
188 format!("pub fn {fn_name}({params}) -> impl IntoElement {{\n")
189 };
190 out.push_str(&sig);
191 out.push_str(&generate_element(
192 &doc.root,
193 1,
194 false,
195 imports,
196 import_props,
197 prop_fields,
198 import_has_slots,
199 style_cascade,
200 style_theme,
201 ));
202 out.push('\n');
203 out.push_str("}\n");
204}
205
206fn generate_stateful(
207 doc: &Document,
208 out: &mut String,
209 imports: &[ResolvedImport],
210 state_type: Option<&str>,
211 import_props: &[(&str, Option<&str>)],
212 prop_fields: Option<&HashMap<String, Vec<PropField>>>,
213 _import_has_slots: &[(&str, bool)],
214 style_cascade: Option<&fncc_styles::Stylesheet>,
215 style_theme: Option<&str>,
216) {
217 let state_type = state_type.unwrap_or("Self");
218
219 out.push_str(&format!("impl Render for {state_type} {{\n"));
220 out.push_str(" fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {\n");
221 out.push_str(" let handle = cx.entity().downgrade();\n");
222 out.push_str(&generate_element(
223 &doc.root,
224 2,
225 true,
226 imports,
227 import_props,
228 prop_fields,
229 _import_has_slots,
230 style_cascade,
231 style_theme,
232 ));
233 out.push_str("\n }\n");
234 out.push_str("}\n");
235}
236
237fn collect_if_chain(nodes: &[Node], start: usize) -> Vec<&Element> {
241 let mut branches = Vec::new();
242 for node in &nodes[start..] {
243 match node {
244 Node::Element(el) if el.name == "If" => {
245 if branches.is_empty() {
246 branches.push(el);
247 } else {
248 break; }
250 }
251 Node::Element(el) if el.name == "ElseIf" || el.name == "Else" => {
252 if branches.is_empty() {
253 break; }
255 branches.push(el);
256 }
257 _ => break,
258 }
259 }
260 branches
261}
262
263fn gen_if_expr(
266 branches: &[&Element],
267 stateful: bool,
268 imports: &[ResolvedImport],
269 import_props: &[(&str, Option<&str>)],
270 prop_fields: Option<&HashMap<String, Vec<PropField>>>,
271 _import_has_slots: &[(&str, bool)],
272 style_cascade: Option<&fncc_styles::Stylesheet>,
273 style_theme: Option<&str>,
274) -> String {
275 let mut expr = String::new();
276 for (i, el) in branches.iter().enumerate() {
277 if i == 0 {
278 expr.push_str("if ");
279 } else if el.name == "Else" {
280 expr.push_str(" else ");
281 } else {
282 expr.push_str(" else if ");
283 }
284 if el.name != "Else" {
285 if let Some(cond) = parser::get_condition_attr(el) {
286 let e = interpolation_expr(cond, stateful);
287 expr.push_str(&e);
288 expr.push(' ');
289 } else {
290 expr.push_str("compile_error!(\"<");
291 expr.push_str(&el.name);
292 expr.push_str("> element is missing `condition` attribute\") ");
293 }
294 }
295 expr.push_str("{ ");
296 match &el.children[..] {
297 [] => expr.push_str("div()"),
298 [single] => match single {
299 Node::Text(t) => expr.push_str(&format!("div().child({:?})", t)),
300 Node::Interpolation(interp) => {
301 let e = interpolation_expr(interp, stateful);
302 let formatted = format_interpolation(&e);
303 expr.push_str(&format!("div().child({formatted})"));
304 }
305 Node::Element(child_el) => {
306 let child_code = generate_element(
307 child_el,
308 0,
309 stateful,
310 imports,
311 import_props,
312 prop_fields,
313 _import_has_slots,
314 style_cascade,
315 style_theme,
316 );
317 expr.push_str(&format!("div().child({})", clean_inline(&child_code)));
318 }
319 },
320 children => {
321 expr.push_str("div()");
322 for child in children {
323 match child {
324 Node::Text(t) => expr.push_str(&format!(".child({:?})", t)),
325 Node::Interpolation(interp) => {
326 let e = interpolation_expr(interp, stateful);
327 let formatted = format_interpolation(&e);
328 expr.push_str(&format!(".child({formatted})"));
329 }
330 Node::Element(child_el) => {
331 let child_code = generate_element(
332 child_el,
333 0,
334 stateful,
335 imports,
336 import_props,
337 prop_fields,
338 _import_has_slots,
339 style_cascade,
340 style_theme,
341 );
342 expr.push_str(&format!(".child({})", clean_inline(&child_code)));
343 }
344 }
345 }
346 }
347 }
348 expr.push_str(" }");
349 }
350 if branches.last().map(|b| b.name.as_str()) != Some("Else") {
352 expr.push_str(" else { div() }");
353 }
354 expr
355}
356
357fn gen_for_expr(
359 el: &Element,
360 stateful: bool,
361 imports: &[ResolvedImport],
362 import_props: &[(&str, Option<&str>)],
363 prop_fields: Option<&HashMap<String, Vec<PropField>>>,
364 _import_has_slots: &[(&str, bool)],
365 style_cascade: Option<&fncc_styles::Stylesheet>,
366 style_theme: Option<&str>,
367) -> String {
368 let each = match parser::get_each_attr(el) {
369 Some(e) => e,
370 None => {
371 return "compile_error!(\"<For> element is missing required `each` attribute\")".to_string();
372 }
373 };
374 let let_var = parser::get_let_attr(el).unwrap_or("item");
375 let index_var = parser::get_index_attr(el);
376 let iter_expr = interpolation_expr(each, stateful);
377
378 let mut body = String::new();
379 match &el.children[..] {
380 [] => body.push_str("div()"),
381 [single] => match single {
382 Node::Text(t) => body.push_str(&format!("div().child({:?})", t)),
383 Node::Interpolation(interp) => {
384 let e = interpolation_expr(interp, stateful);
385 let formatted = format_interpolation(&e);
386 body.push_str(&format!("div().child({formatted})"));
387 }
388 Node::Element(child_el) => {
389 let child_code = generate_element(
390 child_el,
391 1,
392 stateful,
393 imports,
394 import_props,
395 prop_fields,
396 _import_has_slots,
397 style_cascade,
398 style_theme,
399 );
400 body.push_str(&child_code);
401 }
402 },
403 children => {
404 body.push_str("div()");
405 for child in children {
406 match child {
407 Node::Text(t) => body.push_str(&format!(".child({:?})", t)),
408 Node::Interpolation(interp) => {
409 let e = interpolation_expr(interp, stateful);
410 let formatted = format_interpolation(&e);
411 body.push_str(&format!(".child({formatted})"));
412 }
413 Node::Element(child_el) => {
414 let child_code = generate_element(
415 child_el,
416 1,
417 stateful,
418 imports,
419 import_props,
420 prop_fields,
421 _import_has_slots,
422 style_cascade,
423 style_theme,
424 );
425 body.push_str(&format!(".child({})", clean_inline(&child_code)));
426 }
427 }
428 }
429 }
430 }
431
432 let var_names: Vec<&str> = index_var.iter().chain(std::iter::once(&let_var)).copied().collect();
436 for var in &var_names {
437 body = replace_self_prefix(&body, var);
438 }
439
440 if let Some(index) = index_var {
441 format!("{iter_expr}.iter().enumerate().map(|({index}, {let_var})| {{\n {body}\n}})")
442 } else {
443 format!("{iter_expr}.iter().map(|{let_var}| {{\n {body}\n}})")
444 }
445}
446
447fn generate_element(
448 el: &Element,
449 depth: usize,
450 stateful: bool,
451 imports: &[ResolvedImport],
452 import_props: &[(&str, Option<&str>)],
453 prop_fields: Option<&HashMap<String, Vec<PropField>>>,
454 _import_has_slots: &[(&str, bool)],
455 style_cascade: Option<&fncc_styles::Stylesheet>,
456 style_theme: Option<&str>,
457) -> String {
458 let indent = " ".repeat(depth);
459
460 let code = match el.name.as_str() {
461 "Stack" => gen_stack(
462 el,
463 &indent,
464 depth,
465 stateful,
466 imports,
467 import_props,
468 prop_fields,
469 _import_has_slots,
470 style_cascade,
471 style_theme,
472 ),
473 "Text" => gen_text(
474 el,
475 &indent,
476 depth,
477 stateful,
478 imports,
479 import_props,
480 prop_fields,
481 _import_has_slots,
482 style_cascade,
483 style_theme,
484 ),
485 "Button" => gen_button(
486 el,
487 &indent,
488 depth,
489 stateful,
490 imports,
491 import_props,
492 prop_fields,
493 _import_has_slots,
494 style_cascade,
495 style_theme,
496 ),
497 "Fragment" => gen_fragment(
498 el,
499 &indent,
500 depth,
501 stateful,
502 imports,
503 import_props,
504 prop_fields,
505 _import_has_slots,
506 style_cascade,
507 style_theme,
508 ),
509 "For" => format!(
510 "div().children({})",
511 gen_for_expr(
512 el,
513 stateful,
514 imports,
515 import_props,
516 prop_fields,
517 _import_has_slots,
518 style_cascade,
519 style_theme
520 )
521 ),
522 "If" | "ElseIf" | "Else" => {
523 let branches = [el];
524 gen_if_expr(
525 &branches,
526 stateful,
527 imports,
528 import_props,
529 prop_fields,
530 _import_has_slots,
531 style_cascade,
532 style_theme,
533 )
534 }
535 "RouterOutlet" | "Slot" => "children".to_string(),
536 _ => {
537 if let Some(render_fn) = imports
538 .iter()
539 .find(|(name, fn_name)| name == &el.name && !fn_name.is_empty())
540 .map(|(_, fn_name)| *fn_name)
541 {
542 gen_import_call(
543 el,
544 &indent,
545 render_fn,
546 import_props,
547 prop_fields,
548 _import_has_slots,
549 stateful,
550 imports,
551 depth,
552 style_cascade,
553 style_theme,
554 )
555 } else {
556 gen_fallback(
557 el,
558 &indent,
559 depth,
560 stateful,
561 imports,
562 import_props,
563 prop_fields,
564 _import_has_slots,
565 style_cascade,
566 style_theme,
567 )
568 }
569 }
570 };
571
572 match inject_element_styles(&code, el, style_cascade, style_theme) {
573 Ok(code) => code,
574 Err(e) => format!("compile_error!({e:?})"),
575 }
576}
577
578fn gen_import_call(
579 el: &Element,
580 indent: &str,
581 render_fn: &str,
582 import_props: &[(&str, Option<&str>)],
583 prop_fields: Option<&HashMap<String, Vec<PropField>>>,
584 _import_has_slots: &[(&str, bool)],
585 stateful: bool,
586 imports: &[ResolvedImport],
587 depth: usize,
588 style_cascade: Option<&fncc_styles::Stylesheet>,
589 style_theme: Option<&str>,
590) -> String {
591 let props_type = import_props.iter().find(|(n, _)| n == &el.name).and_then(|(_, p)| *p);
592 let has_slot = _import_has_slots
593 .iter()
594 .find(|(n, _)| n == &el.name)
595 .map(|(_, h)| *h)
596 .unwrap_or(false);
597
598 let children_expr = if has_slot {
599 if el.children.is_empty() {
600 "div()".to_string()
601 } else {
602 let child_code = generate_children_code(
603 &el.children,
604 indent,
605 depth + 1,
606 stateful,
607 imports,
608 import_props,
609 prop_fields,
610 _import_has_slots,
611 style_cascade,
612 style_theme,
613 );
614 clean_inline(&format!("div(){child_code}"))
615 }
616 } else {
617 String::new()
618 };
619
620 if let Some(pt) = props_type {
621 let mut struct_fields = String::new();
622 if let Some(fields) = prop_fields.and_then(|m| m.get(pt)) {
623 for f in fields {
624 if let Some((_, attr_val)) = el.attrs.iter().find(|(n, _)| n == &f.name) {
625 let v = match attr_val {
626 AttrValue::String(s) => format!("{:?}.into()", s),
627 AttrValue::Interpolation(expr) => format!("{}.into()", interpolation_expr(expr, stateful)),
628 };
629 struct_fields.push_str(&format!("\n{indent} {}: {},", f.name, v));
630 } else if f.is_optional {
631 struct_fields.push_str(&format!("\n{indent} {}: None,", f.name));
632 }
633 }
634 } else {
635 for (attr_name, attr_val) in &el.attrs {
636 if attr_name == "class" || attr_name == "style" {
637 continue;
638 }
639 let v = match attr_val {
640 AttrValue::String(s) => format!("{:?}.into()", s),
641 AttrValue::Interpolation(expr) => format!("{}.into()", interpolation_expr(expr, stateful)),
642 };
643 struct_fields.push_str(&format!("\n{indent} {attr_name}: {v},"));
644 }
645 }
646 if has_slot {
647 format!("{indent}{render_fn}(&{pt} {{ {struct_fields}\n{indent} }}, {children_expr})")
648 } else {
649 format!("{indent}{render_fn}(&{pt} {{ {struct_fields}\n{indent} }})")
650 }
651 } else if has_slot {
652 format!("{indent}{render_fn}({children_expr})")
653 } else {
654 format!("{indent}{render_fn}()")
655 }
656}
657
658fn inject_element_styles(
659 code: &str,
660 el: &Element,
661 cascade: Option<&fncc_styles::Stylesheet>,
662 theme: Option<&str>,
663) -> Result<String, String> {
664 let cascade = match cascade {
665 Some(c) => c,
666 None => return Ok(code.to_string()),
667 };
668
669 let classes: Vec<String> = el
670 .attrs
671 .iter()
672 .filter(|(name, _)| name == "class")
673 .filter_map(|(_, val)| match val {
674 AttrValue::String(s) => Some(s.split_whitespace().map(|s| s.to_string()).collect::<Vec<_>>()),
675 AttrValue::Interpolation(_) => None,
676 })
677 .flatten()
678 .collect();
679
680 let inline_style = el
681 .attrs
682 .iter()
683 .find(|(name, _)| name == "style")
684 .and_then(|(_, val)| match val {
685 AttrValue::String(s) => Some(s.clone()),
686 AttrValue::Interpolation(_) => None,
687 });
688
689 if classes.is_empty() && inline_style.is_none() {
690 return Ok(code.to_string());
691 }
692
693 let calls = fncc_styles::resolve(&classes, inline_style.as_deref(), cascade, theme)?;
694
695 if calls.is_empty() {
696 return Ok(code.to_string());
697 }
698
699 let indent = code
701 .lines()
702 .rev()
703 .find(|l| !l.trim().is_empty())
704 .map(|l| l.len() - l.trim_start().len())
705 .unwrap_or(4);
706 let indent_str = " ".repeat(indent);
707
708 let style_code: String = calls.iter().map(|c| format!("\n{indent_str}.{}", c.code)).collect();
709
710 let mut insert_pos = None;
712 for (i, line) in code.lines().enumerate() {
713 let trimmed = line.trim_start();
714 if trimmed.starts_with(".child(") || trimmed.starts_with(".children(") {
715 insert_pos = Some(
716 code.lines()
717 .take(i)
718 .map(|l| l.len() + 1)
719 .sum::<usize>()
720 .saturating_sub(1),
721 );
722 break;
723 }
724 }
725
726 if let Some(pos) = insert_pos {
727 Ok(format!("{}{}{}", &code[..pos], style_code, &code[pos..]))
728 } else {
729 Ok(format!("{}{}", code.trim_end(), style_code))
730 }
731}
732
733fn gen_fragment(
736 el: &Element,
737 indent: &str,
738 depth: usize,
739 stateful: bool,
740 imports: &[ResolvedImport],
741 import_props: &[(&str, Option<&str>)],
742 prop_fields: Option<&HashMap<String, Vec<PropField>>>,
743 _import_has_slots: &[(&str, bool)],
744 style_cascade: Option<&fncc_styles::Stylesheet>,
745 style_theme: Option<&str>,
746) -> String {
747 let children_code = generate_children_code(
748 &el.children,
749 indent,
750 depth,
751 stateful,
752 imports,
753 import_props,
754 prop_fields,
755 _import_has_slots,
756 style_cascade,
757 style_theme,
758 );
759 format!("{indent}div()\n{children_code}").trim_end().to_string()
760}
761
762fn generate_children_code(
764 children: &[Node],
765 indent: &str,
766 depth: usize,
767 stateful: bool,
768 imports: &[ResolvedImport],
769 import_props: &[(&str, Option<&str>)],
770 prop_fields: Option<&HashMap<String, Vec<PropField>>>,
771 _import_has_slots: &[(&str, bool)],
772 style_cascade: Option<&fncc_styles::Stylesheet>,
773 style_theme: Option<&str>,
774) -> String {
775 let mut out = String::new();
776 let mut i = 0;
777 while i < children.len() {
778 match &children[i] {
779 Node::Element(el) if el.name == "If" => {
780 let chain = collect_if_chain(children, i);
781 let expr = gen_if_expr(
782 &chain,
783 stateful,
784 imports,
785 import_props,
786 prop_fields,
787 _import_has_slots,
788 style_cascade,
789 style_theme,
790 );
791 out.push_str(&format!("{indent} .child(\n"));
792 out.push_str(&format!("{indent} {expr}\n"));
793 out.push_str(&format!("{indent} )\n"));
794 i += chain.len();
795 }
796 Node::Element(el) if el.name == "For" => {
797 let for_code = gen_for_expr(
798 el,
799 stateful,
800 imports,
801 import_props,
802 prop_fields,
803 _import_has_slots,
804 style_cascade,
805 style_theme,
806 );
807 out.push_str(&format!("{indent} .children(\n"));
808 out.push_str(&format!("{indent} {for_code}\n"));
809 out.push_str(&format!("{indent} )\n"));
810 i += 1;
811 }
812 Node::Element(el) if el.name == "Fragment" => {
813 let frag_code = generate_children_code(
814 &el.children,
815 indent,
816 depth,
817 stateful,
818 imports,
819 import_props,
820 prop_fields,
821 _import_has_slots,
822 style_cascade,
823 style_theme,
824 );
825 out.push_str(&frag_code);
826 i += 1;
827 }
828 _ => {
829 out.push_str(&format!("{indent} .child(\n"));
830 match &children[i] {
831 Node::Element(child_el) => {
832 out.push_str(&generate_element(
833 child_el,
834 depth + 2,
835 stateful,
836 imports,
837 import_props,
838 prop_fields,
839 _import_has_slots,
840 style_cascade,
841 style_theme,
842 ));
843 }
844 Node::Text(t) => {
845 out.push_str(&format!("{indent} {:?}", t));
846 }
847 Node::Interpolation(expr) => {
848 let e = interpolation_expr(expr, stateful);
849 let formatted = format_interpolation(&e);
850 out.push_str(&format!("{indent} {formatted}"));
851 }
852 }
853 out.push('\n');
854 out.push_str(&format!("{indent} )\n"));
855 i += 1;
856 }
857 }
858 }
859 out
860}
861
862fn gen_stack(
863 el: &Element,
864 indent: &str,
865 depth: usize,
866 stateful: bool,
867 imports: &[ResolvedImport],
868 import_props: &[(&str, Option<&str>)],
869 prop_fields: Option<&HashMap<String, Vec<PropField>>>,
870 _import_has_slots: &[(&str, bool)],
871 style_cascade: Option<&fncc_styles::Stylesheet>,
872 style_theme: Option<&str>,
873) -> String {
874 let mut out = format!("{indent}div()\n");
875
876 let mut is_vertical = false;
877 for (key, val) in &el.attrs {
878 match key.as_str() {
879 "direction" if val.as_str() == "vertical" => is_vertical = true,
880 "gap" => {
881 let v = val.as_str();
882 if let Ok(n) = v.parse::<f64>() {
883 out.push_str(&format!("{indent} .gap(px({}))\n", float_lit(n)));
884 }
885 }
886 _ => {}
887 }
888 }
889
890 if is_vertical {
891 out.push_str(&format!("{indent} .flex()\n{indent} .flex_col()\n"));
892 } else {
893 out.push_str(&format!("{indent} .flex()\n"));
894 }
895
896 out.push_str(&generate_children_code(
897 &el.children,
898 indent,
899 depth,
900 stateful,
901 imports,
902 import_props,
903 prop_fields,
904 _import_has_slots,
905 style_cascade,
906 style_theme,
907 ));
908
909 out.trim_end().to_string()
910}
911
912fn gen_text(
913 el: &Element,
914 indent: &str,
915 depth: usize,
916 stateful: bool,
917 imports: &[ResolvedImport],
918 import_props: &[(&str, Option<&str>)],
919 prop_fields: Option<&HashMap<String, Vec<PropField>>>,
920 _import_has_slots: &[(&str, bool)],
921 style_cascade: Option<&fncc_styles::Stylesheet>,
922 style_theme: Option<&str>,
923) -> String {
924 let mut out = format!("{indent}div()\n");
925
926 for (key, val) in &el.attrs {
927 if key.as_str() == "size" {
928 let v = val.as_str();
929 let ts = match v {
930 "xs" => "text_xs()",
931 "sm" => "text_sm()",
932 "base" => "text_base()",
933 "lg" => "text_lg()",
934 "xl" => "text_xl()",
935 "2xl" | "xxl" => "text_2xl()",
936 "3xl" => "text_3xl()",
937 _ => "text_base()",
938 };
939 out.push_str(&format!("{indent} .{ts}\n"));
940 }
941 }
942
943 match &el.children[..] {
944 [Node::Text(t)] => {
945 out.push_str(&format!("{indent} .child({:?})", t));
946 }
947 [Node::Interpolation(expr)] => {
948 let e = interpolation_expr(expr, stateful);
949 let formatted = format_interpolation(&e);
950 out.push_str(&format!("{indent} .child({formatted})"));
951 }
952 children => {
953 out.push_str(&generate_children_code(
954 children,
955 indent,
956 depth,
957 stateful,
958 imports,
959 import_props,
960 prop_fields,
961 _import_has_slots,
962 style_cascade,
963 style_theme,
964 ));
965 }
966 }
967
968 out.trim_end().to_string()
969}
970
971fn gen_button(
972 el: &Element,
973 indent: &str,
974 depth: usize,
975 stateful: bool,
976 imports: &[ResolvedImport],
977 import_props: &[(&str, Option<&str>)],
978 prop_fields: Option<&HashMap<String, Vec<PropField>>>,
979 _import_has_slots: &[(&str, bool)],
980 style_cascade: Option<&fncc_styles::Stylesheet>,
981 style_theme: Option<&str>,
982) -> String {
983 let mut out = format!("{indent}div()\n");
984
985 let btn_id = match &el.children[..] {
986 [Node::Text(t)] => t.clone(),
987 _ => format!("button_{depth}"),
988 };
989 out.push_str(&format!("{indent} .id({:?})\n", btn_id));
990 out.push_str(&format!("{indent} .cursor_pointer()\n"));
991
992 for (key, val) in &el.attrs {
993 if key.as_str() == "onclick" {
994 let handler = val.as_str();
995 let trampoline = format!("__fncc_cmd_{handler}");
996 if stateful {
997 out.push_str(&format!("{indent} .on_click({{\n"));
999 out.push_str(&format!("{indent} let handle = handle.clone();\n"));
1000 out.push_str(&format!("{indent} move |_, _, cx| {{\n"));
1001 out.push_str(&format!("{indent} handle.update(cx, |this, cx| {{\n"));
1002 out.push_str(&format!("{indent} {trampoline}(this, cx);\n"));
1003 out.push_str(&format!("{indent} }}).ok();\n"));
1004 out.push_str(&format!("{indent} }}\n"));
1005 out.push_str(&format!("{indent} }})\n"));
1006 } else {
1007 out.push_str(&format!("{indent} .on_click({trampoline})\n"));
1008 }
1009 }
1010 }
1011
1012 match &el.children[..] {
1013 [Node::Text(t)] => out.push_str(&format!("{indent} .child({:?})", t)),
1014 [Node::Interpolation(expr)] => {
1015 let e = interpolation_expr(expr, stateful);
1016 let formatted = format_interpolation(&e);
1017 out.push_str(&format!("{indent} .child({formatted})"));
1018 }
1019 children => {
1020 out.push_str(&generate_children_code(
1021 children,
1022 indent,
1023 depth,
1024 stateful,
1025 imports,
1026 import_props,
1027 prop_fields,
1028 _import_has_slots,
1029 style_cascade,
1030 style_theme,
1031 ));
1032 }
1033 }
1034
1035 out.trim_end().to_string()
1036}
1037
1038fn gen_fallback(
1039 el: &Element,
1040 indent: &str,
1041 depth: usize,
1042 stateful: bool,
1043 imports: &[ResolvedImport],
1044 import_props: &[(&str, Option<&str>)],
1045 prop_fields: Option<&HashMap<String, Vec<PropField>>>,
1046 _import_has_slots: &[(&str, bool)],
1047 style_cascade: Option<&fncc_styles::Stylesheet>,
1048 style_theme: Option<&str>,
1049) -> String {
1050 let mut out = format!("{indent}div()\n");
1051 for (key, val) in &el.attrs {
1052 if key == "class" || key == "style" {
1053 continue;
1054 }
1055 let v = val.as_str();
1056 out.push_str(&format!("{indent} .attr({:?}, {:?})\n", key, v));
1057 }
1058 out.push_str(&generate_children_code(
1059 &el.children,
1060 indent,
1061 depth,
1062 stateful,
1063 imports,
1064 import_props,
1065 prop_fields,
1066 _import_has_slots,
1067 style_cascade,
1068 style_theme,
1069 ));
1070 out.trim_end().to_string()
1071}
1072
1073fn clean_inline(s: &str) -> String {
1075 let collapsed: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
1076 collapsed.replace(") .", ").")
1077}
1078
1079fn float_lit(n: f64) -> String {
1082 if n.fract() == 0.0 {
1083 format!("{n}.")
1084 } else {
1085 format!("{n}")
1086 }
1087}
1088
1089fn interpolation_expr(expr: &str, stateful: bool) -> String {
1095 let trimmed = expr.trim();
1096 if let Some(field) = trimmed.strip_prefix("state.") {
1097 if stateful {
1098 format!("self.{field}")
1099 } else {
1100 field.to_string()
1101 }
1102 } else if trimmed.starts_with("props.") {
1103 trimmed.to_string()
1104 } else if stateful {
1105 format!("self.{trimmed}")
1106 } else {
1107 trimmed.to_string()
1108 }
1109}
1110
1111fn replace_self_prefix(s: &str, var: &str) -> String {
1115 let pattern = format!("self.{var}");
1116 let mut out = String::new();
1117 let mut last = 0;
1118 for (idx, _) in s.match_indices(&pattern) {
1119 out.push_str(&s[last..idx]);
1120 let end = idx + pattern.len();
1121 let keep = if end < s.len() {
1122 let b = s.as_bytes()[end];
1123 b.is_ascii_alphanumeric() || b == b'_'
1124 } else {
1125 false
1126 };
1127 if keep {
1128 out.push_str(&pattern);
1129 last = idx + pattern.len();
1130 } else {
1131 out.push_str(var);
1132 last = end;
1133 }
1134 }
1135 out.push_str(&s[last..]);
1136 out
1137}
1138
1139pub(crate) fn to_snake_case(name: &str) -> String {
1140 let mut result = String::new();
1141 for (i, ch) in name.chars().enumerate() {
1142 if ch.is_uppercase() {
1143 if i > 0 {
1144 result.push('_');
1145 }
1146 for c in ch.to_lowercase() {
1147 result.push(c);
1148 }
1149 } else {
1150 result.push(ch);
1151 }
1152 }
1153 result
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158 use super::*;
1159 use crate::parser::parse;
1160
1161 fn cascade_none() -> Option<fncc_styles::Stylesheet> {
1162 None
1163 }
1164
1165 fn generate_from(source: &str) -> String {
1166 let doc = parse(source).unwrap();
1167 generate(&doc)
1168 }
1169
1170 #[test]
1173 fn test_generates_stateless_render_function() {
1174 let out = generate_from("<MyComp></MyComp>");
1175 assert!(out.contains("pub fn render_my_comp() -> impl IntoElement {"));
1176 assert!(out.contains("div()"));
1177 }
1178
1179 #[test]
1180 fn test_generates_stateful_render_impl() {
1181 let src = "---\n@state CounterState\n---\n<App></App>";
1182 let out = generate_from(src);
1183 assert!(out.contains("impl Render for CounterState {"));
1184 assert!(
1185 out.contains("fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {")
1186 );
1187 }
1188
1189 #[test]
1190 fn test_frontmatter_is_preserved_in_output() {
1191 let src = "---\nuse crate::prelude::*;\n---\n<App></App>";
1192 let out = generate_from(src);
1193 assert!(out.contains("use crate::prelude::*;"));
1194 }
1195
1196 #[test]
1197 fn test_stack_with_direction_vertical_generates_flex_col() {
1198 let out = generate_from("<Stack direction=\"vertical\"></Stack>");
1199 assert!(out.contains(".flex()"));
1200 assert!(out.contains(".flex_col()"));
1201 }
1202
1203 #[test]
1204 fn test_stack_with_direction_horizontal_generates_flex_only() {
1205 let out = generate_from("<Stack direction=\"horizontal\"></Stack>");
1206 assert!(out.contains(".flex()"));
1207 assert!(!out.contains(".flex_col()"));
1208 }
1209
1210 #[test]
1211 fn test_stack_with_gap_generates_px_value() {
1212 let out = generate_from("<Stack gap=\"12\"></Stack>");
1213 assert!(out.contains(".gap(px(12.))"));
1214 }
1215
1216 #[test]
1217 fn test_text_with_size_xl() {
1218 let out = generate_from("<Text size=\"xl\">hello</Text>");
1219 assert!(out.contains(".text_xl()"));
1220 assert!(out.contains(".child(\"hello\")"));
1221 }
1222
1223 #[test]
1224 fn test_text_with_size_unknown_falls_back_to_base() {
1225 let out = generate_from("<Text size=\"huge\">text</Text>");
1226 assert!(out.contains(".text_base()"));
1227 }
1228
1229 #[test]
1230 fn test_button_with_text_child() {
1231 let out = generate_from("<Button onclick=\"handle_click\">Click</Button>");
1232 assert!(out.contains(".id(\"Click\")"));
1233 assert!(out.contains(".cursor_pointer()"));
1234 assert!(out.contains(".child(\"Click\")"));
1235 }
1236
1237 #[test]
1238 fn test_stateful_button_with_onclick_generates_entity_pattern() {
1239 let src = "---\n@state AppState\n---\n<Button onclick=\"inc\">+1</Button>";
1240 let out = generate_from(src);
1241 assert!(out.contains("let handle = handle.clone();"));
1242 assert!(out.contains("handle.update(cx, |this, cx| {"));
1243 assert!(out.contains("__fncc_cmd_inc(this, cx);"));
1244 }
1245
1246 #[test]
1247 fn test_stateless_button_with_onclick_generates_direct_call() {
1248 let out = generate_from("<Button onclick=\"log_click\">Go</Button>");
1249 assert!(out.contains(".on_click(__fncc_cmd_log_click)"));
1250 }
1251
1252 #[test]
1255 fn test_empty_element_children() {
1256 let out = generate_from("<Div></Div>");
1257 assert!(out.contains("div()"));
1258 }
1259
1260 #[test]
1261 fn test_unknown_element_falls_back_to_div_with_attrs() {
1262 let out = generate_from("<CustomEl foo=\"bar\">content</CustomEl>");
1263 assert!(out.contains(".attr(\"foo\", \"bar\")"));
1264 assert!(out.contains(".child("));
1265 assert!(out.contains("\"content\""));
1266 }
1267
1268 #[test]
1269 fn test_interpolation_in_text_content_generates_format() {
1270 let out = generate_from("---\n@state S\n---\n<Text>{state.msg}</Text>");
1271 assert!(out.contains("self.msg.to_string()"));
1272 assert!(!out.contains("self.state.msg"));
1273 }
1274
1275 #[test]
1276 fn test_interpolation_strips_state_prefix() {
1277 let out = generate_from("<Text>{state.count}</Text>");
1278 assert!(out.contains("count.to_string()"));
1280 }
1281
1282 #[test]
1283 fn test_multiple_commands_collected_in_validation_fn() {
1284 let src = "<Stack><Button onclick=\"a\">A</Button><Button onclick=\"b\">B</Button></Stack>";
1285 let out = generate_from(src);
1286 assert!(out.contains("fn _fncc_validate_"));
1287 assert!(out.contains("__fncc_cmd_a"));
1288 assert!(out.contains("__fncc_cmd_b"));
1289 }
1290
1291 #[test]
1294 fn test_generated_code_contains_no_markdown_or_template_leftovers() {
1295 let out = generate_from("<Text>hello</Text>");
1296 assert!(!out.contains("{{"));
1297 assert!(!out.contains("{state."));
1298 assert!(!out.contains("__fncc_cmd_") || out.contains("__fncc_cmd_"));
1299 }
1300
1301 #[test]
1302 fn test_generated_function_name_follows_snake_case() {
1303 let out = generate_from("<HTMLParser></HTMLParser>");
1304 assert!(out.contains("render_h_t_m_l_parser") || out.contains("render_html_parser"));
1305 }
1306
1307 #[test]
1310 fn test_regression_gap_with_decimal_does_not_produce_invalid_syntax() {
1311 let out = generate_from("<Stack gap=\"12.5\"></Stack>");
1312 assert!(out.contains(".gap(px(12.5))"), "got: {out}");
1313 }
1314
1315 #[test]
1316 fn test_regression_duplicate_button_ids_at_same_depth() {
1317 let src = "<Stack><Button>OK</Button><Button>OK</Button></Stack>";
1318 let out = generate_from(src);
1319 let id_count = out.matches(".id(\"OK\")").count();
1320 assert!(
1321 id_count <= 2,
1322 "expected at most 2 .id(\"OK\") occurrences, got {id_count}"
1323 );
1324 }
1325
1326 #[test]
1327 fn test_regression_multiple_calls_have_unique_validation_fn_names() {
1328 let doc = parse("<Button onclick=\"x\">X</Button>").unwrap();
1329 let a = generate_with_id(&doc, 0);
1330 let b = generate_with_id(&doc, 1);
1331 assert_ne!(a, b);
1332 assert!(a.contains("__fncc_cmd_x"));
1333 assert!(b.contains("__fncc_cmd_x"));
1334 }
1335
1336 #[test]
1337 fn test_regression_empty_gap_does_not_panic() {
1338 let out = generate_from("<Stack gap=\"\"></Stack>");
1339 assert!(!out.contains(".gap(") || out.contains(".gap(px(0.))"));
1341 }
1342
1343 #[test]
1344 fn test_regression_interpolation_without_state_prefix() {
1345 let out = generate_from("---\n@state S\n---\n<Text>{custom_expr}</Text>");
1346 assert!(out.contains("custom_expr") || out.contains("self.custom_expr"));
1348 }
1349
1350 #[test]
1351 fn test_regression_many_calls_do_not_panic() {
1352 for _ in 0..100 {
1353 generate_from("<Button onclick=\"f\">F</Button>");
1354 }
1355 }
1356
1357 #[test]
1360 fn test_imported_element_generates_render_call() {
1361 let doc = parse("<Stack><Header /></Stack>").unwrap();
1362 let imports: &[(&str, &str)] = &[("Header", "render_header")];
1363 let out = generate_with_imports(
1364 &doc,
1365 0,
1366 imports,
1367 None,
1368 None,
1369 None,
1370 &[],
1371 None,
1372 &[],
1373 cascade_none().as_ref(),
1374 None,
1375 );
1376 assert!(out.contains("render_header()"));
1377 }
1378
1379 #[test]
1380 fn test_imported_element_in_stateful_component() {
1381 let src = "---\n@state AppState\n---\n<Stack><Footer /></Stack>";
1382 let doc = parse(src).unwrap();
1383 let imports: &[(&str, &str)] = &[("Footer", "render_footer")];
1384 let out = generate_with_imports(
1385 &doc,
1386 0,
1387 imports,
1388 None,
1389 None,
1390 None,
1391 &[],
1392 None,
1393 &[],
1394 cascade_none().as_ref(),
1395 None,
1396 );
1397 assert!(out.contains("render_footer()"));
1398 }
1399
1400 #[test]
1401 fn test_gpui_import_falls_back_to_div() {
1402 let doc = parse("<Stack><TextInput /></Stack>").unwrap();
1403 let imports: &[(&str, &str)] = &[("TextInput", "")];
1404 let out = generate_with_imports(
1405 &doc,
1406 0,
1407 imports,
1408 None,
1409 None,
1410 None,
1411 &[],
1412 None,
1413 &[],
1414 cascade_none().as_ref(),
1415 None,
1416 );
1417 assert!(out.contains("div()"));
1419 }
1420
1421 #[test]
1422 fn test_builtin_takes_precedence_over_import() {
1423 let doc = parse("<Text>hello</Text>").unwrap();
1424 let imports: &[(&str, &str)] = &[("Text", "render_text")];
1425 let out = generate_with_imports(
1426 &doc,
1427 0,
1428 imports,
1429 None,
1430 None,
1431 None,
1432 &[],
1433 None,
1434 &[],
1435 cascade_none().as_ref(),
1436 None,
1437 );
1438 assert!(out.contains(".child(\"hello\")"));
1440 }
1441
1442 #[test]
1443 fn test_imported_element_with_custom_component_name() {
1444 let doc = parse("<Stack><MyHeader /></Stack>").unwrap();
1445 let imports: &[(&str, &str)] = &[("MyHeader", "render_header")];
1446 let out = generate_with_imports(
1447 &doc,
1448 0,
1449 imports,
1450 None,
1451 None,
1452 None,
1453 &[],
1454 None,
1455 &[],
1456 cascade_none().as_ref(),
1457 None,
1458 );
1459 assert!(out.contains("render_header()"));
1460 }
1461
1462 #[test]
1463 fn test_render_fn_name_uses_component_name_arg() {
1464 let doc = parse("<Text>hello</Text>").unwrap();
1465 let out = generate_with_imports(
1466 &doc,
1467 0,
1468 &[],
1469 Some("CustomWidget"),
1470 None,
1471 None,
1472 &[],
1473 None,
1474 &[],
1475 cascade_none().as_ref(),
1476 None,
1477 );
1478 assert!(out.contains("pub fn render_custom_widget()"));
1479 assert!(!out.contains("pub fn render_text()"));
1481 }
1482
1483 #[test]
1486 fn test_props_stateless_component_with_props_signature() {
1487 let doc = parse("<Text>{props.title}</Text>").unwrap();
1488 let out = generate_with_imports(
1489 &doc,
1490 0,
1491 &[],
1492 Some("Header"),
1493 None,
1494 Some("HeaderProps"),
1495 &[],
1496 None,
1497 &[],
1498 cascade_none().as_ref(),
1499 None,
1500 );
1501 assert!(out.contains("pub fn render_header(props: &HeaderProps) -> impl IntoElement {"));
1502 assert!(out.contains("props.title"));
1503 }
1504
1505 #[test]
1506 fn test_props_caller_generates_struct_construction() {
1507 let doc = parse("<Header title=\"Welcome\" />").unwrap();
1508 let imports: &[(&str, &str)] = &[("Header", "render_header")];
1509 let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps"))];
1510 let out = generate_with_imports(
1511 &doc,
1512 0,
1513 imports,
1514 None,
1515 None,
1516 None,
1517 import_props,
1518 None,
1519 &[],
1520 cascade_none().as_ref(),
1521 None,
1522 );
1523 assert!(out.contains("render_header(&HeaderProps {"));
1524 assert!(out.contains("title: \"Welcome\".into(),"));
1525 assert!(out.contains("})"));
1526 }
1527
1528 #[test]
1529 fn test_props_caller_multiple_attributes() {
1530 let doc = parse("<Header title=\"Hi\" subtitle=\"World\" />").unwrap();
1531 let imports: &[(&str, &str)] = &[("Header", "render_header")];
1532 let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps"))];
1533 let out = generate_with_imports(
1534 &doc,
1535 0,
1536 imports,
1537 None,
1538 None,
1539 None,
1540 import_props,
1541 None,
1542 &[],
1543 cascade_none().as_ref(),
1544 None,
1545 );
1546 assert!(out.contains("title: \"Hi\".into(),"));
1547 assert!(out.contains("subtitle: \"World\".into(),"));
1548 }
1549
1550 #[test]
1551 fn test_props_caller_option_field() {
1552 let doc = parse("<Header title=\"Hi\" />").unwrap();
1553 let imports: &[(&str, &str)] = &[("Header", "render_header")];
1554 let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps"))];
1555 let out = generate_with_imports(
1556 &doc,
1557 0,
1558 imports,
1559 None,
1560 None,
1561 None,
1562 import_props,
1563 None,
1564 &[],
1565 cascade_none().as_ref(),
1566 None,
1567 );
1568 assert!(out.contains("title: \"Hi\".into(),"));
1570 }
1571
1572 #[test]
1573 fn test_props_nested_element_with_props() {
1574 let doc = parse("<Stack><Header title=\"Nested\" /><Text>ok</Text></Stack>").unwrap();
1575 let imports: &[(&str, &str)] = &[("Header", "render_header")];
1576 let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps"))];
1577 let out = generate_with_imports(
1578 &doc,
1579 0,
1580 imports,
1581 None,
1582 None,
1583 None,
1584 import_props,
1585 None,
1586 &[],
1587 cascade_none().as_ref(),
1588 None,
1589 );
1590 assert!(out.contains("render_header(&HeaderProps {"));
1591 assert!(out.contains("title: \"Nested\".into(),"));
1592 }
1593
1594 #[test]
1595 fn test_props_mixed_components_with_and_without_props() {
1596 let doc = parse("<Stack><Header title=\"A\" /><Footer /></Stack>").unwrap();
1597 let imports: &[(&str, &str)] = &[("Header", "render_header"), ("Footer", "render_footer")];
1598 let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps")), ("Footer", None)];
1599 let out = generate_with_imports(
1600 &doc,
1601 0,
1602 imports,
1603 None,
1604 None,
1605 None,
1606 import_props,
1607 None,
1608 &[],
1609 cascade_none().as_ref(),
1610 None,
1611 );
1612 assert!(out.contains("render_header(&HeaderProps {"));
1613 assert!(out.contains("title: \"A\".into(),"));
1614 assert!(out.contains("render_footer()"));
1615 }
1616
1617 #[test]
1618 fn test_props_element_without_props_still_calls_directly() {
1619 let doc = parse("<Footer />").unwrap();
1620 let imports: &[(&str, &str)] = &[("Footer", "render_footer")];
1621 let import_props: &[(&str, Option<&str>)] = &[("Footer", None)];
1622 let out = generate_with_imports(
1623 &doc,
1624 0,
1625 imports,
1626 None,
1627 None,
1628 None,
1629 import_props,
1630 None,
1631 &[],
1632 cascade_none().as_ref(),
1633 None,
1634 );
1635 assert!(out.contains("render_footer()"));
1636 assert!(!out.contains("&"));
1637 }
1638
1639 #[test]
1640 fn test_props_self_closing_with_props() {
1641 let doc = parse("<Header title=\"SelfClose\" subtitle=\"X\" />").unwrap();
1642 let imports: &[(&str, &str)] = &[("Header", "render_header")];
1643 let import_props: &[(&str, Option<&str>)] = &[("Header", Some("HeaderProps"))];
1644 let out = generate_with_imports(
1645 &doc,
1646 0,
1647 imports,
1648 None,
1649 None,
1650 None,
1651 import_props,
1652 None,
1653 &[],
1654 cascade_none().as_ref(),
1655 None,
1656 );
1657 assert!(out.contains("title: \"SelfClose\".into(),"));
1658 assert!(out.contains("subtitle: \"X\".into(),"));
1659 }
1660
1661 #[test]
1664 fn test_if_generates_if_expression() {
1665 let out = generate_from("<If condition=\"{state.show}\"><Text>Hi</Text></If>");
1666 assert!(out.contains("if show {"));
1667 assert!(out.contains("div().child(div().child(\"Hi\"))"));
1668 assert!(out.contains("} else {"), "else branch should be present");
1669 assert!(out.contains("div() }"));
1670 }
1671
1672 #[test]
1673 fn test_if_else_chain() {
1674 let src = "<Stack><If condition=\"{state.a}\"><Text>A</Text></If><Else><Text>B</Text></Else></Stack>";
1675 let out = generate_from(src);
1676 assert!(out.contains("if a {"));
1677 assert!(out.contains("} else {"));
1678 assert!(out.contains("div().child(\"B\")"));
1679 }
1680
1681 #[test]
1682 fn test_if_elseif_else_chain() {
1683 let src = "<Stack><If condition=\"{state.x}\"><Text>X</Text></If><ElseIf condition=\"{state.y}\"><Text>Y</Text></ElseIf><Else><Text>Z</Text></Else></Stack>";
1684 let out = generate_from(src);
1685 assert!(out.contains("if x {"));
1686 assert!(out.contains("else if y {"));
1687 assert!(out.contains("else {"));
1688 }
1689
1690 #[test]
1691 fn test_for_generates_iteration() {
1692 let out = generate_from("<For each=\"{state.items}\" let=\"item\"><Text>{item.name}</Text></For>");
1693 assert!(out.contains("items.iter().map(|item| {"));
1694 assert!(out.contains("item.name"));
1695 }
1696
1697 #[test]
1698 fn test_for_with_index() {
1699 let out = generate_from("<For each=\"{state.items}\" let=\"item\" index=\"i\"><Text>{item.name}</Text></For>");
1700 assert!(out.contains(".enumerate()"));
1701 assert!(out.contains("|(i, item)|"));
1702 }
1703
1704 #[test]
1705 fn test_fragment_generates_div() {
1706 let out = generate_from("<Fragment><Text>A</Text><Text>B</Text></Fragment>");
1707 assert!(out.contains("div()"));
1708 }
1709
1710 #[test]
1711 fn test_slot_in_stateless_component() {
1712 let doc = parse("<Stack><Slot /></Stack>").unwrap();
1713 let out = generate_with_imports(
1714 &doc,
1715 0,
1716 &[],
1717 Some("Card"),
1718 None,
1719 None,
1720 &[],
1721 None,
1722 &[],
1723 cascade_none().as_ref(),
1724 None,
1725 );
1726 assert!(out.contains("children: impl IntoElement"));
1727 assert!(out.contains("children"));
1728 }
1729
1730 #[test]
1731 fn test_if_in_stack() {
1732 let src = "<Stack><If condition=\"{state.flag}\"><Text>Yes</Text></If><Text>Always</Text></Stack>";
1733 let out = generate_from(src);
1734 assert!(out.contains("if flag {"));
1735 assert!(out.contains("\"Always\""));
1736 }
1737
1738 #[test]
1739 fn test_for_in_stack() {
1740 let src = "<Stack><For each=\"{state.items}\" let=\"item\"><Text>{item}</Text></For></Stack>";
1741 let out = generate_from(src);
1742 assert!(out.contains("items.iter().map(|item| {"));
1743 assert!(out.contains(".children("));
1744 }
1745
1746 #[test]
1747 fn test_fragment_nested_in_stack() {
1748 let src = "<Stack><Fragment><Text>A</Text><Text>B</Text></Fragment></Stack>";
1749 let out = generate_from(src);
1750 assert!(out.contains("div()"));
1751 assert!(out.contains(".child(\"A\")"));
1752 assert!(out.contains(".child(\"B\")"));
1753 }
1754
1755 #[test]
1758 fn test_style_class_injects_method_calls() {
1759 let doc = parse("<Text class=\"heading\">Title</Text>").unwrap();
1760 let mut ss = fncc_styles::Stylesheet::default();
1761 ss.rules.insert(
1762 "heading".into(),
1763 vec![("color".into(), "red".into()), ("font-size".into(), "24px".into())],
1764 );
1765 let out = generate_with_imports(&doc, 0, &[], None, None, None, &[], None, &[], Some(&ss), None);
1766 assert!(
1767 out.contains(".text_color(rgba(0xff0000ff))"),
1768 "missing text_color: {out}"
1769 );
1770 assert!(out.contains(".text_2xl()"), "missing text_2xl: {out}");
1771 }
1772
1773 #[test]
1774 fn test_style_inline_injects_method_calls() {
1775 let doc = parse("<Text style=\"color: blue; padding: 8px\">Hi</Text>").unwrap();
1776 let ss = fncc_styles::Stylesheet::default();
1777 let out = generate_with_imports(&doc, 0, &[], None, None, None, &[], None, &[], Some(&ss), None);
1778 assert!(out.contains(".text_color(rgba(0x0000ffff))"));
1779 assert!(out.contains(".p(px(8.))"));
1780 }
1781
1782 #[test]
1783 fn test_style_class_unknown_skipped() {
1784 let doc = parse("<Text class=\"nonexistent\">Hi</Text>").unwrap();
1785 let ss = fncc_styles::Stylesheet::default();
1786 let out = generate_with_imports(&doc, 0, &[], None, None, None, &[], None, &[], Some(&ss), None);
1787 assert!(out.contains(".child(\"Hi\")"));
1789 }
1790
1791 #[test]
1792 fn test_style_class_and_inline_merged() {
1793 let doc = parse("<Button class=\"big\" style=\"color: white\">Go</Button>").unwrap();
1794 let mut ss = fncc_styles::Stylesheet::default();
1795 ss.rules.insert(
1796 "big".into(),
1797 vec![("padding".into(), "16px".into()), ("font-size".into(), "20px".into())],
1798 );
1799 let out = generate_with_imports(&doc, 0, &[], None, None, None, &[], None, &[], Some(&ss), None);
1800 assert!(out.contains(".p(px(16.))"));
1801 assert!(out.contains(".text_xl()"));
1802 assert!(out.contains(".text_color(rgba(0xffffffff))"));
1803 }
1804}