Skip to main content

fncc_core/
lib.rs

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