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