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    // Build slot index: render_fn_name -> has_slot
127    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    // Validate imports: every FuiPath import must exist in the index
136    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    // Semantic analysis (optional): scan .rs files for #[fncc::command]
152    let semantic_db = if let Some(src_dir) = opts.src_dir {
153        let db = semantic::analyze_rs_files(src_dir)?;
154        // Extraction-level diagnostics (e.g. duplicate commands) are hard errors
155        if let Some(diag) = db.diagnostics.first() {
156            anyhow::bail!("{}", diag);
157        }
158        Some(db)
159    } else {
160        None
161    };
162
163    // Per-file state type resolution: @state or inferred from commands
164    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    // Pass 2: Generate code with resolved imports and props
213    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        // Resolve import props: for each FuiPath import, find the target's props type
233        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        // This component's own props type — only set if the template actually
248        // uses {props.xxx} interpolation (meaning it receives props, vs merely
249        // referencing a PropsType import for a child component).
250        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        // Validate props attributes against field definitions
264        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        // Stateful components cannot receive props
272        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        // Does this component itself have a slot? (computed once, used for both validation and codegen)
280        let has_slot = parser::has_slot(&pf.ast.root);
281
282        // Stateful components cannot have slots
283        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        // Resolve import_has_slots: for each FuiPath import, does the target have a slot?
291        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
325/// Validate that all attributes on imported components with props match their field definitions:
326/// - Unknown attributes cause a hard error
327/// - Missing required (non-Option) fields cause a hard error
328fn 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        // Function name uses file stem ("App"), not root element name
397        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        // Function name uses file stem ("App"), not root element name
413        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        // Header.fui — stateless component
424        std::fs::write(ui_dir.join("Header.fui"), "<Text size=\"xl\">Welcome</Text>").unwrap();
425        // App.fui — imports Header
426        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        // Header generates its own render function
437        assert!(content.contains("pub fn render_header()"));
438        // App uses the imported component
439        assert!(content.contains("render_header()"));
440        // No ui:: import leaks into generated Rust
441        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        // gpui imports are real Rust — preserved in frontmatter
506        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        // File in subdirectory
515        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        // File in root
518        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    // --- Semantic analysis tests ---
537
538    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        // .fui file without @state
553        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        // .rs file with Level 3 command
563        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        // Should generate impl Render for CounterState (inferred from command)
579        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        // No .rs file with #[fncc::command] nonexistent
595        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        // .fui with @state that conflicts with command signature
616        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        // Stateless component, no @state, no Level 3 commands
650        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        // Should be stateless — standalone render function
663        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        // Verify that legacy generate_all (no src_dir) still works unchanged
707        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    // --- Props integration tests ---
724
725    #[test]
726    fn test_props_component_with_props_generates_props_signature() {
727        let (dir, ui_dir, src_dir) = test_dir_with_src();
728
729        // Component .fui — declares its own props type
730        std::fs::write(
731            ui_dir.join("Header.fui"),
732            "---\nuse props::HeaderProps;\n---\n<Text>{props.title}</Text>",
733        )
734        .unwrap();
735
736        // .rs file with #[derive(Props)] struct
737        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        // Component that defines props
764        std::fs::write(
765            ui_dir.join("Header.fui"),
766            "---\nuse props::HeaderProps;\n---\n<Text>{props.title}</Text>",
767        )
768        .unwrap();
769
770        // App that imports and calls it with attribute values
771        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        // .rs file with props struct
778        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        // Option<String> field — .into() handles conversion at codegen level
851        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    // --- Props validation tests ---
871
872    #[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        // Missing required field "heading"
919        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        // subtitle is Option<String> — allowed to be absent
956        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        // All fields optional, none provided — should succeed
990        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        // name is required (String) and missing; email is Option<String> and present
1024        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        // App has Header nested inside a Stack, with both props and non-props children
1107        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        // Component with props
1138        std::fs::write(
1139            ui_dir.join("Header.fui"),
1140            "---\nuse props::HeaderProps;\n---\n<Text>{props.title}</Text>",
1141        )
1142        .unwrap();
1143
1144        // Component without props
1145        std::fs::write(ui_dir.join("Footer.fui"), "<Text>static footer</Text>").unwrap();
1146
1147        // App uses both
1148        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}