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;
11
12fn collect_fui_files(dir: &Path) -> Result<Vec<std::path::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
30struct ParsedFile {
32 path: std::path::PathBuf,
33 relative_stem: String,
36 ast: parser::Document,
37}
38
39pub struct GenerateOptions<'a> {
43 pub ui_dir: &'a Path,
44 pub out_file: &'a Path,
45 pub src_dir: Option<&'a Path>,
48}
49
50impl<'a> GenerateOptions<'a> {
51 pub fn new(ui_dir: &'a Path, out_file: &'a Path) -> Self {
52 GenerateOptions {
53 ui_dir,
54 out_file,
55 src_dir: None,
56 }
57 }
58}
59
60pub fn generate_all(ui_dir: &Path, out_file: &Path) -> Result<()> {
63 generate_all_with_options(GenerateOptions::new(ui_dir, out_file))
64}
65
66pub fn generate_all_with_options(opts: GenerateOptions) -> Result<()> {
69 let ui_dir = opts.ui_dir;
70 let out_file = opts.out_file;
71 let files = collect_fui_files(ui_dir)?;
72
73 let mut parsed_files: Vec<ParsedFile> = Vec::new();
75 for path in &files {
76 let source = std::fs::read_to_string(path).with_context(|| format!("failed to read {:?}", path))?;
77 let ast = parser::parse(&source).map_err(|e| anyhow::anyhow!("failed to parse {:?}: {e}", path))?;
78
79 let relative_stem = match path.strip_prefix(ui_dir) {
81 Ok(rel) => {
82 let stem = rel.file_stem().unwrap_or_default();
83 let parent = rel.parent().unwrap_or_else(|| std::path::Path::new(""));
84 parent.join(stem).to_string_lossy().to_string()
85 }
86 Err(_) => path.file_stem().unwrap_or_default().to_string_lossy().to_string(),
87 };
88 let relative_stem = relative_stem.replace(std::path::MAIN_SEPARATOR_STR, "/");
89
90 parsed_files.push(ParsedFile {
91 path: path.clone(),
92 relative_stem,
93 ast,
94 });
95 }
96
97 let mut import_index: HashMap<String, String> = HashMap::new();
99 for pf in &parsed_files {
100 let ui_path = format!("ui::{}", pf.relative_stem.replace('/', "::"));
101 let component_name = pf.relative_stem.split('/').next_back().unwrap_or("");
102 let render_fn = format!("render_{}", codegen::to_snake_case(component_name));
103 import_index.insert(ui_path, render_fn);
104 }
105
106 let mut render_fn_to_props: HashMap<String, Option<String>> = HashMap::new();
109 for pf in &parsed_files {
110 let component_name = pf.relative_stem.split('/').next_back().unwrap_or("");
111 let render_fn = format!("render_{}", codegen::to_snake_case(component_name));
112 let props_type = parser::uses_props_interpolation(&pf.ast.root)
113 .then(|| {
114 pf.ast.imports.iter().find_map(|imp| {
115 if matches!(imp.source, parser::ImportSource::PropsType) {
116 Some(imp.name.clone())
117 } else {
118 None
119 }
120 })
121 })
122 .flatten();
123 render_fn_to_props.insert(render_fn, props_type);
124 }
125
126 for pf in &parsed_files {
128 for imp in &pf.ast.imports {
129 if let parser::ImportSource::FuiPath(ref ui_path) = imp.source
130 && !import_index.contains_key(ui_path)
131 {
132 anyhow::bail!(
133 "in '{}': import `{}` -> file not found at `{}.fui`",
134 pf.path.display(),
135 ui_path,
136 ui_path.strip_prefix("ui::").unwrap_or(ui_path)
137 );
138 }
139 }
140 }
141
142 let semantic_db = if let Some(src_dir) = opts.src_dir {
144 let db = semantic::analyze_rs_files(src_dir)?;
145 if let Some(diag) = db.diagnostics.first() {
147 anyhow::bail!("{}", diag);
148 }
149 Some(db)
150 } else {
151 None
152 };
153
154 let mut state_types: HashMap<usize, Option<String>> = HashMap::new();
156 if let Some(ref db) = semantic_db {
157 for (file_id, pf) in parsed_files.iter().enumerate() {
158 let command_refs = parser::collect_commands(&pf.ast.root);
159
160 let mut inferred_types: Vec<&str> = Vec::new();
161 for cmd_name in &command_refs {
162 let cmd = db.commands.get(cmd_name).ok_or_else(|| {
163 anyhow::anyhow!(
164 "in '{}': onclick=\"{}\" references #[fncc::command] fn {}() which was not found in any Rust source file",
165 pf.path.display(), cmd_name, cmd_name,
166 )
167 })?;
168 if let Some(ref st) = cmd.state_type
169 && !inferred_types.contains(&st.as_str())
170 {
171 inferred_types.push(st);
172 }
173 }
174
175 let resolved = match (pf.ast.state_type.as_ref(), inferred_types.as_slice()) {
176 (Some(declared), []) => Some(declared.clone()),
177 (Some(declared), [inferred]) if declared.as_str() == *inferred => Some(declared.clone()),
178 (Some(declared), [inferred]) => anyhow::bail!(
179 "in '{}': @state {} conflicts with inferred state type {} from command signatures — remove @state or align the types",
180 pf.path.display(),
181 declared,
182 inferred,
183 ),
184 (Some(d), _) => anyhow::bail!(
185 "in '{}': commands reference multiple state types ({}) but @state {} was declared",
186 pf.path.display(),
187 inferred_types.join(", "),
188 d,
189 ),
190 (None, []) => None,
191 (None, [inferred]) => Some((*inferred).to_string()),
192 (None, _) => anyhow::bail!(
193 "in '{}': commands reference multiple state types ({}) — a component can only have one",
194 pf.path.display(),
195 inferred_types.join(", "),
196 ),
197 };
198
199 state_types.insert(file_id, resolved);
200 }
201 }
202
203 let mut output = String::new();
205 output.push_str("// Generated by fncc-core. Do not edit.\n\n");
206
207 for (file_id, pf) in parsed_files.iter().enumerate() {
208 let resolved: Vec<(&str, &str)> = pf
209 .ast
210 .imports
211 .iter()
212 .map(|imp| {
213 let render_fn = match &imp.source {
214 parser::ImportSource::FuiPath(ui_path) => {
215 import_index.get(ui_path).map(|s| s.as_str()).unwrap_or("")
216 }
217 parser::ImportSource::Gpui | parser::ImportSource::PropsType => "",
218 };
219 (imp.name.as_str(), render_fn)
220 })
221 .collect();
222
223 let import_props: Vec<(&str, Option<&str>)> = pf
225 .ast
226 .imports
227 .iter()
228 .filter_map(|imp| {
229 let render_fn = match &imp.source {
230 parser::ImportSource::FuiPath(ui_path) => import_index.get(ui_path),
231 _ => None,
232 }?;
233 let props = render_fn_to_props.get(render_fn).and_then(|p| p.as_deref());
234 Some((imp.name.as_str(), props))
235 })
236 .collect();
237
238 let uses_props = parser::uses_props_interpolation(&pf.ast.root);
242 let own_props_type = uses_props
243 .then(|| {
244 pf.ast.imports.iter().find_map(|imp| {
245 if matches!(imp.source, parser::ImportSource::PropsType) {
246 Some(imp.name.as_str())
247 } else {
248 None
249 }
250 })
251 })
252 .flatten();
253
254 if let Some(ref db) = semantic_db {
256 validate_props_usage(&pf.path, &pf.ast.root, &import_props, &db.props_types)?;
257 }
258
259 let component_name = pf.relative_stem.split('/').next_back();
260 let resolved_state = state_types.get(&file_id).and_then(|s| s.as_deref());
261
262 if resolved_state.is_some() && own_props_type.is_some() {
264 anyhow::bail!(
265 "in '{}': component cannot have both state and props — props are only supported on stateless components",
266 pf.path.display(),
267 );
268 }
269
270 let prop_fields = semantic_db.as_ref().map(|db| &db.props_types);
271 let generated = codegen::generate_with_imports(
272 &pf.ast,
273 file_id,
274 &resolved,
275 component_name,
276 resolved_state,
277 own_props_type,
278 &import_props,
279 prop_fields,
280 );
281 output.push_str(&generated);
282 output.push('\n');
283 }
284
285 std::fs::write(out_file, &output).context("failed to write generated file")?;
286 Ok(())
287}
288
289fn validate_props_usage(
293 file_path: &Path,
294 el: &parser::Element,
295 import_props: &[(&str, Option<&str>)],
296 props_types: &HashMap<String, Vec<semantic::PropField>>,
297) -> Result<()> {
298 if let Some((_, Some(props_type_name))) = import_props.iter().find(|(name, _)| name == &el.name)
299 && let Some(fields) = props_types.get(*props_type_name)
300 {
301 for (attr_name, _) in &el.attrs {
302 if !fields.iter().any(|f| f.name == *attr_name) {
303 let available: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
304 anyhow::bail!(
305 "in '{}': component `{}` has no prop `{}` — available props: {}",
306 file_path.display(),
307 el.name,
308 attr_name,
309 available.join(", "),
310 );
311 }
312 }
313 for field in fields {
314 if !field.is_optional && !el.attrs.iter().any(|(name, _)| name == &field.name) {
315 anyhow::bail!(
316 "in '{}': component `{}` requires prop `{}` (type {})",
317 file_path.display(),
318 el.name,
319 field.name,
320 field.type_expr,
321 );
322 }
323 }
324 }
325 for child in &el.children {
326 if let parser::Node::Element(child_el) = child {
327 validate_props_usage(file_path, child_el, import_props, props_types)?;
328 }
329 }
330 Ok(())
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use std::path::PathBuf;
337 use std::sync::atomic::{AtomicUsize, Ordering};
338
339 static LIB_TEST_COUNTER: AtomicUsize = AtomicUsize::new(0);
340
341 fn test_dir() -> (PathBuf, PathBuf) {
342 let id = LIB_TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
343 let dir = std::env::temp_dir().join(format!("fncc_lib_test_{id}"));
344 let ui_dir = dir.join("ui");
345 std::fs::create_dir_all(&ui_dir).unwrap();
346 (dir, ui_dir)
347 }
348
349 #[test]
350 fn test_generate_all_creates_output_file() {
351 let (dir, ui_dir) = test_dir();
352 std::fs::write(ui_dir.join("App.fui"), "<Text>hello</Text>").unwrap();
353 let out_file = dir.join("out.rs");
354
355 generate_all(&ui_dir, &out_file).unwrap();
356
357 assert!(out_file.exists());
358 let content = std::fs::read_to_string(&out_file).unwrap();
359 assert!(content.contains("// Generated by fncc-core. Do not edit."));
360 assert!(content.contains("pub fn render_app()"));
362 std::fs::remove_dir_all(&dir).unwrap();
363 }
364
365 #[test]
366 fn test_generate_all_ignores_non_fui_files() {
367 let (dir, ui_dir) = test_dir();
368 std::fs::write(ui_dir.join("App.fui"), "<A></A>").unwrap();
369 std::fs::write(ui_dir.join("notes.txt"), "not a fui").unwrap();
370 std::fs::write(ui_dir.join("main.rs"), "fn main() {}").unwrap();
371 let out_file = dir.join("out.rs");
372
373 generate_all(&ui_dir, &out_file).unwrap();
374
375 let content = std::fs::read_to_string(&out_file).unwrap();
376 assert!(content.contains("pub fn render_app()"));
378 assert!(!content.contains("notes"));
379 assert!(!content.contains("main"));
380 std::fs::remove_dir_all(&dir).unwrap();
381 }
382
383 #[test]
384 fn test_generate_all_with_import() {
385 let (dir, ui_dir) = test_dir();
386
387 std::fs::write(ui_dir.join("Header.fui"), "<Text size=\"xl\">Welcome</Text>").unwrap();
389 std::fs::write(
391 ui_dir.join("App.fui"),
392 "---\nuse ui::Header;\n---\n<Stack><Header /></Stack>",
393 )
394 .unwrap();
395
396 let out_file = dir.join("out.rs");
397 generate_all(&ui_dir, &out_file).unwrap();
398
399 let content = std::fs::read_to_string(&out_file).unwrap();
400 assert!(content.contains("pub fn render_header()"));
402 assert!(content.contains("render_header()"));
404 assert!(!content.contains("use ui::"));
406 std::fs::remove_dir_all(&dir).unwrap();
407 }
408
409 #[test]
410 fn test_generate_all_with_grouped_imports() {
411 let (dir, ui_dir) = test_dir();
412
413 std::fs::create_dir_all(ui_dir.join("components")).unwrap();
414 std::fs::write(
415 ui_dir.join("components").join("Button.fui"),
416 "<Button onclick=\"handle_click\">Click</Button>",
417 )
418 .unwrap();
419 std::fs::write(
420 ui_dir.join("components").join("Card.fui"),
421 "<Stack><Text>card</Text></Stack>",
422 )
423 .unwrap();
424 std::fs::write(
425 ui_dir.join("App.fui"),
426 "---\nuse ui::components::{Button, Card};\n---\n<Stack><Card /><Button /></Stack>",
427 )
428 .unwrap();
429
430 let out_file = dir.join("out.rs");
431 generate_all(&ui_dir, &out_file).unwrap();
432
433 let content = std::fs::read_to_string(&out_file).unwrap();
434 assert!(content.contains("pub fn render_button()"));
435 assert!(content.contains("pub fn render_card()"));
436 assert!(content.contains("render_card()"));
437 assert!(content.contains("render_button()"));
438 std::fs::remove_dir_all(&dir).unwrap();
439 }
440
441 #[test]
442 fn test_generate_all_import_missing_file_errors() {
443 let (dir, ui_dir) = test_dir();
444
445 std::fs::write(ui_dir.join("App.fui"), "---\nuse ui::Missing;\n---\n<Stack></Stack>").unwrap();
446
447 let out_file = dir.join("out.rs");
448 let result = generate_all(&ui_dir, &out_file);
449 assert!(result.is_err());
450 let err = format!("{}", result.unwrap_err());
451 assert!(err.contains("Missing") || err.contains("not found"));
452 std::fs::remove_dir_all(&dir).unwrap();
453 }
454
455 #[test]
456 fn test_generate_all_with_gpui_import() {
457 let (dir, ui_dir) = test_dir();
458
459 std::fs::write(
460 ui_dir.join("App.fui"),
461 "---\nuse gpui::TextInput;\n---\n<Stack></Stack>",
462 )
463 .unwrap();
464
465 let out_file = dir.join("out.rs");
466 generate_all(&ui_dir, &out_file).unwrap();
467
468 let content = std::fs::read_to_string(&out_file).unwrap();
469 assert!(content.contains("use gpui::TextInput;"));
471 std::fs::remove_dir_all(&dir).unwrap();
472 }
473
474 #[test]
475 fn test_generate_all_recursive_scan() {
476 let (dir, ui_dir) = test_dir();
477
478 std::fs::create_dir_all(ui_dir.join("widgets")).unwrap();
480 std::fs::write(ui_dir.join("widgets").join("Footer.fui"), "<Text>footer</Text>").unwrap();
481 std::fs::write(
483 ui_dir.join("App.fui"),
484 "---\nuse ui::widgets::Footer;\n---\n<Stack><Footer /></Stack>",
485 )
486 .unwrap();
487
488 let out_file = dir.join("out.rs");
489 generate_all(&ui_dir, &out_file).unwrap();
490
491 let content = std::fs::read_to_string(&out_file).unwrap();
492 assert!(
493 content.contains("pub fn render_footer()"),
494 "content missing render_footer fn:\n{content}"
495 );
496 assert!(content.contains("render_footer()"));
497 std::fs::remove_dir_all(&dir).unwrap();
498 }
499
500 fn test_dir_with_src() -> (PathBuf, PathBuf, PathBuf) {
503 let id = LIB_TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
504 let dir = std::env::temp_dir().join(format!("fncc_sem_lib_test_{id}"));
505 let ui_dir = dir.join("ui");
506 let src_dir = dir.join("src");
507 std::fs::create_dir_all(&ui_dir).unwrap();
508 std::fs::create_dir_all(&src_dir).unwrap();
509 (dir, ui_dir, src_dir)
510 }
511
512 #[test]
513 fn test_generate_all_with_options_infers_state_from_rs() {
514 let (dir, ui_dir, src_dir) = test_dir_with_src();
515
516 std::fs::write(
518 ui_dir.join("App.fui"),
519 r#"<Stack direction="vertical" gap="12">
520 <Text>Count: {state.count}</Text>
521 <Button onclick="inc">+1</Button>
522</Stack>"#,
523 )
524 .unwrap();
525
526 std::fs::write(
528 src_dir.join("main.rs"),
529 "#[fncc::command]\nfn inc(state: &mut CounterState, cx: &mut Context<CounterState>) { state.count += 1; cx.notify(); }\n",
530 )
531 .unwrap();
532
533 let out_file = dir.join("out.rs");
534 generate_all_with_options(GenerateOptions {
535 ui_dir: &ui_dir,
536 out_file: &out_file,
537 src_dir: Some(&src_dir),
538 })
539 .unwrap();
540
541 let content = std::fs::read_to_string(&out_file).unwrap();
542 assert!(content.contains("impl Render for CounterState {"));
544 assert!(content.contains("__fncc_cmd_inc"));
545 std::fs::remove_dir_all(&dir).unwrap();
546 }
547
548 #[test]
549 fn test_generate_all_with_options_hard_error_on_missing_command() {
550 let (dir, ui_dir, src_dir) = test_dir_with_src();
551
552 std::fs::write(
553 ui_dir.join("App.fui"),
554 r#"<Button onclick="nonexistent">Click</Button>"#,
555 )
556 .unwrap();
557
558 std::fs::write(src_dir.join("main.rs"), "fn main() {}\n").unwrap();
560
561 let out_file = dir.join("out.rs");
562 let result = generate_all_with_options(GenerateOptions {
563 ui_dir: &ui_dir,
564 out_file: &out_file,
565 src_dir: Some(&src_dir),
566 });
567
568 assert!(result.is_err());
569 let err = format!("{}", result.unwrap_err());
570 assert!(err.contains("nonexistent"), "error should mention command name: {err}");
571 assert!(err.contains("not found"), "error should say not found: {err}");
572 std::fs::remove_dir_all(&dir).unwrap();
573 }
574
575 #[test]
576 fn test_generate_all_with_options_state_conflict_errors() {
577 let (dir, ui_dir, src_dir) = test_dir_with_src();
578
579 std::fs::write(
581 ui_dir.join("App.fui"),
582 "---\n@state OldState\n---\n<Button onclick=\"upd\">Update</Button>",
583 )
584 .unwrap();
585
586 std::fs::write(
587 src_dir.join("main.rs"),
588 "#[fncc::command]\nfn upd(state: &mut NewState, cx: &mut Context<NewState>) {}\n",
589 )
590 .unwrap();
591
592 let out_file = dir.join("out.rs");
593 let result = generate_all_with_options(GenerateOptions {
594 ui_dir: &ui_dir,
595 out_file: &out_file,
596 src_dir: Some(&src_dir),
597 });
598
599 assert!(result.is_err());
600 let err = format!("{}", result.unwrap_err());
601 assert!(
602 err.contains("@state OldState"),
603 "error should mention @state declaration: {err}"
604 );
605 assert!(err.contains("NewState"), "error should mention inferred type: {err}");
606 std::fs::remove_dir_all(&dir).unwrap();
607 }
608
609 #[test]
610 fn test_generate_all_with_options_stateless_remains_stateless() {
611 let (dir, ui_dir, src_dir) = test_dir_with_src();
612
613 std::fs::write(ui_dir.join("App.fui"), "<Text>hello</Text>").unwrap();
615 std::fs::write(src_dir.join("main.rs"), "fn main() {}\n").unwrap();
616
617 let out_file = dir.join("out.rs");
618 generate_all_with_options(GenerateOptions {
619 ui_dir: &ui_dir,
620 out_file: &out_file,
621 src_dir: Some(&src_dir),
622 })
623 .unwrap();
624
625 let content = std::fs::read_to_string(&out_file).unwrap();
626 assert!(content.contains("pub fn render_app()"));
628 assert!(!content.contains("impl Render for"));
629 std::fs::remove_dir_all(&dir).unwrap();
630 }
631
632 #[test]
633 fn test_generate_all_with_options_state_mismatch_across_commands() {
634 let (dir, ui_dir, src_dir) = test_dir_with_src();
635
636 std::fs::write(
637 ui_dir.join("App.fui"),
638 r#"<Stack>
639 <Button onclick="cmd_a">A</Button>
640 <Button onclick="cmd_b">B</Button>
641</Stack>"#,
642 )
643 .unwrap();
644
645 std::fs::write(
646 src_dir.join("main.rs"),
647 "#[fncc::command]\nfn cmd_a(s: &mut TypeA, cx: &mut Context<TypeA>) {}\n\
648 #[fncc::command]\nfn cmd_b(s: &mut TypeB, cx: &mut Context<TypeB>) {}\n",
649 )
650 .unwrap();
651
652 let out_file = dir.join("out.rs");
653 let result = generate_all_with_options(GenerateOptions {
654 ui_dir: &ui_dir,
655 out_file: &out_file,
656 src_dir: Some(&src_dir),
657 });
658
659 assert!(result.is_err());
660 let err = format!("{}", result.unwrap_err());
661 assert!(
662 err.contains("multiple state types"),
663 "error should mention multiple types: {err}"
664 );
665 std::fs::remove_dir_all(&dir).unwrap();
666 }
667
668 #[test]
669 fn test_generate_all_legacy_still_works_without_src_dir() {
670 let (dir, ui_dir) = test_dir();
672
673 std::fs::write(
674 ui_dir.join("App.fui"),
675 "---\n@state CounterState\n---\n<Button onclick=\"inc\">+1</Button>",
676 )
677 .unwrap();
678
679 let out_file = dir.join("out.rs");
680 generate_all(&ui_dir, &out_file).unwrap();
681
682 let content = std::fs::read_to_string(&out_file).unwrap();
683 assert!(content.contains("impl Render for CounterState {"));
684 std::fs::remove_dir_all(&dir).unwrap();
685 }
686
687 #[test]
690 fn test_props_component_with_props_generates_props_signature() {
691 let (dir, ui_dir, src_dir) = test_dir_with_src();
692
693 std::fs::write(
695 ui_dir.join("Header.fui"),
696 "---\nuse props::HeaderProps;\n---\n<Text>{props.title}</Text>",
697 )
698 .unwrap();
699
700 std::fs::write(
702 src_dir.join("lib.rs"),
703 "#[derive(fncc::Props)]\nstruct HeaderProps {\n pub title: String,\n}\n",
704 )
705 .unwrap();
706
707 let out_file = dir.join("out.rs");
708 generate_all_with_options(GenerateOptions {
709 ui_dir: &ui_dir,
710 out_file: &out_file,
711 src_dir: Some(&src_dir),
712 })
713 .unwrap();
714
715 let content = std::fs::read_to_string(&out_file).unwrap();
716 assert!(
717 content.contains("pub fn render_header(props: &HeaderProps)"),
718 "expected props signature, got:\n{content}"
719 );
720 std::fs::remove_dir_all(&dir).unwrap();
721 }
722
723 #[test]
724 fn test_props_caller_constructs_props_struct() {
725 let (dir, ui_dir, src_dir) = test_dir_with_src();
726
727 std::fs::write(
729 ui_dir.join("Header.fui"),
730 "---\nuse props::HeaderProps;\n---\n<Text>{props.title}</Text>",
731 )
732 .unwrap();
733
734 std::fs::write(
736 ui_dir.join("App.fui"),
737 "---\nuse ui::Header;\nuse props::HeaderProps;\n---\n<Stack><Header title=\"Hello\" /></Stack>",
738 )
739 .unwrap();
740
741 std::fs::write(
743 src_dir.join("lib.rs"),
744 "#[derive(fncc::Props)]\nstruct HeaderProps {\n pub title: String,\n}\n",
745 )
746 .unwrap();
747
748 let out_file = dir.join("out.rs");
749 generate_all_with_options(GenerateOptions {
750 ui_dir: &ui_dir,
751 out_file: &out_file,
752 src_dir: Some(&src_dir),
753 })
754 .unwrap();
755
756 let content = std::fs::read_to_string(&out_file).unwrap();
757 assert!(content.contains("render_header(&HeaderProps {"));
758 assert!(content.contains("title: \"Hello\".into(),"));
759 std::fs::remove_dir_all(&dir).unwrap();
760 }
761
762 #[test]
763 fn test_props_multiple_fields() {
764 let (dir, ui_dir, src_dir) = test_dir_with_src();
765
766 std::fs::write(
767 ui_dir.join("Banner.fui"),
768 "---\nuse props::BannerProps;\n---\n<Text>{props.heading}{props.sub}</Text>",
769 )
770 .unwrap();
771
772 std::fs::write(
773 ui_dir.join("App.fui"),
774 "---\nuse ui::Banner;\nuse props::BannerProps;\n---\n<Banner heading=\"Hi\" sub=\"World\" />",
775 )
776 .unwrap();
777
778 std::fs::write(
779 src_dir.join("lib.rs"),
780 "#[derive(fncc::Props)]\nstruct BannerProps {\n pub heading: String,\n pub sub: String,\n}\n",
781 )
782 .unwrap();
783
784 let out_file = dir.join("out.rs");
785 generate_all_with_options(GenerateOptions {
786 ui_dir: &ui_dir,
787 out_file: &out_file,
788 src_dir: Some(&src_dir),
789 })
790 .unwrap();
791
792 let content = std::fs::read_to_string(&out_file).unwrap();
793 assert!(content.contains("heading: \"Hi\".into(),"));
794 assert!(content.contains("sub: \"World\".into(),"));
795 std::fs::remove_dir_all(&dir).unwrap();
796 }
797
798 #[test]
799 fn test_props_option_field() {
800 let (dir, ui_dir, src_dir) = test_dir_with_src();
801
802 std::fs::write(
803 ui_dir.join("Card.fui"),
804 "---\nuse props::CardProps;\n---\n<Text>{props.title}</Text>",
805 )
806 .unwrap();
807
808 std::fs::write(
809 ui_dir.join("App.fui"),
810 "---\nuse ui::Card;\nuse props::CardProps;\n---\n<Card title=\"Optional\" />",
811 )
812 .unwrap();
813
814 std::fs::write(
816 src_dir.join("lib.rs"),
817 "#[derive(fncc::Props)]\nstruct CardProps {\n pub title: Option<String>,\n}\n",
818 )
819 .unwrap();
820
821 let out_file = dir.join("out.rs");
822 generate_all_with_options(GenerateOptions {
823 ui_dir: &ui_dir,
824 out_file: &out_file,
825 src_dir: Some(&src_dir),
826 })
827 .unwrap();
828
829 let content = std::fs::read_to_string(&out_file).unwrap();
830 assert!(content.contains("title: \"Optional\".into(),"));
831 std::fs::remove_dir_all(&dir).unwrap();
832 }
833
834 #[test]
837 fn test_props_unknown_attribute_errors() {
838 let (dir, ui_dir, src_dir) = test_dir_with_src();
839
840 std::fs::write(
841 ui_dir.join("Widget.fui"),
842 "---\nuse props::WidgetProps;\n---\n<Text>{props.title}</Text>",
843 )
844 .unwrap();
845
846 std::fs::write(
847 ui_dir.join("App.fui"),
848 "---\nuse ui::Widget;\nuse props::WidgetProps;\n---\n<Widget title=\"Hi\" unknown=\"val\" />",
849 )
850 .unwrap();
851
852 std::fs::write(
853 src_dir.join("lib.rs"),
854 "#[derive(fncc::Props)]\npub struct WidgetProps { pub title: String, }\n",
855 )
856 .unwrap();
857
858 let out_file = dir.join("out.rs");
859 let result = generate_all_with_options(GenerateOptions {
860 ui_dir: &ui_dir,
861 out_file: &out_file,
862 src_dir: Some(&src_dir),
863 });
864
865 assert!(result.is_err());
866 let err = format!("{}", result.unwrap_err());
867 assert!(err.contains("unknown"), "error should mention unknown prop: {err}");
868 assert!(err.contains("unknown"), "error should contain attribute name: {err}");
869 std::fs::remove_dir_all(&dir).unwrap();
870 }
871
872 #[test]
873 fn test_props_missing_required_field_errors() {
874 let (dir, ui_dir, src_dir) = test_dir_with_src();
875
876 std::fs::write(
877 ui_dir.join("Card.fui"),
878 "---\nuse props::CardProps;\n---\n<Text>{props.heading}</Text>",
879 )
880 .unwrap();
881
882 std::fs::write(
884 ui_dir.join("App.fui"),
885 "---\nuse ui::Card;\nuse props::CardProps;\n---\n<Card />",
886 )
887 .unwrap();
888
889 std::fs::write(
890 src_dir.join("lib.rs"),
891 "#[derive(fncc::Props)]\npub struct CardProps { pub heading: String, }\n",
892 )
893 .unwrap();
894
895 let out_file = dir.join("out.rs");
896 let result = generate_all_with_options(GenerateOptions {
897 ui_dir: &ui_dir,
898 out_file: &out_file,
899 src_dir: Some(&src_dir),
900 });
901
902 assert!(result.is_err());
903 let err = format!("{}", result.unwrap_err());
904 assert!(err.contains("requires"), "error should mention requires: {err}");
905 assert!(err.contains("heading"), "error should mention field name: {err}");
906 std::fs::remove_dir_all(&dir).unwrap();
907 }
908
909 #[test]
910 fn test_props_optional_field_allows_absence() {
911 let (dir, ui_dir, src_dir) = test_dir_with_src();
912
913 std::fs::write(
914 ui_dir.join("Banner.fui"),
915 "---\nuse props::BannerProps;\n---\n<Text>{props.title}</Text>",
916 )
917 .unwrap();
918
919 std::fs::write(
921 ui_dir.join("App.fui"),
922 "---\nuse ui::Banner;\nuse props::BannerProps;\n---\n<Banner title=\"Hi\" />",
923 )
924 .unwrap();
925
926 std::fs::write(
927 src_dir.join("lib.rs"),
928 "#[derive(fncc::Props)]\npub struct BannerProps { pub title: String, pub subtitle: Option<String>, }\n",
929 )
930 .unwrap();
931
932 let out_file = dir.join("out.rs");
933 generate_all_with_options(GenerateOptions {
934 ui_dir: &ui_dir,
935 out_file: &out_file,
936 src_dir: Some(&src_dir),
937 })
938 .unwrap();
939
940 std::fs::remove_dir_all(&dir).unwrap();
941 }
942
943 #[test]
944 fn test_props_all_optional_none_provided_ok() {
945 let (dir, ui_dir, src_dir) = test_dir_with_src();
946
947 std::fs::write(
948 ui_dir.join("Msg.fui"),
949 "---\nuse props::MsgProps;\n---\n<Text>{props.body}</Text>",
950 )
951 .unwrap();
952
953 std::fs::write(
955 ui_dir.join("App.fui"),
956 "---\nuse ui::Msg;\nuse props::MsgProps;\n---\n<Msg />",
957 )
958 .unwrap();
959
960 std::fs::write(
961 src_dir.join("lib.rs"),
962 "#[derive(fncc::Props)]\npub struct MsgProps { pub body: Option<String>, }\n",
963 )
964 .unwrap();
965
966 let out_file = dir.join("out.rs");
967 generate_all_with_options(GenerateOptions {
968 ui_dir: &ui_dir,
969 out_file: &out_file,
970 src_dir: Some(&src_dir),
971 })
972 .unwrap();
973
974 std::fs::remove_dir_all(&dir).unwrap();
975 }
976
977 #[test]
978 fn test_props_missing_required_with_optionals_present_errors() {
979 let (dir, ui_dir, src_dir) = test_dir_with_src();
980
981 std::fs::write(
982 ui_dir.join("Form.fui"),
983 "---\nuse props::FormProps;\n---\n<Text>{props.name}</Text>",
984 )
985 .unwrap();
986
987 std::fs::write(
989 ui_dir.join("App.fui"),
990 "---\nuse ui::Form;\nuse props::FormProps;\n---\n<Form email=\"a@b.com\" />",
991 )
992 .unwrap();
993
994 std::fs::write(
995 src_dir.join("lib.rs"),
996 "#[derive(fncc::Props)]\npub struct FormProps { pub name: String, pub email: Option<String>, }\n",
997 )
998 .unwrap();
999
1000 let out_file = dir.join("out.rs");
1001 let result = generate_all_with_options(GenerateOptions {
1002 ui_dir: &ui_dir,
1003 out_file: &out_file,
1004 src_dir: Some(&src_dir),
1005 });
1006
1007 assert!(result.is_err());
1008 let err = format!("{}", result.unwrap_err());
1009 assert!(err.contains("name"), "error should mention missing field name: {err}");
1010 std::fs::remove_dir_all(&dir).unwrap();
1011 }
1012
1013 #[test]
1014 fn test_props_grouped_imports() {
1015 let (dir, ui_dir, src_dir) = test_dir_with_src();
1016
1017 std::fs::create_dir_all(ui_dir.join("widgets")).unwrap();
1018 std::fs::write(
1019 ui_dir.join("widgets").join("Header.fui"),
1020 "---\nuse props::HeaderProps;\n---\n<Text>{props.title}</Text>",
1021 )
1022 .unwrap();
1023 std::fs::write(
1024 ui_dir.join("widgets").join("Footer.fui"),
1025 "---\nuse props::FooterProps;\n---\n<Text>{props.msg}</Text>",
1026 )
1027 .unwrap();
1028
1029 std::fs::write(
1030 ui_dir.join("App.fui"),
1031 "---\nuse ui::widgets::{Header, Footer};\nuse props::{HeaderProps, FooterProps};\n---\n<Stack><Header title=\"A\" /><Footer msg=\"B\" /></Stack>",
1032 )
1033 .unwrap();
1034
1035 std::fs::write(
1036 src_dir.join("lib.rs"),
1037 "#[derive(fncc::Props)]\nstruct HeaderProps { pub title: String, }\n\
1038 #[derive(fncc::Props)]\nstruct FooterProps { pub msg: String, }\n",
1039 )
1040 .unwrap();
1041
1042 let out_file = dir.join("out.rs");
1043 generate_all_with_options(GenerateOptions {
1044 ui_dir: &ui_dir,
1045 out_file: &out_file,
1046 src_dir: Some(&src_dir),
1047 })
1048 .unwrap();
1049
1050 let content = std::fs::read_to_string(&out_file).unwrap();
1051 assert!(content.contains("pub fn render_header(props: &HeaderProps)"));
1052 assert!(content.contains("pub fn render_footer(props: &FooterProps)"));
1053 assert!(content.contains("render_header(&HeaderProps {"));
1054 assert!(content.contains("title: \"A\".into(),"));
1055 assert!(content.contains("render_footer(&FooterProps {"));
1056 assert!(content.contains("msg: \"B\".into(),"));
1057 std::fs::remove_dir_all(&dir).unwrap();
1058 }
1059
1060 #[test]
1061 fn test_props_nested_component_receiving_props() {
1062 let (dir, ui_dir, src_dir) = test_dir_with_src();
1063
1064 std::fs::write(
1065 ui_dir.join("Header.fui"),
1066 "---\nuse props::HeaderProps;\n---\n<Text>{props.title}</Text>",
1067 )
1068 .unwrap();
1069
1070 std::fs::write(
1072 ui_dir.join("App.fui"),
1073 "---\nuse ui::Header;\nuse props::HeaderProps;\n---\n<Stack><Header title=\"Nest\" /><Text>plain</Text></Stack>",
1074 )
1075 .unwrap();
1076
1077 std::fs::write(
1078 src_dir.join("lib.rs"),
1079 "#[derive(fncc::Props)]\nstruct HeaderProps { pub title: String, }\n",
1080 )
1081 .unwrap();
1082
1083 let out_file = dir.join("out.rs");
1084 generate_all_with_options(GenerateOptions {
1085 ui_dir: &ui_dir,
1086 out_file: &out_file,
1087 src_dir: Some(&src_dir),
1088 })
1089 .unwrap();
1090
1091 let content = std::fs::read_to_string(&out_file).unwrap();
1092 assert!(content.contains("render_header(&HeaderProps {"));
1093 assert!(content.contains("title: \"Nest\".into(),"));
1094 std::fs::remove_dir_all(&dir).unwrap();
1095 }
1096
1097 #[test]
1098 fn test_props_mixed_components_with_and_without_props() {
1099 let (dir, ui_dir, src_dir) = test_dir_with_src();
1100
1101 std::fs::write(
1103 ui_dir.join("Header.fui"),
1104 "---\nuse props::HeaderProps;\n---\n<Text>{props.title}</Text>",
1105 )
1106 .unwrap();
1107
1108 std::fs::write(ui_dir.join("Footer.fui"), "<Text>static footer</Text>").unwrap();
1110
1111 std::fs::write(
1113 ui_dir.join("App.fui"),
1114 "---\nuse ui::{Header, Footer};\nuse props::HeaderProps;\n---\n<Stack><Header title=\"X\" /><Footer /></Stack>",
1115 )
1116 .unwrap();
1117
1118 std::fs::write(
1119 src_dir.join("lib.rs"),
1120 "#[derive(fncc::Props)]\nstruct HeaderProps { pub title: String, }\n",
1121 )
1122 .unwrap();
1123
1124 let out_file = dir.join("out.rs");
1125 generate_all_with_options(GenerateOptions {
1126 ui_dir: &ui_dir,
1127 out_file: &out_file,
1128 src_dir: Some(&src_dir),
1129 })
1130 .unwrap();
1131
1132 let content = std::fs::read_to_string(&out_file).unwrap();
1133 assert!(content.contains("render_header(&HeaderProps {"));
1134 assert!(content.contains("title: \"X\".into(),"));
1135 assert!(content.contains("render_footer()"));
1136 std::fs::remove_dir_all(&dir).unwrap();
1137 }
1138
1139 #[test]
1140 fn test_props_component_without_props_uses_direct_call() {
1141 let (dir, ui_dir, src_dir) = test_dir_with_src();
1142
1143 std::fs::write(ui_dir.join("Footer.fui"), "<Text>footer</Text>").unwrap();
1144
1145 std::fs::write(ui_dir.join("App.fui"), "---\nuse ui::Footer;\n---\n<Footer />").unwrap();
1146
1147 std::fs::write(src_dir.join("lib.rs"), "fn main() {}\n").unwrap();
1148
1149 let out_file = dir.join("out.rs");
1150 generate_all_with_options(GenerateOptions {
1151 ui_dir: &ui_dir,
1152 out_file: &out_file,
1153 src_dir: Some(&src_dir),
1154 })
1155 .unwrap();
1156
1157 let content = std::fs::read_to_string(&out_file).unwrap();
1158 assert!(content.contains("render_footer()"));
1159 assert!(!content.contains("&"));
1160 std::fs::remove_dir_all(&dir).unwrap();
1161 }
1162}