1pub mod codegen;
2pub mod config;
3pub mod parser;
4pub mod router;
5pub mod semantic;
6
7pub use parser::parse;
8pub use router::{RouteInfo, RouteTree};
9
10use anyhow::{Context, Result};
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14fn collect_fui_files(dir: &Path) -> Result<Vec<PathBuf>> {
16 let mut files = Vec::new();
17 if !dir.is_dir() {
18 return Ok(files);
19 }
20 for entry in std::fs::read_dir(dir).context("failed to read ui dir")? {
21 let entry = entry.context("failed to read entry")?;
22 let path = entry.path();
23 if path.is_dir() {
24 files.extend(collect_fui_files(&path)?);
25 } else if path.extension().is_some_and(|ext| ext == "fui") {
26 files.push(path);
27 }
28 }
29 Ok(files)
30}
31
32fn collect_fncss_files(dir: &Path) -> Result<HashMap<PathBuf, fncc_styles::Stylesheet>> {
34 let mut sheets = HashMap::new();
35 collect_fncss_recursive(dir, &mut sheets)?;
36 Ok(sheets)
37}
38
39fn collect_fncss_recursive(current: &Path, sheets: &mut HashMap<PathBuf, fncc_styles::Stylesheet>) -> Result<()> {
40 if !current.is_dir() {
41 return Ok(());
42 }
43 let mut entries: Vec<_> = std::fs::read_dir(current)
44 .with_context(|| format!("failed to read dir {:?}", current))?
45 .collect::<std::io::Result<Vec<_>>>()
46 .with_context(|| format!("failed to read entry in {:?}", current))?;
47 entries.sort_by_key(|e| e.file_name());
48
49 for entry in entries {
50 let path = entry.path();
51 if path.is_dir() {
52 collect_fncss_recursive(&path, sheets)?;
53 } else if path.extension().is_some_and(|ext| ext == "fncss") {
54 let content = std::fs::read_to_string(&path).with_context(|| format!("failed to read {:?}", path))?;
55 match fncc_styles::css_parser::parse(&content) {
56 Ok(mut ss) => {
57 let parent = path.parent().unwrap_or(current).to_path_buf();
58 resolve_font_paths(&mut ss, &parent)?;
59 let merged = match sheets.remove(&parent) {
60 Some(prev) => fncc_styles::merge(vec![prev, ss]),
61 None => ss,
62 };
63 sheets.insert(parent, merged);
64 }
65 Err(e) => anyhow::bail!("failed to parse {:?}: {e}", path),
66 }
67 }
68 }
69 Ok(())
70}
71
72fn resolve_font_paths(ss: &mut fncc_styles::Stylesheet, base_dir: &Path) -> Result<()> {
75 for path in ss.fonts.values_mut() {
76 let p = Path::new(path.as_str());
77 if !p.is_absolute() {
78 *path = base_dir.join(p).to_string_lossy().to_string();
79 }
80 }
81 Ok(())
82}
83
84fn generate_fonts_code(sheets: &HashMap<PathBuf, fncc_styles::Stylesheet>, out_file: &Path) -> Result<String> {
88 let mut fonts: Vec<(&str, &str)> = Vec::new();
89 for sheet in sheets.values() {
90 for (family, path) in &sheet.fonts {
91 if !fonts.iter().any(|(f, p)| f == family && *p == path.as_str()) {
92 fonts.push((family.as_str(), path.as_str()));
93 }
94 }
95 }
96
97 if fonts.is_empty() {
98 return Ok(String::new());
99 }
100
101 let out_dir = out_file.parent().context("out_file has no parent directory")?;
102 let fonts_dir = out_dir.join("fncc_fonts");
103 std::fs::create_dir_all(&fonts_dir).context("failed to create fncc_fonts dir")?;
104
105 let mut entries = String::new();
106 for (_, path) in &fonts {
107 let src = Path::new(path);
108 let file_name = src
111 .to_string_lossy()
112 .chars()
113 .map(|c| if c.is_ascii_alphanumeric() || c == '.' { c } else { '_' })
114 .collect::<String>();
115 let dest = fonts_dir.join(&file_name);
116 std::fs::copy(src, &dest).with_context(|| format!("failed to copy font {} to {:?}", src.display(), dest))?;
117 entries.push_str(&format!(
118 " Cow::Borrowed(include_bytes!(concat!(env!(\"OUT_DIR\"), \"/fncc_fonts/{file_name}\")) as &[u8]),\n"
119 ));
120 }
121
122 Ok(format!(
123 "/// Register custom fonts declared via `@font-face` in `.fncss` files.\n\
124 /// Call this in app startup, before opening any window:\n\
125 /// ```no_run\n\
126 /// Application::new().run(|cx: &mut App| {{ fncc_load_fonts(cx); ... }});\n\
127 /// ```\n\
128 pub fn fncc_load_fonts(cx: &mut App) {{\n\
129 \x20 use std::borrow::Cow;\n\
130 \x20 if let Err(e) = cx.text_system().add_fonts(vec![\n\
131 {entries}\
132 \x20 ]) {{\n\
133 \x20 eprintln!(\"Failed to load fonts: {{e:?}}\");\n\
134 \x20 }}\n\
135 }}\n\n"
136 ))
137}
138
139fn build_cascade(
142 fui_path: &Path,
143 ui_dir: &Path,
144 fncss_sheets: &HashMap<PathBuf, fncc_styles::Stylesheet>,
145 inline_styles: Option<&str>,
146) -> Result<fncc_styles::Stylesheet> {
147 let mut cascade = Vec::new();
148
149 let fui_dir = fui_path.parent().unwrap_or(Path::new(""));
150
151 let mut ancestors: Vec<PathBuf> = Vec::new();
153 let mut current = Some(fui_dir.to_path_buf());
154 while let Some(dir) = current {
155 if dir == ui_dir || dir.starts_with(ui_dir) || ui_dir.starts_with(&dir) {
156 ancestors.push(dir.clone());
157 }
158 if dir == ui_dir {
159 break;
160 }
161 current = dir.parent().map(|p| p.to_path_buf());
162 }
163 ancestors.reverse();
164
165 for dir in &ancestors {
166 if let Some(ss) = fncss_sheets.get(dir) {
167 cascade.push(ss.clone());
168 }
169 }
170
171 if let Some(styles_text) = inline_styles {
173 let inline_ss = fncc_styles::css_parser::parse(styles_text)
174 .map_err(|e| anyhow::anyhow!("failed to parse <Styles> block: {e}"))?;
175 cascade.push(inline_ss);
176 }
177
178 Ok(fncc_styles::merge(cascade))
179}
180
181struct ParsedFile {
183 path: std::path::PathBuf,
184 relative_stem: String,
187 ast: parser::Document,
188}
189
190pub struct GenerateOptions<'a> {
194 pub ui_dir: &'a Path,
195 pub out_file: &'a Path,
196 pub src_dir: Option<&'a Path>,
199}
200
201impl<'a> GenerateOptions<'a> {
202 pub fn new(ui_dir: &'a Path, out_file: &'a Path) -> Self {
203 GenerateOptions {
204 ui_dir,
205 out_file,
206 src_dir: None,
207 }
208 }
209}
210
211pub fn generate_all(ui_dir: &Path, out_file: &Path) -> Result<()> {
214 generate_all_with_options(GenerateOptions::new(ui_dir, out_file))
215}
216
217pub fn generate_all_with_options(opts: GenerateOptions) -> Result<()> {
220 let ui_dir = opts.ui_dir;
221 let out_file = opts.out_file;
222 let files = collect_fui_files(ui_dir)?;
223
224 let fncss_sheets = collect_fncss_files(ui_dir)?;
226
227 let mut parsed_files: Vec<ParsedFile> = Vec::new();
229 for path in &files {
230 let source = std::fs::read_to_string(path).with_context(|| format!("failed to read {:?}", path))?;
231 let ast = parser::parse(&source).map_err(|e| anyhow::anyhow!("failed to parse {:?}: {e}", path))?;
232
233 let relative_stem = match path.strip_prefix(ui_dir) {
235 Ok(rel) => {
236 let stem = rel.file_stem().unwrap_or_default();
237 let parent = rel.parent().unwrap_or_else(|| std::path::Path::new(""));
238 parent.join(stem).to_string_lossy().to_string()
239 }
240 Err(_) => path.file_stem().unwrap_or_default().to_string_lossy().to_string(),
241 };
242 let relative_stem = relative_stem.replace(std::path::MAIN_SEPARATOR_STR, "/");
243
244 parsed_files.push(ParsedFile {
245 path: path.clone(),
246 relative_stem,
247 ast,
248 });
249 }
250
251 let routes_dir = if ui_dir.file_name() == Some(std::ffi::OsStr::new("routes")) {
253 Some(ui_dir.to_path_buf())
254 } else if ui_dir.join("routes").is_dir() {
255 Some(ui_dir.join("routes"))
256 } else {
257 None
258 };
259
260 let route_tree = if let Some(ref r_dir) = routes_dir {
261 Some(RouteTree::scan(r_dir)?)
262 } else {
263 None
264 };
265
266 let resolve_render_fn = |pf: &ParsedFile| -> String {
268 if let (Some(r_dir), Some(tree)) = (routes_dir.as_ref(), route_tree.as_ref())
269 && let Ok(rel) = pf.path.strip_prefix(r_dir)
270 {
271 if rel.file_name() == Some(std::ffi::OsStr::new("layout.fui")) {
272 let parent = rel.parent().unwrap_or_else(|| Path::new(""));
273 let parent_str = parent.to_string_lossy();
274 if parent_str.is_empty() {
275 return "render_layout".to_string();
276 } else {
277 let pascal = router::to_pascal_case(&parent_str.replace('/', "_"));
278 return format!("render_{}_layout", codegen::to_snake_case(&pascal));
279 }
280 }
281
282 if let Some(route) = tree.routes.iter().find(|r| r.file_path == pf.path) {
283 return format!("render_{}", codegen::to_snake_case(&route.variant_name));
284 }
285
286 if let Some(ref fb) = tree.fallback
287 && fb.file_path == pf.path
288 {
289 return "render_fallback".to_string();
290 }
291 }
292
293 let component_name = pf.relative_stem.split('/').next_back().unwrap_or("");
294 format!("render_{}", codegen::to_snake_case(component_name))
295 };
296
297 let mut import_index: HashMap<String, String> = HashMap::new();
299 for pf in &parsed_files {
300 let ui_path = format!("ui::{}", pf.relative_stem.replace('/', "::"));
301 let render_fn = resolve_render_fn(pf);
302 import_index.insert(ui_path, render_fn);
303 }
304
305 let mut render_fn_to_props: HashMap<String, Option<String>> = HashMap::new();
308 for pf in &parsed_files {
309 let render_fn = resolve_render_fn(pf);
310 let props_type = parser::uses_props_interpolation(&pf.ast.root)
311 .then(|| {
312 pf.ast.imports.iter().find_map(|imp| {
313 if matches!(imp.source, parser::ImportSource::PropsType) {
314 Some(imp.name.clone())
315 } else {
316 None
317 }
318 })
319 })
320 .flatten();
321 render_fn_to_props.insert(render_fn, props_type);
322 }
323
324 let mut render_fn_to_slot: HashMap<String, bool> = HashMap::new();
326 for pf in &parsed_files {
327 let render_fn = resolve_render_fn(pf);
328 let has_slot = parser::has_slot(&pf.ast.root);
329 render_fn_to_slot.insert(render_fn, has_slot);
330 }
331
332 for pf in &parsed_files {
334 for imp in &pf.ast.imports {
335 if let parser::ImportSource::FuiPath(ref ui_path) = imp.source
336 && !import_index.contains_key(ui_path)
337 {
338 anyhow::bail!(
339 "in '{}': import `{}` -> file not found at `{}.fui`",
340 pf.path.display(),
341 ui_path,
342 ui_path.strip_prefix("ui::").unwrap_or(ui_path)
343 );
344 }
345 }
346 }
347
348 let semantic_db = if let Some(src_dir) = opts.src_dir {
350 let db = semantic::analyze_rs_files(src_dir)?;
351 if let Some(diag) = db.diagnostics.first() {
353 anyhow::bail!("{}", diag);
354 }
355 Some(db)
356 } else {
357 None
358 };
359
360 let mut state_types: HashMap<usize, Option<String>> = HashMap::new();
362 if let Some(ref db) = semantic_db {
363 for (file_id, pf) in parsed_files.iter().enumerate() {
364 let command_refs = parser::collect_commands(&pf.ast.root);
365
366 let mut inferred_types: Vec<&str> = Vec::new();
367 for cmd_name in &command_refs {
368 let cmd = db.commands.get(cmd_name).ok_or_else(|| {
369 anyhow::anyhow!(
370 "in '{}': onclick=\"{}\" references #[fncc::command] fn {}() which was not found in any Rust source file",
371 pf.path.display(), cmd_name, cmd_name,
372 )
373 })?;
374 if let Some(ref st) = cmd.state_type
375 && !inferred_types.contains(&st.as_str())
376 {
377 inferred_types.push(st);
378 }
379 }
380
381 let resolved = match (pf.ast.state_type.as_ref(), inferred_types.as_slice()) {
382 (Some(declared), []) => Some(declared.clone()),
383 (Some(declared), [inferred]) if declared.as_str() == *inferred => Some(declared.clone()),
384 (Some(declared), [inferred]) => anyhow::bail!(
385 "in '{}': @state {} conflicts with inferred state type {} from command signatures — remove @state or align the types",
386 pf.path.display(),
387 declared,
388 inferred,
389 ),
390 (Some(d), _) => anyhow::bail!(
391 "in '{}': commands reference multiple state types ({}) but @state {} was declared",
392 pf.path.display(),
393 inferred_types.join(", "),
394 d,
395 ),
396 (None, []) => None,
397 (None, [inferred]) => Some((*inferred).to_string()),
398 (None, _) => anyhow::bail!(
399 "in '{}': commands reference multiple state types ({}) — a component can only have one",
400 pf.path.display(),
401 inferred_types.join(", "),
402 ),
403 };
404
405 state_types.insert(file_id, resolved);
406 }
407 }
408
409 let mut output = String::new();
411 output.push_str("// Generated by fncc-core. Do not edit.\n\n");
412
413 let fonts_code = generate_fonts_code(&fncss_sheets, out_file)?;
417 output.push_str(&fonts_code);
418
419 for (file_id, pf) in parsed_files.iter().enumerate() {
420 let resolved: Vec<(&str, &str)> = pf
421 .ast
422 .imports
423 .iter()
424 .map(|imp| {
425 let render_fn = match &imp.source {
426 parser::ImportSource::FuiPath(ui_path) => {
427 import_index.get(ui_path).map(|s| s.as_str()).unwrap_or("")
428 }
429 parser::ImportSource::Gpui | parser::ImportSource::PropsType => "",
430 };
431 (imp.name.as_str(), render_fn)
432 })
433 .collect();
434
435 let import_props: Vec<(&str, Option<&str>)> = pf
437 .ast
438 .imports
439 .iter()
440 .filter_map(|imp| {
441 let render_fn = match &imp.source {
442 parser::ImportSource::FuiPath(ui_path) => import_index.get(ui_path),
443 _ => None,
444 }?;
445 let props = render_fn_to_props.get(render_fn).and_then(|p| p.as_deref());
446 Some((imp.name.as_str(), props))
447 })
448 .collect();
449
450 let uses_props = parser::uses_props_interpolation(&pf.ast.root);
454 let own_props_type = uses_props
455 .then(|| {
456 pf.ast.imports.iter().find_map(|imp| {
457 if matches!(imp.source, parser::ImportSource::PropsType) {
458 Some(imp.name.as_str())
459 } else {
460 None
461 }
462 })
463 })
464 .flatten();
465
466 if let Some(ref db) = semantic_db {
468 validate_props_usage(&pf.path, &pf.ast.root, &import_props, &db.props_types)?;
469 }
470
471 let render_fn = resolve_render_fn(pf);
472 let comp_name = render_fn.strip_prefix("render_").unwrap_or(&render_fn);
473 let component_name = Some(comp_name);
474 let resolved_state = state_types.get(&file_id).and_then(|s| s.as_deref());
475
476 if resolved_state.is_some() && own_props_type.is_some() {
478 anyhow::bail!(
479 "in '{}': component cannot have both state and props — props are only supported on stateless components",
480 pf.path.display(),
481 );
482 }
483
484 let has_slot = parser::has_slot(&pf.ast.root);
486
487 if resolved_state.is_some() && has_slot {
489 anyhow::bail!(
490 "in '{}': stateful components cannot use `<Slot>` — slots are only supported on stateless components",
491 pf.path.display(),
492 );
493 }
494
495 let import_has_slots: Vec<(&str, bool)> = pf
497 .ast
498 .imports
499 .iter()
500 .filter_map(|imp| {
501 let render_fn = match &imp.source {
502 parser::ImportSource::FuiPath(ui_path) => import_index.get(ui_path),
503 _ => None,
504 }?;
505 let has_slot = render_fn_to_slot.get(render_fn).copied().unwrap_or(false);
506 Some((imp.name.as_str(), has_slot))
507 })
508 .collect();
509
510 let prop_fields = semantic_db.as_ref().map(|db| &db.props_types);
511 let style_cascade = build_cascade(&pf.path, ui_dir, &fncss_sheets, pf.ast.styles.as_deref())
512 .map_err(|e| anyhow::anyhow!("in '{}': {e}", pf.path.display()))?;
513 let style_theme = pf.ast.theme.as_deref();
514 if let Some(theme_name) = style_theme
515 && !style_cascade.themes.contains_key(theme_name)
516 {
517 anyhow::bail!(
518 "in '{}': unknown theme `{theme_name}` declared via `@theme` — no matching `theme {theme_name} {{ ... }}` block in the cascade",
519 pf.path.display(),
520 );
521 }
522 let route_params = route_tree
523 .as_ref()
524 .and_then(|tree| tree.routes.iter().find(|r| r.file_path == pf.path))
525 .map(|r| r.params.as_slice())
526 .unwrap_or(&[]);
527
528 if let Some(state_ty) = resolved_state
529 && !route_params.is_empty()
530 {
531 anyhow::bail!(
532 "in '{}': dynamic route screens must be stateless in v0.1 (found state type '{}' on route with params {:?})",
533 pf.path.display(),
534 state_ty,
535 route_params
536 );
537 }
538
539 let generated = codegen::generate_with_imports_and_route_params(
540 &pf.ast,
541 file_id,
542 &resolved,
543 component_name,
544 resolved_state,
545 own_props_type,
546 &import_props,
547 prop_fields,
548 &import_has_slots,
549 Some(&style_cascade),
550 style_theme,
551 route_params,
552 );
553 output.push_str(&generated);
554 output.push('\n');
555 }
556
557 if let Some(ref tree) = route_tree
559 && (!tree.routes.is_empty() || tree.fallback.is_some())
560 {
561 let router_code = router::generate_router_code(tree);
562 output.push_str(&router_code);
563 }
564
565 std::fs::write(out_file, &output).context("failed to write generated file")?;
566 Ok(())
567}
568
569fn validate_props_usage(
573 file_path: &Path,
574 el: &parser::Element,
575 import_props: &[(&str, Option<&str>)],
576 props_types: &HashMap<String, Vec<semantic::PropField>>,
577) -> Result<()> {
578 if let Some((_, Some(props_type_name))) = import_props.iter().find(|(name, _)| name == &el.name)
579 && let Some(fields) = props_types.get(*props_type_name)
580 {
581 for (attr_name, _) in &el.attrs {
582 if !fields.iter().any(|f| f.name == *attr_name) {
583 let available: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
584 anyhow::bail!(
585 "in '{}': component `{}` has no prop `{}` — available props: {}",
586 file_path.display(),
587 el.name,
588 attr_name,
589 available.join(", "),
590 );
591 }
592 }
593 for field in fields {
594 if !field.is_optional && !el.attrs.iter().any(|(name, _)| name == &field.name) {
595 anyhow::bail!(
596 "in '{}': component `{}` requires prop `{}` (type {})",
597 file_path.display(),
598 el.name,
599 field.name,
600 field.type_expr,
601 );
602 }
603 }
604 }
605 for child in &el.children {
606 if let parser::Node::Element(child_el) = child {
607 validate_props_usage(file_path, child_el, import_props, props_types)?;
608 }
609 }
610 Ok(())
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616 use std::path::PathBuf;
617 use std::sync::atomic::{AtomicUsize, Ordering};
618
619 static LIB_TEST_COUNTER: AtomicUsize = AtomicUsize::new(0);
620
621 fn test_dir() -> (PathBuf, PathBuf) {
622 let id = LIB_TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
623 let dir = std::env::temp_dir().join(format!("fncc_lib_test_{id}"));
624 let ui_dir = dir.join("ui");
625 std::fs::create_dir_all(&ui_dir).unwrap();
626 (dir, ui_dir)
627 }
628
629 #[test]
630 fn test_generate_all_creates_output_file() {
631 let (dir, ui_dir) = test_dir();
632 std::fs::write(ui_dir.join("App.fui"), "<Text>hello</Text>").unwrap();
633 let out_file = dir.join("out.rs");
634
635 generate_all(&ui_dir, &out_file).unwrap();
636
637 assert!(out_file.exists());
638 let content = std::fs::read_to_string(&out_file).unwrap();
639 assert!(content.contains("// Generated by fncc-core. Do not edit."));
640 assert!(content.contains("pub fn render_app()"));
642 std::fs::remove_dir_all(&dir).unwrap();
643 }
644
645 #[test]
646 fn test_generate_all_ignores_non_fui_files() {
647 let (dir, ui_dir) = test_dir();
648 std::fs::write(ui_dir.join("App.fui"), "<A></A>").unwrap();
649 std::fs::write(ui_dir.join("notes.txt"), "not a fui").unwrap();
650 std::fs::write(ui_dir.join("main.rs"), "fn main() {}").unwrap();
651 let out_file = dir.join("out.rs");
652
653 generate_all(&ui_dir, &out_file).unwrap();
654
655 let content = std::fs::read_to_string(&out_file).unwrap();
656 assert!(content.contains("pub fn render_app()"));
658 assert!(!content.contains("notes"));
659 assert!(!content.contains("main"));
660 std::fs::remove_dir_all(&dir).unwrap();
661 }
662
663 #[test]
664 fn test_generate_all_with_import() {
665 let (dir, ui_dir) = test_dir();
666
667 std::fs::write(ui_dir.join("Header.fui"), "<Text size=\"xl\">Welcome</Text>").unwrap();
669 std::fs::write(
671 ui_dir.join("App.fui"),
672 "---\nuse ui::Header;\n---\n<Stack><Header /></Stack>",
673 )
674 .unwrap();
675
676 let out_file = dir.join("out.rs");
677 generate_all(&ui_dir, &out_file).unwrap();
678
679 let content = std::fs::read_to_string(&out_file).unwrap();
680 assert!(content.contains("pub fn render_header()"));
682 assert!(content.contains("render_header()"));
684 assert!(!content.contains("use ui::"));
686 std::fs::remove_dir_all(&dir).unwrap();
687 }
688
689 #[test]
690 fn test_generate_all_with_grouped_imports() {
691 let (dir, ui_dir) = test_dir();
692
693 std::fs::create_dir_all(ui_dir.join("components")).unwrap();
694 std::fs::write(
695 ui_dir.join("components").join("Button.fui"),
696 "<Button onclick=\"handle_click\">Click</Button>",
697 )
698 .unwrap();
699 std::fs::write(
700 ui_dir.join("components").join("Card.fui"),
701 "<Stack><Text>card</Text></Stack>",
702 )
703 .unwrap();
704 std::fs::write(
705 ui_dir.join("App.fui"),
706 "---\nuse ui::components::{Button, Card};\n---\n<Stack><Card /><Button /></Stack>",
707 )
708 .unwrap();
709
710 let out_file = dir.join("out.rs");
711 generate_all(&ui_dir, &out_file).unwrap();
712
713 let content = std::fs::read_to_string(&out_file).unwrap();
714 assert!(content.contains("pub fn render_button()"));
715 assert!(content.contains("pub fn render_card()"));
716 assert!(content.contains("render_card()"));
717 assert!(content.contains("render_button()"));
718 std::fs::remove_dir_all(&dir).unwrap();
719 }
720
721 #[test]
722 fn test_generate_all_import_missing_file_errors() {
723 let (dir, ui_dir) = test_dir();
724
725 std::fs::write(ui_dir.join("App.fui"), "---\nuse ui::Missing;\n---\n<Stack></Stack>").unwrap();
726
727 let out_file = dir.join("out.rs");
728 let result = generate_all(&ui_dir, &out_file);
729 assert!(result.is_err());
730 let err = format!("{}", result.unwrap_err());
731 assert!(err.contains("Missing") || err.contains("not found"));
732 std::fs::remove_dir_all(&dir).unwrap();
733 }
734
735 #[test]
736 fn test_generate_all_with_gpui_import() {
737 let (dir, ui_dir) = test_dir();
738
739 std::fs::write(
740 ui_dir.join("App.fui"),
741 "---\nuse gpui::TextInput;\n---\n<Stack></Stack>",
742 )
743 .unwrap();
744
745 let out_file = dir.join("out.rs");
746 generate_all(&ui_dir, &out_file).unwrap();
747
748 let content = std::fs::read_to_string(&out_file).unwrap();
749 assert!(content.contains("use gpui::TextInput;"));
751 std::fs::remove_dir_all(&dir).unwrap();
752 }
753
754 #[test]
755 fn test_generate_all_recursive_scan() {
756 let (dir, ui_dir) = test_dir();
757
758 std::fs::create_dir_all(ui_dir.join("widgets")).unwrap();
760 std::fs::write(ui_dir.join("widgets").join("Footer.fui"), "<Text>footer</Text>").unwrap();
761 std::fs::write(
763 ui_dir.join("App.fui"),
764 "---\nuse ui::widgets::Footer;\n---\n<Stack><Footer /></Stack>",
765 )
766 .unwrap();
767
768 let out_file = dir.join("out.rs");
769 generate_all(&ui_dir, &out_file).unwrap();
770
771 let content = std::fs::read_to_string(&out_file).unwrap();
772 assert!(
773 content.contains("pub fn render_footer()"),
774 "content missing render_footer fn:\n{content}"
775 );
776 assert!(content.contains("render_footer()"));
777 std::fs::remove_dir_all(&dir).unwrap();
778 }
779
780 fn test_dir_with_src() -> (PathBuf, PathBuf, PathBuf) {
783 let id = LIB_TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
784 let dir = std::env::temp_dir().join(format!("fncc_sem_lib_test_{id}"));
785 let ui_dir = dir.join("ui");
786 let src_dir = dir.join("src");
787 std::fs::create_dir_all(&ui_dir).unwrap();
788 std::fs::create_dir_all(&src_dir).unwrap();
789 (dir, ui_dir, src_dir)
790 }
791
792 #[test]
793 fn test_generate_all_with_options_infers_state_from_rs() {
794 let (dir, ui_dir, src_dir) = test_dir_with_src();
795
796 std::fs::write(
798 ui_dir.join("App.fui"),
799 r#"<Stack direction="vertical" gap="12">
800 <Text>Count: {state.count}</Text>
801 <Button onclick="inc">+1</Button>
802</Stack>"#,
803 )
804 .unwrap();
805
806 std::fs::write(
808 src_dir.join("main.rs"),
809 "#[fncc::command]\nfn inc(state: &mut CounterState, cx: &mut Context<CounterState>) { state.count += 1; cx.notify(); }\n",
810 )
811 .unwrap();
812
813 let out_file = dir.join("out.rs");
814 generate_all_with_options(GenerateOptions {
815 ui_dir: &ui_dir,
816 out_file: &out_file,
817 src_dir: Some(&src_dir),
818 })
819 .unwrap();
820
821 let content = std::fs::read_to_string(&out_file).unwrap();
822 assert!(content.contains("impl Render for CounterState {"));
824 assert!(content.contains("__fncc_cmd_inc"));
825 std::fs::remove_dir_all(&dir).unwrap();
826 }
827
828 #[test]
829 fn test_generate_all_with_options_hard_error_on_missing_command() {
830 let (dir, ui_dir, src_dir) = test_dir_with_src();
831
832 std::fs::write(
833 ui_dir.join("App.fui"),
834 r#"<Button onclick="nonexistent">Click</Button>"#,
835 )
836 .unwrap();
837
838 std::fs::write(src_dir.join("main.rs"), "fn main() {}\n").unwrap();
840
841 let out_file = dir.join("out.rs");
842 let result = generate_all_with_options(GenerateOptions {
843 ui_dir: &ui_dir,
844 out_file: &out_file,
845 src_dir: Some(&src_dir),
846 });
847
848 assert!(result.is_err());
849 let err = format!("{}", result.unwrap_err());
850 assert!(err.contains("nonexistent"), "error should mention command name: {err}");
851 assert!(err.contains("not found"), "error should say not found: {err}");
852 std::fs::remove_dir_all(&dir).unwrap();
853 }
854
855 #[test]
856 fn test_generate_all_with_options_state_conflict_errors() {
857 let (dir, ui_dir, src_dir) = test_dir_with_src();
858
859 std::fs::write(
861 ui_dir.join("App.fui"),
862 "---\n@state OldState\n---\n<Button onclick=\"upd\">Update</Button>",
863 )
864 .unwrap();
865
866 std::fs::write(
867 src_dir.join("main.rs"),
868 "#[fncc::command]\nfn upd(state: &mut NewState, cx: &mut Context<NewState>) {}\n",
869 )
870 .unwrap();
871
872 let out_file = dir.join("out.rs");
873 let result = generate_all_with_options(GenerateOptions {
874 ui_dir: &ui_dir,
875 out_file: &out_file,
876 src_dir: Some(&src_dir),
877 });
878
879 assert!(result.is_err());
880 let err = format!("{}", result.unwrap_err());
881 assert!(
882 err.contains("@state OldState"),
883 "error should mention @state declaration: {err}"
884 );
885 assert!(err.contains("NewState"), "error should mention inferred type: {err}");
886 std::fs::remove_dir_all(&dir).unwrap();
887 }
888
889 #[test]
890 fn test_generate_all_with_options_stateless_remains_stateless() {
891 let (dir, ui_dir, src_dir) = test_dir_with_src();
892
893 std::fs::write(ui_dir.join("App.fui"), "<Text>hello</Text>").unwrap();
895 std::fs::write(src_dir.join("main.rs"), "fn main() {}\n").unwrap();
896
897 let out_file = dir.join("out.rs");
898 generate_all_with_options(GenerateOptions {
899 ui_dir: &ui_dir,
900 out_file: &out_file,
901 src_dir: Some(&src_dir),
902 })
903 .unwrap();
904
905 let content = std::fs::read_to_string(&out_file).unwrap();
906 assert!(content.contains("pub fn render_app()"));
908 assert!(!content.contains("impl Render for"));
909 std::fs::remove_dir_all(&dir).unwrap();
910 }
911
912 #[test]
913 fn test_generate_all_with_options_state_mismatch_across_commands() {
914 let (dir, ui_dir, src_dir) = test_dir_with_src();
915
916 std::fs::write(
917 ui_dir.join("App.fui"),
918 r#"<Stack>
919 <Button onclick="cmd_a">A</Button>
920 <Button onclick="cmd_b">B</Button>
921</Stack>"#,
922 )
923 .unwrap();
924
925 std::fs::write(
926 src_dir.join("main.rs"),
927 "#[fncc::command]\nfn cmd_a(s: &mut TypeA, cx: &mut Context<TypeA>) {}\n\
928 #[fncc::command]\nfn cmd_b(s: &mut TypeB, cx: &mut Context<TypeB>) {}\n",
929 )
930 .unwrap();
931
932 let out_file = dir.join("out.rs");
933 let result = generate_all_with_options(GenerateOptions {
934 ui_dir: &ui_dir,
935 out_file: &out_file,
936 src_dir: Some(&src_dir),
937 });
938
939 assert!(result.is_err());
940 let err = format!("{}", result.unwrap_err());
941 assert!(
942 err.contains("multiple state types"),
943 "error should mention multiple types: {err}"
944 );
945 std::fs::remove_dir_all(&dir).unwrap();
946 }
947
948 #[test]
949 fn test_generate_all_legacy_still_works_without_src_dir() {
950 let (dir, ui_dir) = test_dir();
952
953 std::fs::write(
954 ui_dir.join("App.fui"),
955 "---\n@state CounterState\n---\n<Button onclick=\"inc\">+1</Button>",
956 )
957 .unwrap();
958
959 let out_file = dir.join("out.rs");
960 generate_all(&ui_dir, &out_file).unwrap();
961
962 let content = std::fs::read_to_string(&out_file).unwrap();
963 assert!(content.contains("impl Render for CounterState {"));
964 std::fs::remove_dir_all(&dir).unwrap();
965 }
966
967 #[test]
970 fn test_props_component_with_props_generates_props_signature() {
971 let (dir, ui_dir, src_dir) = test_dir_with_src();
972
973 std::fs::write(
975 ui_dir.join("Header.fui"),
976 "---\nuse props::HeaderProps;\n---\n<Text>{props.title}</Text>",
977 )
978 .unwrap();
979
980 std::fs::write(
982 src_dir.join("lib.rs"),
983 "#[derive(fncc::Props)]\nstruct HeaderProps {\n pub title: String,\n}\n",
984 )
985 .unwrap();
986
987 let out_file = dir.join("out.rs");
988 generate_all_with_options(GenerateOptions {
989 ui_dir: &ui_dir,
990 out_file: &out_file,
991 src_dir: Some(&src_dir),
992 })
993 .unwrap();
994
995 let content = std::fs::read_to_string(&out_file).unwrap();
996 assert!(
997 content.contains("pub fn render_header(props: &HeaderProps)"),
998 "expected props signature, got:\n{content}"
999 );
1000 std::fs::remove_dir_all(&dir).unwrap();
1001 }
1002
1003 #[test]
1004 fn test_props_caller_constructs_props_struct() {
1005 let (dir, ui_dir, src_dir) = test_dir_with_src();
1006
1007 std::fs::write(
1009 ui_dir.join("Header.fui"),
1010 "---\nuse props::HeaderProps;\n---\n<Text>{props.title}</Text>",
1011 )
1012 .unwrap();
1013
1014 std::fs::write(
1016 ui_dir.join("App.fui"),
1017 "---\nuse ui::Header;\nuse props::HeaderProps;\n---\n<Stack><Header title=\"Hello\" /></Stack>",
1018 )
1019 .unwrap();
1020
1021 std::fs::write(
1023 src_dir.join("lib.rs"),
1024 "#[derive(fncc::Props)]\nstruct HeaderProps {\n pub title: String,\n}\n",
1025 )
1026 .unwrap();
1027
1028 let out_file = dir.join("out.rs");
1029 generate_all_with_options(GenerateOptions {
1030 ui_dir: &ui_dir,
1031 out_file: &out_file,
1032 src_dir: Some(&src_dir),
1033 })
1034 .unwrap();
1035
1036 let content = std::fs::read_to_string(&out_file).unwrap();
1037 assert!(content.contains("render_header(&HeaderProps {"));
1038 assert!(content.contains("title: \"Hello\".into(),"));
1039 std::fs::remove_dir_all(&dir).unwrap();
1040 }
1041
1042 #[test]
1043 fn test_props_multiple_fields() {
1044 let (dir, ui_dir, src_dir) = test_dir_with_src();
1045
1046 std::fs::write(
1047 ui_dir.join("Banner.fui"),
1048 "---\nuse props::BannerProps;\n---\n<Text>{props.heading}{props.sub}</Text>",
1049 )
1050 .unwrap();
1051
1052 std::fs::write(
1053 ui_dir.join("App.fui"),
1054 "---\nuse ui::Banner;\nuse props::BannerProps;\n---\n<Banner heading=\"Hi\" sub=\"World\" />",
1055 )
1056 .unwrap();
1057
1058 std::fs::write(
1059 src_dir.join("lib.rs"),
1060 "#[derive(fncc::Props)]\nstruct BannerProps {\n pub heading: String,\n pub sub: String,\n}\n",
1061 )
1062 .unwrap();
1063
1064 let out_file = dir.join("out.rs");
1065 generate_all_with_options(GenerateOptions {
1066 ui_dir: &ui_dir,
1067 out_file: &out_file,
1068 src_dir: Some(&src_dir),
1069 })
1070 .unwrap();
1071
1072 let content = std::fs::read_to_string(&out_file).unwrap();
1073 assert!(content.contains("heading: \"Hi\".into(),"));
1074 assert!(content.contains("sub: \"World\".into(),"));
1075 std::fs::remove_dir_all(&dir).unwrap();
1076 }
1077
1078 #[test]
1079 fn test_props_option_field() {
1080 let (dir, ui_dir, src_dir) = test_dir_with_src();
1081
1082 std::fs::write(
1083 ui_dir.join("Card.fui"),
1084 "---\nuse props::CardProps;\n---\n<Text>{props.title}</Text>",
1085 )
1086 .unwrap();
1087
1088 std::fs::write(
1089 ui_dir.join("App.fui"),
1090 "---\nuse ui::Card;\nuse props::CardProps;\n---\n<Card title=\"Optional\" />",
1091 )
1092 .unwrap();
1093
1094 std::fs::write(
1096 src_dir.join("lib.rs"),
1097 "#[derive(fncc::Props)]\nstruct CardProps {\n pub title: Option<String>,\n}\n",
1098 )
1099 .unwrap();
1100
1101 let out_file = dir.join("out.rs");
1102 generate_all_with_options(GenerateOptions {
1103 ui_dir: &ui_dir,
1104 out_file: &out_file,
1105 src_dir: Some(&src_dir),
1106 })
1107 .unwrap();
1108
1109 let content = std::fs::read_to_string(&out_file).unwrap();
1110 assert!(content.contains("title: \"Optional\".into(),"));
1111 std::fs::remove_dir_all(&dir).unwrap();
1112 }
1113
1114 #[test]
1117 fn test_props_unknown_attribute_errors() {
1118 let (dir, ui_dir, src_dir) = test_dir_with_src();
1119
1120 std::fs::write(
1121 ui_dir.join("Widget.fui"),
1122 "---\nuse props::WidgetProps;\n---\n<Text>{props.title}</Text>",
1123 )
1124 .unwrap();
1125
1126 std::fs::write(
1127 ui_dir.join("App.fui"),
1128 "---\nuse ui::Widget;\nuse props::WidgetProps;\n---\n<Widget title=\"Hi\" unknown=\"val\" />",
1129 )
1130 .unwrap();
1131
1132 std::fs::write(
1133 src_dir.join("lib.rs"),
1134 "#[derive(fncc::Props)]\npub struct WidgetProps { pub title: String, }\n",
1135 )
1136 .unwrap();
1137
1138 let out_file = dir.join("out.rs");
1139 let result = generate_all_with_options(GenerateOptions {
1140 ui_dir: &ui_dir,
1141 out_file: &out_file,
1142 src_dir: Some(&src_dir),
1143 });
1144
1145 assert!(result.is_err());
1146 let err = format!("{}", result.unwrap_err());
1147 assert!(err.contains("unknown"), "error should mention unknown prop: {err}");
1148 assert!(err.contains("unknown"), "error should contain attribute name: {err}");
1149 std::fs::remove_dir_all(&dir).unwrap();
1150 }
1151
1152 #[test]
1153 fn test_props_missing_required_field_errors() {
1154 let (dir, ui_dir, src_dir) = test_dir_with_src();
1155
1156 std::fs::write(
1157 ui_dir.join("Card.fui"),
1158 "---\nuse props::CardProps;\n---\n<Text>{props.heading}</Text>",
1159 )
1160 .unwrap();
1161
1162 std::fs::write(
1164 ui_dir.join("App.fui"),
1165 "---\nuse ui::Card;\nuse props::CardProps;\n---\n<Card />",
1166 )
1167 .unwrap();
1168
1169 std::fs::write(
1170 src_dir.join("lib.rs"),
1171 "#[derive(fncc::Props)]\npub struct CardProps { pub heading: String, }\n",
1172 )
1173 .unwrap();
1174
1175 let out_file = dir.join("out.rs");
1176 let result = generate_all_with_options(GenerateOptions {
1177 ui_dir: &ui_dir,
1178 out_file: &out_file,
1179 src_dir: Some(&src_dir),
1180 });
1181
1182 assert!(result.is_err());
1183 let err = format!("{}", result.unwrap_err());
1184 assert!(err.contains("requires"), "error should mention requires: {err}");
1185 assert!(err.contains("heading"), "error should mention field name: {err}");
1186 std::fs::remove_dir_all(&dir).unwrap();
1187 }
1188
1189 #[test]
1190 fn test_props_optional_field_allows_absence() {
1191 let (dir, ui_dir, src_dir) = test_dir_with_src();
1192
1193 std::fs::write(
1194 ui_dir.join("Banner.fui"),
1195 "---\nuse props::BannerProps;\n---\n<Text>{props.title}</Text>",
1196 )
1197 .unwrap();
1198
1199 std::fs::write(
1201 ui_dir.join("App.fui"),
1202 "---\nuse ui::Banner;\nuse props::BannerProps;\n---\n<Banner title=\"Hi\" />",
1203 )
1204 .unwrap();
1205
1206 std::fs::write(
1207 src_dir.join("lib.rs"),
1208 "#[derive(fncc::Props)]\npub struct BannerProps { pub title: String, pub subtitle: Option<String>, }\n",
1209 )
1210 .unwrap();
1211
1212 let out_file = dir.join("out.rs");
1213 generate_all_with_options(GenerateOptions {
1214 ui_dir: &ui_dir,
1215 out_file: &out_file,
1216 src_dir: Some(&src_dir),
1217 })
1218 .unwrap();
1219
1220 std::fs::remove_dir_all(&dir).unwrap();
1221 }
1222
1223 #[test]
1224 fn test_props_all_optional_none_provided_ok() {
1225 let (dir, ui_dir, src_dir) = test_dir_with_src();
1226
1227 std::fs::write(
1228 ui_dir.join("Msg.fui"),
1229 "---\nuse props::MsgProps;\n---\n<Text>{props.body}</Text>",
1230 )
1231 .unwrap();
1232
1233 std::fs::write(
1235 ui_dir.join("App.fui"),
1236 "---\nuse ui::Msg;\nuse props::MsgProps;\n---\n<Msg />",
1237 )
1238 .unwrap();
1239
1240 std::fs::write(
1241 src_dir.join("lib.rs"),
1242 "#[derive(fncc::Props)]\npub struct MsgProps { pub body: Option<String>, }\n",
1243 )
1244 .unwrap();
1245
1246 let out_file = dir.join("out.rs");
1247 generate_all_with_options(GenerateOptions {
1248 ui_dir: &ui_dir,
1249 out_file: &out_file,
1250 src_dir: Some(&src_dir),
1251 })
1252 .unwrap();
1253
1254 std::fs::remove_dir_all(&dir).unwrap();
1255 }
1256
1257 #[test]
1258 fn test_props_missing_required_with_optionals_present_errors() {
1259 let (dir, ui_dir, src_dir) = test_dir_with_src();
1260
1261 std::fs::write(
1262 ui_dir.join("Form.fui"),
1263 "---\nuse props::FormProps;\n---\n<Text>{props.name}</Text>",
1264 )
1265 .unwrap();
1266
1267 std::fs::write(
1269 ui_dir.join("App.fui"),
1270 "---\nuse ui::Form;\nuse props::FormProps;\n---\n<Form email=\"a@b.com\" />",
1271 )
1272 .unwrap();
1273
1274 std::fs::write(
1275 src_dir.join("lib.rs"),
1276 "#[derive(fncc::Props)]\npub struct FormProps { pub name: String, pub email: Option<String>, }\n",
1277 )
1278 .unwrap();
1279
1280 let out_file = dir.join("out.rs");
1281 let result = generate_all_with_options(GenerateOptions {
1282 ui_dir: &ui_dir,
1283 out_file: &out_file,
1284 src_dir: Some(&src_dir),
1285 });
1286
1287 assert!(result.is_err());
1288 let err = format!("{}", result.unwrap_err());
1289 assert!(err.contains("name"), "error should mention missing field name: {err}");
1290 std::fs::remove_dir_all(&dir).unwrap();
1291 }
1292
1293 #[test]
1294 fn test_props_grouped_imports() {
1295 let (dir, ui_dir, src_dir) = test_dir_with_src();
1296
1297 std::fs::create_dir_all(ui_dir.join("widgets")).unwrap();
1298 std::fs::write(
1299 ui_dir.join("widgets").join("Header.fui"),
1300 "---\nuse props::HeaderProps;\n---\n<Text>{props.title}</Text>",
1301 )
1302 .unwrap();
1303 std::fs::write(
1304 ui_dir.join("widgets").join("Footer.fui"),
1305 "---\nuse props::FooterProps;\n---\n<Text>{props.msg}</Text>",
1306 )
1307 .unwrap();
1308
1309 std::fs::write(
1310 ui_dir.join("App.fui"),
1311 "---\nuse ui::widgets::{Header, Footer};\nuse props::{HeaderProps, FooterProps};\n---\n<Stack><Header title=\"A\" /><Footer msg=\"B\" /></Stack>",
1312 )
1313 .unwrap();
1314
1315 std::fs::write(
1316 src_dir.join("lib.rs"),
1317 "#[derive(fncc::Props)]\nstruct HeaderProps { pub title: String, }\n\
1318 #[derive(fncc::Props)]\nstruct FooterProps { pub msg: String, }\n",
1319 )
1320 .unwrap();
1321
1322 let out_file = dir.join("out.rs");
1323 generate_all_with_options(GenerateOptions {
1324 ui_dir: &ui_dir,
1325 out_file: &out_file,
1326 src_dir: Some(&src_dir),
1327 })
1328 .unwrap();
1329
1330 let content = std::fs::read_to_string(&out_file).unwrap();
1331 assert!(content.contains("pub fn render_header(props: &HeaderProps)"));
1332 assert!(content.contains("pub fn render_footer(props: &FooterProps)"));
1333 assert!(content.contains("render_header(&HeaderProps {"));
1334 assert!(content.contains("title: \"A\".into(),"));
1335 assert!(content.contains("render_footer(&FooterProps {"));
1336 assert!(content.contains("msg: \"B\".into(),"));
1337 std::fs::remove_dir_all(&dir).unwrap();
1338 }
1339
1340 #[test]
1341 fn test_props_nested_component_receiving_props() {
1342 let (dir, ui_dir, src_dir) = test_dir_with_src();
1343
1344 std::fs::write(
1345 ui_dir.join("Header.fui"),
1346 "---\nuse props::HeaderProps;\n---\n<Text>{props.title}</Text>",
1347 )
1348 .unwrap();
1349
1350 std::fs::write(
1352 ui_dir.join("App.fui"),
1353 "---\nuse ui::Header;\nuse props::HeaderProps;\n---\n<Stack><Header title=\"Nest\" /><Text>plain</Text></Stack>",
1354 )
1355 .unwrap();
1356
1357 std::fs::write(
1358 src_dir.join("lib.rs"),
1359 "#[derive(fncc::Props)]\nstruct HeaderProps { pub title: String, }\n",
1360 )
1361 .unwrap();
1362
1363 let out_file = dir.join("out.rs");
1364 generate_all_with_options(GenerateOptions {
1365 ui_dir: &ui_dir,
1366 out_file: &out_file,
1367 src_dir: Some(&src_dir),
1368 })
1369 .unwrap();
1370
1371 let content = std::fs::read_to_string(&out_file).unwrap();
1372 assert!(content.contains("render_header(&HeaderProps {"));
1373 assert!(content.contains("title: \"Nest\".into(),"));
1374 std::fs::remove_dir_all(&dir).unwrap();
1375 }
1376
1377 #[test]
1378 fn test_props_mixed_components_with_and_without_props() {
1379 let (dir, ui_dir, src_dir) = test_dir_with_src();
1380
1381 std::fs::write(
1383 ui_dir.join("Header.fui"),
1384 "---\nuse props::HeaderProps;\n---\n<Text>{props.title}</Text>",
1385 )
1386 .unwrap();
1387
1388 std::fs::write(ui_dir.join("Footer.fui"), "<Text>static footer</Text>").unwrap();
1390
1391 std::fs::write(
1393 ui_dir.join("App.fui"),
1394 "---\nuse ui::{Header, Footer};\nuse props::HeaderProps;\n---\n<Stack><Header title=\"X\" /><Footer /></Stack>",
1395 )
1396 .unwrap();
1397
1398 std::fs::write(
1399 src_dir.join("lib.rs"),
1400 "#[derive(fncc::Props)]\nstruct HeaderProps { pub title: String, }\n",
1401 )
1402 .unwrap();
1403
1404 let out_file = dir.join("out.rs");
1405 generate_all_with_options(GenerateOptions {
1406 ui_dir: &ui_dir,
1407 out_file: &out_file,
1408 src_dir: Some(&src_dir),
1409 })
1410 .unwrap();
1411
1412 let content = std::fs::read_to_string(&out_file).unwrap();
1413 assert!(content.contains("render_header(&HeaderProps {"));
1414 assert!(content.contains("title: \"X\".into(),"));
1415 assert!(content.contains("render_footer()"));
1416 std::fs::remove_dir_all(&dir).unwrap();
1417 }
1418
1419 #[test]
1420 fn test_props_component_without_props_uses_direct_call() {
1421 let (dir, ui_dir, src_dir) = test_dir_with_src();
1422
1423 std::fs::write(ui_dir.join("Footer.fui"), "<Text>footer</Text>").unwrap();
1424
1425 std::fs::write(ui_dir.join("App.fui"), "---\nuse ui::Footer;\n---\n<Footer />").unwrap();
1426
1427 std::fs::write(src_dir.join("lib.rs"), "fn main() {}\n").unwrap();
1428
1429 let out_file = dir.join("out.rs");
1430 generate_all_with_options(GenerateOptions {
1431 ui_dir: &ui_dir,
1432 out_file: &out_file,
1433 src_dir: Some(&src_dir),
1434 })
1435 .unwrap();
1436
1437 let content = std::fs::read_to_string(&out_file).unwrap();
1438 assert!(content.contains("render_footer()"));
1439 assert!(!content.contains("&"));
1440 std::fs::remove_dir_all(&dir).unwrap();
1441 }
1442
1443 #[test]
1444 fn test_generate_font_loader() {
1445 let (dir, ui_dir) = test_dir();
1446 let fonts_dir = ui_dir.join("fonts");
1447 std::fs::create_dir_all(&fonts_dir).unwrap();
1448 std::fs::write(fonts_dir.join("Verdana.ttf"), b"fake font bytes").unwrap();
1449 std::fs::write(ui_dir.join("App.fui"), "<Text>hello</Text>").unwrap();
1450 std::fs::write(
1451 ui_dir.join("styles.fncss"),
1452 "@font-face { font-family: Verdana; src: url(\"./fonts/Verdana.ttf\"); }",
1453 )
1454 .unwrap();
1455 let out_file = dir.join("out.rs");
1456
1457 generate_all(&ui_dir, &out_file).unwrap();
1458
1459 let content = std::fs::read_to_string(&out_file).unwrap();
1460 assert!(
1461 content.contains("pub fn fncc_load_fonts(cx: &mut App)"),
1462 "missing fncc_load_fonts: {content}"
1463 );
1464 let file_name: String = ui_dir
1465 .join("./fonts/Verdana.ttf")
1466 .to_string_lossy()
1467 .chars()
1468 .map(|c| if c.is_ascii_alphanumeric() || c == '.' { c } else { '_' })
1469 .collect();
1470 assert!(content.contains(&format!(
1471 "include_bytes!(concat!(env!(\"OUT_DIR\"), \"/fncc_fonts/{file_name}\"))"
1472 )));
1473
1474 let copied = out_file.parent().unwrap().join("fncc_fonts").join(&file_name);
1475 assert!(copied.exists(), "font not copied to {:?}", copied);
1476 std::fs::remove_dir_all(&dir).unwrap();
1477 }
1478
1479 #[test]
1480 fn test_generate_font_loader_no_fonts() {
1481 let (dir, ui_dir) = test_dir();
1482 std::fs::write(ui_dir.join("App.fui"), "<Text>hello</Text>").unwrap();
1483 let out_file = dir.join("out.rs");
1484
1485 generate_all(&ui_dir, &out_file).unwrap();
1486
1487 let content = std::fs::read_to_string(&out_file).unwrap();
1488 assert!(!content.contains("fncc_load_fonts"));
1489 std::fs::remove_dir_all(&dir).unwrap();
1490 }
1491
1492 #[test]
1493 fn test_nfbr_full_end_to_end() {
1494 let (dir, ui_dir) = test_dir();
1495 let routes_dir = ui_dir.join("routes");
1496 std::fs::create_dir_all(&routes_dir).unwrap();
1497
1498 std::fs::write(
1500 routes_dir.join("layout.fui"),
1501 "<Stack><Text>Root Layout Header</Text><RouterOutlet /></Stack>",
1502 )
1503 .unwrap();
1504
1505 std::fs::write(routes_dir.join("index.fui"), "<Text>Home Screen</Text>").unwrap();
1507
1508 let dash_dir = routes_dir.join("dashboard");
1510 std::fs::create_dir_all(&dash_dir).unwrap();
1511 std::fs::write(
1512 dash_dir.join("layout.fui"),
1513 "<Stack><Text>Dashboard Sidebar</Text><RouterOutlet /></Stack>",
1514 )
1515 .unwrap();
1516 std::fs::write(dash_dir.join("index.fui"), "<Text>Dashboard Main</Text>").unwrap();
1517
1518 let users_dir = routes_dir.join("users");
1520 std::fs::create_dir_all(&users_dir).unwrap();
1521 std::fs::write(users_dir.join("[id].fui"), "<Text>{id}</Text>").unwrap();
1522
1523 let out_file = dir.join("generated.rs");
1524 generate_all(&ui_dir, &out_file).unwrap();
1525
1526 let code = std::fs::read_to_string(&out_file).unwrap();
1527
1528 assert!(code.contains("pub fn render_layout(children: impl IntoElement) -> impl IntoElement"));
1530 assert!(code.contains("pub fn render_dashboard_layout(children: impl IntoElement) -> impl IntoElement"));
1531
1532 assert!(code.contains("pub fn render_users_id(id: &str) -> impl IntoElement"));
1534
1535 assert!(code.contains("pub enum Route {"));
1537 assert!(code.contains("Index,"));
1538 assert!(code.contains("Dashboard,"));
1539 assert!(code.contains("UsersId {"));
1540
1541 assert!(code.contains(
1543 "Route::Dashboard => render_layout(render_dashboard_layout(render_dashboard())).into_any_element()"
1544 ));
1545 assert!(
1546 code.contains("Route::UsersId { id } => render_layout(render_users_id(id.as_str())).into_any_element()")
1547 );
1548
1549 std::fs::remove_dir_all(&dir).unwrap();
1550 }
1551}