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
11fn 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
29struct ParsedFile {
31 path: std::path::PathBuf,
32 relative_stem: String,
35 ast: parser::Document,
36}
37
38pub fn generate_all(ui_dir: &Path, out_file: &Path) -> Result<()> {
41 let files = collect_fui_files(ui_dir)?;
42
43 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 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 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 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 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 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 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 std::fs::write(ui_dir.join("Header.fui"), "<Text size=\"xl\">Welcome</Text>").unwrap();
178 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 assert!(content.contains("pub fn render_header()"));
191 assert!(content.contains("render_header()"));
193 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 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 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 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}