Skip to main content

fncc_core/
lib.rs

1pub mod codegen;
2pub mod config;
3pub mod parser;
4
5pub use parser::parse;
6
7use anyhow::{Context, Result};
8use std::collections::HashMap;
9use std::path::Path;
10
11/// Recursively collect all .fui files under `dir`.
12fn collect_fui_files(dir: &Path) -> Result<Vec<std::path::PathBuf>> {
13    let mut files = Vec::new();
14    if !dir.is_dir() {
15        return Ok(files);
16    }
17    for entry in std::fs::read_dir(dir).context("failed to read ui dir")? {
18        let entry = entry.context("failed to read entry")?;
19        let path = entry.path();
20        if path.is_dir() {
21            files.extend(collect_fui_files(&path)?);
22        } else if path.extension().is_some_and(|ext| ext == "fui") {
23            files.push(path);
24        }
25    }
26    Ok(files)
27}
28
29/// Structured parsed file for two-pass processing.
30struct ParsedFile {
31    path: std::path::PathBuf,
32    /// Path relative to ui_dir, forward-slash-separated, without extension.
33    /// e.g. "components/Header"
34    relative_stem: String,
35    ast: parser::Document,
36}
37
38/// Main entry point: parse all .fui files under `ui_dir` and write
39/// generated Rust code into `out_file`.
40pub fn generate_all(ui_dir: &Path, out_file: &Path) -> Result<()> {
41    let files = collect_fui_files(ui_dir)?;
42
43    // Pass 1: Parse all files
44    let mut parsed_files: Vec<ParsedFile> = Vec::new();
45    for path in &files {
46        let source = std::fs::read_to_string(path).with_context(|| format!("failed to read {:?}", path))?;
47        let ast = parser::parse(&source).map_err(|e| anyhow::anyhow!("failed to parse {:?}: {e}", path))?;
48
49        // Compute relative stem from ui_dir
50        let relative_stem = match path.strip_prefix(ui_dir) {
51            Ok(rel) => {
52                let stem = rel.file_stem().unwrap_or_default();
53                let parent = rel.parent().unwrap_or_else(|| std::path::Path::new(""));
54                parent.join(stem).to_string_lossy().to_string()
55            }
56            Err(_) => path.file_stem().unwrap_or_default().to_string_lossy().to_string(),
57        };
58        let relative_stem = relative_stem.replace(std::path::MAIN_SEPARATOR_STR, "/");
59
60        parsed_files.push(ParsedFile {
61            path: path.clone(),
62            relative_stem,
63            ast,
64        });
65    }
66
67    // Build import resolution index: "ui::path::Component" -> render function name
68    let mut import_index: HashMap<String, String> = HashMap::new();
69    for pf in &parsed_files {
70        let ui_path = format!("ui::{}", pf.relative_stem.replace('/', "::"));
71        let component_name = pf.relative_stem.split('/').next_back().unwrap_or("");
72        let render_fn = format!("render_{}", codegen::to_snake_case(component_name));
73        import_index.insert(ui_path, render_fn);
74    }
75
76    // Validate imports: every FuiPath import must exist in the index
77    for pf in &parsed_files {
78        for imp in &pf.ast.imports {
79            if let parser::ImportSource::FuiPath(ref ui_path) = imp.source
80                && !import_index.contains_key(ui_path)
81            {
82                anyhow::bail!(
83                    "in '{}': import `{}` -> file not found at `{}.fui`",
84                    pf.path.display(),
85                    ui_path,
86                    ui_path.strip_prefix("ui::").unwrap_or(ui_path)
87                );
88            }
89        }
90    }
91
92    // Pass 2: Generate code with resolved imports
93    let mut output = String::new();
94    output.push_str("// Generated by fncc-core. Do not edit.\n\n");
95
96    for (file_id, pf) in parsed_files.iter().enumerate() {
97        let resolved: Vec<(&str, &str)> = pf
98            .ast
99            .imports
100            .iter()
101            .map(|imp| {
102                let render_fn = match &imp.source {
103                    parser::ImportSource::FuiPath(ui_path) => {
104                        import_index.get(ui_path).map(|s| s.as_str()).unwrap_or("")
105                    }
106                    parser::ImportSource::Gpui => "",
107                };
108                (imp.name.as_str(), render_fn)
109            })
110            .collect();
111
112        let component_name = pf.relative_stem.split('/').next_back();
113        let generated = codegen::generate_with_imports(&pf.ast, file_id, &resolved, component_name);
114        output.push_str(&generated);
115        output.push('\n');
116    }
117
118    std::fs::write(out_file, &output).context("failed to write generated file")?;
119    Ok(())
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use std::path::PathBuf;
126    use std::sync::atomic::{AtomicUsize, Ordering};
127
128    static LIB_TEST_COUNTER: AtomicUsize = AtomicUsize::new(0);
129
130    fn test_dir() -> (PathBuf, PathBuf) {
131        let id = LIB_TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
132        let dir = std::env::temp_dir().join(format!("fncc_lib_test_{id}"));
133        let ui_dir = dir.join("ui");
134        std::fs::create_dir_all(&ui_dir).unwrap();
135        (dir, ui_dir)
136    }
137
138    #[test]
139    fn test_generate_all_creates_output_file() {
140        let (dir, ui_dir) = test_dir();
141        std::fs::write(ui_dir.join("App.fui"), "<Text>hello</Text>").unwrap();
142        let out_file = dir.join("out.rs");
143
144        generate_all(&ui_dir, &out_file).unwrap();
145
146        assert!(out_file.exists());
147        let content = std::fs::read_to_string(&out_file).unwrap();
148        assert!(content.contains("// Generated by fncc-core. Do not edit."));
149        // Function name uses file stem ("App"), not root element name
150        assert!(content.contains("pub fn render_app()"));
151        std::fs::remove_dir_all(&dir).unwrap();
152    }
153
154    #[test]
155    fn test_generate_all_ignores_non_fui_files() {
156        let (dir, ui_dir) = test_dir();
157        std::fs::write(ui_dir.join("App.fui"), "<A></A>").unwrap();
158        std::fs::write(ui_dir.join("notes.txt"), "not a fui").unwrap();
159        std::fs::write(ui_dir.join("main.rs"), "fn main() {}").unwrap();
160        let out_file = dir.join("out.rs");
161
162        generate_all(&ui_dir, &out_file).unwrap();
163
164        let content = std::fs::read_to_string(&out_file).unwrap();
165        // Function name uses file stem ("App"), not root element name
166        assert!(content.contains("pub fn render_app()"));
167        assert!(!content.contains("notes"));
168        assert!(!content.contains("main"));
169        std::fs::remove_dir_all(&dir).unwrap();
170    }
171
172    #[test]
173    fn test_generate_all_with_import() {
174        let (dir, ui_dir) = test_dir();
175
176        // Header.fui — stateless component
177        std::fs::write(ui_dir.join("Header.fui"), "<Text size=\"xl\">Welcome</Text>").unwrap();
178        // App.fui — imports Header
179        std::fs::write(
180            ui_dir.join("App.fui"),
181            "---\nuse ui::Header;\n---\n<Stack><Header /></Stack>",
182        )
183        .unwrap();
184
185        let out_file = dir.join("out.rs");
186        generate_all(&ui_dir, &out_file).unwrap();
187
188        let content = std::fs::read_to_string(&out_file).unwrap();
189        // Header generates its own render function
190        assert!(content.contains("pub fn render_header()"));
191        // App uses the imported component
192        assert!(content.contains("render_header()"));
193        // No ui:: import leaks into generated Rust
194        assert!(!content.contains("use ui::"));
195        std::fs::remove_dir_all(&dir).unwrap();
196    }
197
198    #[test]
199    fn test_generate_all_with_grouped_imports() {
200        let (dir, ui_dir) = test_dir();
201
202        std::fs::create_dir_all(ui_dir.join("components")).unwrap();
203        std::fs::write(
204            ui_dir.join("components").join("Button.fui"),
205            "<Button onclick=\"handle_click\">Click</Button>",
206        )
207        .unwrap();
208        std::fs::write(
209            ui_dir.join("components").join("Card.fui"),
210            "<Stack><Text>card</Text></Stack>",
211        )
212        .unwrap();
213        std::fs::write(
214            ui_dir.join("App.fui"),
215            "---\nuse ui::components::{Button, Card};\n---\n<Stack><Card /><Button /></Stack>",
216        )
217        .unwrap();
218
219        let out_file = dir.join("out.rs");
220        generate_all(&ui_dir, &out_file).unwrap();
221
222        let content = std::fs::read_to_string(&out_file).unwrap();
223        assert!(content.contains("pub fn render_button()"));
224        assert!(content.contains("pub fn render_card()"));
225        assert!(content.contains("render_card()"));
226        assert!(content.contains("render_button()"));
227        std::fs::remove_dir_all(&dir).unwrap();
228    }
229
230    #[test]
231    fn test_generate_all_import_missing_file_errors() {
232        let (dir, ui_dir) = test_dir();
233
234        std::fs::write(ui_dir.join("App.fui"), "---\nuse ui::Missing;\n---\n<Stack></Stack>").unwrap();
235
236        let out_file = dir.join("out.rs");
237        let result = generate_all(&ui_dir, &out_file);
238        assert!(result.is_err());
239        let err = format!("{}", result.unwrap_err());
240        assert!(err.contains("Missing") || err.contains("not found"));
241        std::fs::remove_dir_all(&dir).unwrap();
242    }
243
244    #[test]
245    fn test_generate_all_with_gpui_import() {
246        let (dir, ui_dir) = test_dir();
247
248        std::fs::write(
249            ui_dir.join("App.fui"),
250            "---\nuse gpui::TextInput;\n---\n<Stack></Stack>",
251        )
252        .unwrap();
253
254        let out_file = dir.join("out.rs");
255        generate_all(&ui_dir, &out_file).unwrap();
256
257        let content = std::fs::read_to_string(&out_file).unwrap();
258        // gpui imports are real Rust — preserved in frontmatter
259        assert!(content.contains("use gpui::TextInput;"));
260        std::fs::remove_dir_all(&dir).unwrap();
261    }
262
263    #[test]
264    fn test_generate_all_recursive_scan() {
265        let (dir, ui_dir) = test_dir();
266
267        // File in subdirectory
268        std::fs::create_dir_all(ui_dir.join("widgets")).unwrap();
269        std::fs::write(ui_dir.join("widgets").join("Footer.fui"), "<Text>footer</Text>").unwrap();
270        // File in root
271        std::fs::write(
272            ui_dir.join("App.fui"),
273            "---\nuse ui::widgets::Footer;\n---\n<Stack><Footer /></Stack>",
274        )
275        .unwrap();
276
277        let out_file = dir.join("out.rs");
278        generate_all(&ui_dir, &out_file).unwrap();
279
280        let content = std::fs::read_to_string(&out_file).unwrap();
281        assert!(
282            content.contains("pub fn render_footer()"),
283            "content missing render_footer fn:\n{content}"
284        );
285        assert!(content.contains("render_footer()"));
286        std::fs::remove_dir_all(&dir).unwrap();
287    }
288}