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