fncc-core 0.3.0

Parser and codegen for the fncc native UI framework — compiles .fui files to GPUI Rust code
Documentation
pub mod codegen;
pub mod config;
pub mod parser;

pub use parser::parse;

use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::Path;

/// Recursively collect all .fui files under `dir`.
fn collect_fui_files(dir: &Path) -> Result<Vec<std::path::PathBuf>> {
    let mut files = Vec::new();
    if !dir.is_dir() {
        return Ok(files);
    }
    for entry in std::fs::read_dir(dir).context("failed to read ui dir")? {
        let entry = entry.context("failed to read entry")?;
        let path = entry.path();
        if path.is_dir() {
            files.extend(collect_fui_files(&path)?);
        } else if path.extension().is_some_and(|ext| ext == "fui") {
            files.push(path);
        }
    }
    Ok(files)
}

/// Structured parsed file for two-pass processing.
struct ParsedFile {
    path: std::path::PathBuf,
    /// Path relative to ui_dir, forward-slash-separated, without extension.
    /// e.g. "components/Header"
    relative_stem: String,
    ast: parser::Document,
}

/// Main entry point: parse all .fui files under `ui_dir` and write
/// generated Rust code into `out_file`.
pub fn generate_all(ui_dir: &Path, out_file: &Path) -> Result<()> {
    let files = collect_fui_files(ui_dir)?;

    // Pass 1: Parse all files
    let mut parsed_files: Vec<ParsedFile> = Vec::new();
    for path in &files {
        let source = std::fs::read_to_string(path).with_context(|| format!("failed to read {:?}", path))?;
        let ast = parser::parse(&source).map_err(|e| anyhow::anyhow!("failed to parse {:?}: {e}", path))?;

        // Compute relative stem from ui_dir
        let relative_stem = match path.strip_prefix(ui_dir) {
            Ok(rel) => {
                let stem = rel.file_stem().unwrap_or_default();
                let parent = rel.parent().unwrap_or_else(|| std::path::Path::new(""));
                parent.join(stem).to_string_lossy().to_string()
            }
            Err(_) => path.file_stem().unwrap_or_default().to_string_lossy().to_string(),
        };
        let relative_stem = relative_stem.replace(std::path::MAIN_SEPARATOR_STR, "/");

        parsed_files.push(ParsedFile {
            path: path.clone(),
            relative_stem,
            ast,
        });
    }

    // Build import resolution index: "ui::path::Component" -> render function name
    let mut import_index: HashMap<String, String> = HashMap::new();
    for pf in &parsed_files {
        let ui_path = format!("ui::{}", pf.relative_stem.replace('/', "::"));
        let component_name = pf.relative_stem.split('/').next_back().unwrap_or("");
        let render_fn = format!("render_{}", codegen::to_snake_case(component_name));
        import_index.insert(ui_path, render_fn);
    }

    // Validate imports: every FuiPath import must exist in the index
    for pf in &parsed_files {
        for imp in &pf.ast.imports {
            if let parser::ImportSource::FuiPath(ref ui_path) = imp.source
                && !import_index.contains_key(ui_path)
            {
                anyhow::bail!(
                    "in '{}': import `{}` -> file not found at `{}.fui`",
                    pf.path.display(),
                    ui_path,
                    ui_path.strip_prefix("ui::").unwrap_or(ui_path)
                );
            }
        }
    }

    // Pass 2: Generate code with resolved imports
    let mut output = String::new();
    output.push_str("// Generated by fncc-core. Do not edit.\n\n");

    for (file_id, pf) in parsed_files.iter().enumerate() {
        let resolved: Vec<(&str, &str)> = pf
            .ast
            .imports
            .iter()
            .map(|imp| {
                let render_fn = match &imp.source {
                    parser::ImportSource::FuiPath(ui_path) => {
                        import_index.get(ui_path).map(|s| s.as_str()).unwrap_or("")
                    }
                    parser::ImportSource::Gpui => "",
                };
                (imp.name.as_str(), render_fn)
            })
            .collect();

        let component_name = pf.relative_stem.split('/').next_back();
        let generated = codegen::generate_with_imports(&pf.ast, file_id, &resolved, component_name);
        output.push_str(&generated);
        output.push('\n');
    }

    std::fs::write(out_file, &output).context("failed to write generated file")?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;
    use std::sync::atomic::{AtomicUsize, Ordering};

    static LIB_TEST_COUNTER: AtomicUsize = AtomicUsize::new(0);

    fn test_dir() -> (PathBuf, PathBuf) {
        let id = LIB_TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
        let dir = std::env::temp_dir().join(format!("fncc_lib_test_{id}"));
        let ui_dir = dir.join("ui");
        std::fs::create_dir_all(&ui_dir).unwrap();
        (dir, ui_dir)
    }

    #[test]
    fn test_generate_all_creates_output_file() {
        let (dir, ui_dir) = test_dir();
        std::fs::write(ui_dir.join("App.fui"), "<Text>hello</Text>").unwrap();
        let out_file = dir.join("out.rs");

        generate_all(&ui_dir, &out_file).unwrap();

        assert!(out_file.exists());
        let content = std::fs::read_to_string(&out_file).unwrap();
        assert!(content.contains("// Generated by fncc-core. Do not edit."));
        // Function name uses file stem ("App"), not root element name
        assert!(content.contains("pub fn render_app()"));
        std::fs::remove_dir_all(&dir).unwrap();
    }

    #[test]
    fn test_generate_all_ignores_non_fui_files() {
        let (dir, ui_dir) = test_dir();
        std::fs::write(ui_dir.join("App.fui"), "<A></A>").unwrap();
        std::fs::write(ui_dir.join("notes.txt"), "not a fui").unwrap();
        std::fs::write(ui_dir.join("main.rs"), "fn main() {}").unwrap();
        let out_file = dir.join("out.rs");

        generate_all(&ui_dir, &out_file).unwrap();

        let content = std::fs::read_to_string(&out_file).unwrap();
        // Function name uses file stem ("App"), not root element name
        assert!(content.contains("pub fn render_app()"));
        assert!(!content.contains("notes"));
        assert!(!content.contains("main"));
        std::fs::remove_dir_all(&dir).unwrap();
    }

    #[test]
    fn test_generate_all_with_import() {
        let (dir, ui_dir) = test_dir();

        // Header.fui — stateless component
        std::fs::write(ui_dir.join("Header.fui"), "<Text size=\"xl\">Welcome</Text>").unwrap();
        // App.fui — imports Header
        std::fs::write(
            ui_dir.join("App.fui"),
            "---\nuse ui::Header;\n---\n<Stack><Header /></Stack>",
        )
        .unwrap();

        let out_file = dir.join("out.rs");
        generate_all(&ui_dir, &out_file).unwrap();

        let content = std::fs::read_to_string(&out_file).unwrap();
        // Header generates its own render function
        assert!(content.contains("pub fn render_header()"));
        // App uses the imported component
        assert!(content.contains("render_header()"));
        // No ui:: import leaks into generated Rust
        assert!(!content.contains("use ui::"));
        std::fs::remove_dir_all(&dir).unwrap();
    }

    #[test]
    fn test_generate_all_with_grouped_imports() {
        let (dir, ui_dir) = test_dir();

        std::fs::create_dir_all(ui_dir.join("components")).unwrap();
        std::fs::write(
            ui_dir.join("components").join("Button.fui"),
            "<Button onclick=\"handle_click\">Click</Button>",
        )
        .unwrap();
        std::fs::write(
            ui_dir.join("components").join("Card.fui"),
            "<Stack><Text>card</Text></Stack>",
        )
        .unwrap();
        std::fs::write(
            ui_dir.join("App.fui"),
            "---\nuse ui::components::{Button, Card};\n---\n<Stack><Card /><Button /></Stack>",
        )
        .unwrap();

        let out_file = dir.join("out.rs");
        generate_all(&ui_dir, &out_file).unwrap();

        let content = std::fs::read_to_string(&out_file).unwrap();
        assert!(content.contains("pub fn render_button()"));
        assert!(content.contains("pub fn render_card()"));
        assert!(content.contains("render_card()"));
        assert!(content.contains("render_button()"));
        std::fs::remove_dir_all(&dir).unwrap();
    }

    #[test]
    fn test_generate_all_import_missing_file_errors() {
        let (dir, ui_dir) = test_dir();

        std::fs::write(ui_dir.join("App.fui"), "---\nuse ui::Missing;\n---\n<Stack></Stack>").unwrap();

        let out_file = dir.join("out.rs");
        let result = generate_all(&ui_dir, &out_file);
        assert!(result.is_err());
        let err = format!("{}", result.unwrap_err());
        assert!(err.contains("Missing") || err.contains("not found"));
        std::fs::remove_dir_all(&dir).unwrap();
    }

    #[test]
    fn test_generate_all_with_gpui_import() {
        let (dir, ui_dir) = test_dir();

        std::fs::write(
            ui_dir.join("App.fui"),
            "---\nuse gpui::TextInput;\n---\n<Stack></Stack>",
        )
        .unwrap();

        let out_file = dir.join("out.rs");
        generate_all(&ui_dir, &out_file).unwrap();

        let content = std::fs::read_to_string(&out_file).unwrap();
        // gpui imports are real Rust — preserved in frontmatter
        assert!(content.contains("use gpui::TextInput;"));
        std::fs::remove_dir_all(&dir).unwrap();
    }

    #[test]
    fn test_generate_all_recursive_scan() {
        let (dir, ui_dir) = test_dir();

        // File in subdirectory
        std::fs::create_dir_all(ui_dir.join("widgets")).unwrap();
        std::fs::write(ui_dir.join("widgets").join("Footer.fui"), "<Text>footer</Text>").unwrap();
        // File in root
        std::fs::write(
            ui_dir.join("App.fui"),
            "---\nuse ui::widgets::Footer;\n---\n<Stack><Footer /></Stack>",
        )
        .unwrap();

        let out_file = dir.join("out.rs");
        generate_all(&ui_dir, &out_file).unwrap();

        let content = std::fs::read_to_string(&out_file).unwrap();
        assert!(
            content.contains("pub fn render_footer()"),
            "content missing render_footer fn:\n{content}"
        );
        assert!(content.contains("render_footer()"));
        std::fs::remove_dir_all(&dir).unwrap();
    }
}