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