Skip to main content

fncc_core/
lib.rs

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
12/// Recursively collect all .fui files under `dir`.
13fn 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
30/// Structured parsed file for two-pass processing.
31struct ParsedFile {
32    path: std::path::PathBuf,
33    /// Path relative to ui_dir, forward-slash-separated, without extension.
34    /// e.g. "components/Header"
35    relative_stem: String,
36    ast: parser::Document,
37}
38
39/// Configuration for the fncc compilation pipeline.
40/// Use `GenerateOptions::new(ui_dir, out_file)` for default (legacy) behavior,
41/// or set `src_dir` to enable semantic analysis (state inference, command validation).
42pub struct GenerateOptions<'a> {
43    pub ui_dir: &'a Path,
44    pub out_file: &'a Path,
45    /// If `Some`, enables semantic analysis (scans `.rs` files for `#[fncc::command]`).
46    /// Expected to point to the Rust source directory (e.g. `"src/"`).
47    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
60/// Legacy entry point: parse all .fui files under `ui_dir` and write
61/// generated Rust code into `out_file`. No semantic analysis.
62pub fn generate_all(ui_dir: &Path, out_file: &Path) -> Result<()> {
63    generate_all_with_options(GenerateOptions::new(ui_dir, out_file))
64}
65
66/// Full entry point: parse `.fui` files and optionally run semantic analysis
67/// (state inference, command validation) when `opts.src_dir` is `Some`.
68pub 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    // Pass 1: Parse all files
74    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        // Compute relative stem from ui_dir
80        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    // Build import resolution index: "ui::path::Component" -> render function name
98    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    // Build props type index: render_fn_name -> props type name (e.g. "HeaderProps")
107    // Only set when the component's template actually uses {props.xxx} interpolation.
108    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    // Validate imports: every FuiPath import must exist in the index
127    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    // Semantic analysis (optional): scan .rs files for #[fncc::command]
143    let semantic_db = if let Some(src_dir) = opts.src_dir {
144        let db = semantic::analyze_rs_files(src_dir)?;
145        // Extraction-level diagnostics (e.g. duplicate commands) are hard errors
146        if let Some(diag) = db.diagnostics.first() {
147            anyhow::bail!("{}", diag);
148        }
149        Some(db)
150    } else {
151        None
152    };
153
154    // Per-file state type resolution: @state or inferred from commands
155    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    // Pass 2: Generate code with resolved imports and props
204    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        // Resolve import props: for each FuiPath import, find the target's props type
224        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        // This component's own props type — only set if the template actually
239        // uses {props.xxx} interpolation (meaning it receives props, vs merely
240        // referencing a PropsType import for a child component).
241        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        // Validate props attributes against field definitions
255        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        // Stateful components cannot receive props
263        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
289/// Validate that all attributes on imported components with props match their field definitions:
290/// - Unknown attributes cause a hard error
291/// - Missing required (non-Option) fields cause a hard error
292fn 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        // Function name uses file stem ("App"), not root element name
361        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        // Function name uses file stem ("App"), not root element name
377        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        // Header.fui — stateless component
388        std::fs::write(ui_dir.join("Header.fui"), "<Text size=\"xl\">Welcome</Text>").unwrap();
389        // App.fui — imports Header
390        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        // Header generates its own render function
401        assert!(content.contains("pub fn render_header()"));
402        // App uses the imported component
403        assert!(content.contains("render_header()"));
404        // No ui:: import leaks into generated Rust
405        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        // gpui imports are real Rust — preserved in frontmatter
470        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        // File in subdirectory
479        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        // File in root
482        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    // --- Semantic analysis tests ---
501
502    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        // .fui file without @state
517        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        // .rs file with Level 3 command
527        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        // Should generate impl Render for CounterState (inferred from command)
543        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        // No .rs file with #[fncc::command] nonexistent
559        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        // .fui with @state that conflicts with command signature
580        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        // Stateless component, no @state, no Level 3 commands
614        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        // Should be stateless — standalone render function
627        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        // Verify that legacy generate_all (no src_dir) still works unchanged
671        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    // --- Props integration tests ---
688
689    #[test]
690    fn test_props_component_with_props_generates_props_signature() {
691        let (dir, ui_dir, src_dir) = test_dir_with_src();
692
693        // Component .fui — declares its own props type
694        std::fs::write(
695            ui_dir.join("Header.fui"),
696            "---\nuse props::HeaderProps;\n---\n<Text>{props.title}</Text>",
697        )
698        .unwrap();
699
700        // .rs file with #[derive(Props)] struct
701        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        // Component that defines props
728        std::fs::write(
729            ui_dir.join("Header.fui"),
730            "---\nuse props::HeaderProps;\n---\n<Text>{props.title}</Text>",
731        )
732        .unwrap();
733
734        // App that imports and calls it with attribute values
735        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        // .rs file with props struct
742        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        // Option<String> field — .into() handles conversion at codegen level
815        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    // --- Props validation tests ---
835
836    #[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        // Missing required field "heading"
883        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        // subtitle is Option<String> — allowed to be absent
920        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        // All fields optional, none provided — should succeed
954        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        // name is required (String) and missing; email is Option<String> and present
988        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        // App has Header nested inside a Stack, with both props and non-props children
1071        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        // Component with props
1102        std::fs::write(
1103            ui_dir.join("Header.fui"),
1104            "---\nuse props::HeaderProps;\n---\n<Text>{props.title}</Text>",
1105        )
1106        .unwrap();
1107
1108        // Component without props
1109        std::fs::write(ui_dir.join("Footer.fui"), "<Text>static footer</Text>").unwrap();
1110
1111        // App uses both
1112        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}