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;
11
12fn collect_fui_files(dir: &Path) -> Result<Vec<std::path::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
30struct ParsedFile {
32 path: std::path::PathBuf,
33 relative_stem: String,
36 ast: parser::Document,
37}
38
39pub struct GenerateOptions<'a> {
43 pub ui_dir: &'a Path,
44 pub out_file: &'a Path,
45 pub src_dir: Option<&'a Path>,
48}
49
50impl<'a> GenerateOptions<'a> {
51 pub fn new(ui_dir: &'a Path, out_file: &'a Path) -> Self {
52 GenerateOptions {
53 ui_dir,
54 out_file,
55 src_dir: None,
56 }
57 }
58}
59
60pub fn generate_all(ui_dir: &Path, out_file: &Path) -> Result<()> {
63 generate_all_with_options(GenerateOptions::new(ui_dir, out_file))
64}
65
66pub fn generate_all_with_options(opts: GenerateOptions) -> Result<()> {
69 let ui_dir = opts.ui_dir;
70 let out_file = opts.out_file;
71 let files = collect_fui_files(ui_dir)?;
72
73 let mut parsed_files: Vec<ParsedFile> = Vec::new();
75 for path in &files {
76 let source = std::fs::read_to_string(path).with_context(|| format!("failed to read {:?}", path))?;
77 let ast = parser::parse(&source).map_err(|e| anyhow::anyhow!("failed to parse {:?}: {e}", path))?;
78
79 let relative_stem = match path.strip_prefix(ui_dir) {
81 Ok(rel) => {
82 let stem = rel.file_stem().unwrap_or_default();
83 let parent = rel.parent().unwrap_or_else(|| std::path::Path::new(""));
84 parent.join(stem).to_string_lossy().to_string()
85 }
86 Err(_) => path.file_stem().unwrap_or_default().to_string_lossy().to_string(),
87 };
88 let relative_stem = relative_stem.replace(std::path::MAIN_SEPARATOR_STR, "/");
89
90 parsed_files.push(ParsedFile {
91 path: path.clone(),
92 relative_stem,
93 ast,
94 });
95 }
96
97 let mut import_index: HashMap<String, String> = HashMap::new();
99 for pf in &parsed_files {
100 let ui_path = format!("ui::{}", pf.relative_stem.replace('/', "::"));
101 let component_name = pf.relative_stem.split('/').next_back().unwrap_or("");
102 let render_fn = format!("render_{}", codegen::to_snake_case(component_name));
103 import_index.insert(ui_path, render_fn);
104 }
105
106 for pf in &parsed_files {
108 for imp in &pf.ast.imports {
109 if let parser::ImportSource::FuiPath(ref ui_path) = imp.source
110 && !import_index.contains_key(ui_path)
111 {
112 anyhow::bail!(
113 "in '{}': import `{}` -> file not found at `{}.fui`",
114 pf.path.display(),
115 ui_path,
116 ui_path.strip_prefix("ui::").unwrap_or(ui_path)
117 );
118 }
119 }
120 }
121
122 let semantic_db = if let Some(src_dir) = opts.src_dir {
124 let db = semantic::analyze_rs_files(src_dir)?;
125 if let Some(diag) = db.diagnostics.first() {
127 anyhow::bail!("{}", diag);
128 }
129 Some(db)
130 } else {
131 None
132 };
133
134 let mut state_types: HashMap<usize, Option<String>> = HashMap::new();
136 if let Some(ref db) = semantic_db {
137 for (file_id, pf) in parsed_files.iter().enumerate() {
138 let command_refs = parser::collect_commands(&pf.ast.root);
139
140 let mut inferred_types: Vec<&str> = Vec::new();
141 for cmd_name in &command_refs {
142 let cmd = db.commands.get(cmd_name).ok_or_else(|| {
143 anyhow::anyhow!(
144 "in '{}': onclick=\"{}\" references #[fncc::command] fn {}() which was not found in any Rust source file",
145 pf.path.display(), cmd_name, cmd_name,
146 )
147 })?;
148 if let Some(ref st) = cmd.state_type
149 && !inferred_types.contains(&st.as_str())
150 {
151 inferred_types.push(st);
152 }
153 }
154
155 let resolved = match (pf.ast.state_type.as_ref(), inferred_types.as_slice()) {
156 (Some(declared), []) => Some(declared.clone()),
157 (Some(declared), [inferred]) if declared.as_str() == *inferred => Some(declared.clone()),
158 (Some(declared), [inferred]) => anyhow::bail!(
159 "in '{}': @state {} conflicts with inferred state type {} from command signatures — remove @state or align the types",
160 pf.path.display(),
161 declared,
162 inferred,
163 ),
164 (Some(d), _) => anyhow::bail!(
165 "in '{}': commands reference multiple state types ({}) but @state {} was declared",
166 pf.path.display(),
167 inferred_types.join(", "),
168 d,
169 ),
170 (None, []) => None,
171 (None, [inferred]) => Some((*inferred).to_string()),
172 (None, _) => anyhow::bail!(
173 "in '{}': commands reference multiple state types ({}) — a component can only have one",
174 pf.path.display(),
175 inferred_types.join(", "),
176 ),
177 };
178
179 state_types.insert(file_id, resolved);
180 }
181 }
182
183 let mut output = String::new();
185 output.push_str("// Generated by fncc-core. Do not edit.\n\n");
186
187 for (file_id, pf) in parsed_files.iter().enumerate() {
188 let resolved: Vec<(&str, &str)> = pf
189 .ast
190 .imports
191 .iter()
192 .map(|imp| {
193 let render_fn = match &imp.source {
194 parser::ImportSource::FuiPath(ui_path) => {
195 import_index.get(ui_path).map(|s| s.as_str()).unwrap_or("")
196 }
197 parser::ImportSource::Gpui => "",
198 };
199 (imp.name.as_str(), render_fn)
200 })
201 .collect();
202
203 let component_name = pf.relative_stem.split('/').next_back();
204 let resolved_state = state_types.get(&file_id).and_then(|s| s.as_deref());
205 let generated = codegen::generate_with_imports(&pf.ast, file_id, &resolved, component_name, resolved_state);
206 output.push_str(&generated);
207 output.push('\n');
208 }
209
210 std::fs::write(out_file, &output).context("failed to write generated file")?;
211 Ok(())
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use std::path::PathBuf;
218 use std::sync::atomic::{AtomicUsize, Ordering};
219
220 static LIB_TEST_COUNTER: AtomicUsize = AtomicUsize::new(0);
221
222 fn test_dir() -> (PathBuf, PathBuf) {
223 let id = LIB_TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
224 let dir = std::env::temp_dir().join(format!("fncc_lib_test_{id}"));
225 let ui_dir = dir.join("ui");
226 std::fs::create_dir_all(&ui_dir).unwrap();
227 (dir, ui_dir)
228 }
229
230 #[test]
231 fn test_generate_all_creates_output_file() {
232 let (dir, ui_dir) = test_dir();
233 std::fs::write(ui_dir.join("App.fui"), "<Text>hello</Text>").unwrap();
234 let out_file = dir.join("out.rs");
235
236 generate_all(&ui_dir, &out_file).unwrap();
237
238 assert!(out_file.exists());
239 let content = std::fs::read_to_string(&out_file).unwrap();
240 assert!(content.contains("// Generated by fncc-core. Do not edit."));
241 assert!(content.contains("pub fn render_app()"));
243 std::fs::remove_dir_all(&dir).unwrap();
244 }
245
246 #[test]
247 fn test_generate_all_ignores_non_fui_files() {
248 let (dir, ui_dir) = test_dir();
249 std::fs::write(ui_dir.join("App.fui"), "<A></A>").unwrap();
250 std::fs::write(ui_dir.join("notes.txt"), "not a fui").unwrap();
251 std::fs::write(ui_dir.join("main.rs"), "fn main() {}").unwrap();
252 let out_file = dir.join("out.rs");
253
254 generate_all(&ui_dir, &out_file).unwrap();
255
256 let content = std::fs::read_to_string(&out_file).unwrap();
257 assert!(content.contains("pub fn render_app()"));
259 assert!(!content.contains("notes"));
260 assert!(!content.contains("main"));
261 std::fs::remove_dir_all(&dir).unwrap();
262 }
263
264 #[test]
265 fn test_generate_all_with_import() {
266 let (dir, ui_dir) = test_dir();
267
268 std::fs::write(ui_dir.join("Header.fui"), "<Text size=\"xl\">Welcome</Text>").unwrap();
270 std::fs::write(
272 ui_dir.join("App.fui"),
273 "---\nuse ui::Header;\n---\n<Stack><Header /></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!(content.contains("pub fn render_header()"));
283 assert!(content.contains("render_header()"));
285 assert!(!content.contains("use ui::"));
287 std::fs::remove_dir_all(&dir).unwrap();
288 }
289
290 #[test]
291 fn test_generate_all_with_grouped_imports() {
292 let (dir, ui_dir) = test_dir();
293
294 std::fs::create_dir_all(ui_dir.join("components")).unwrap();
295 std::fs::write(
296 ui_dir.join("components").join("Button.fui"),
297 "<Button onclick=\"handle_click\">Click</Button>",
298 )
299 .unwrap();
300 std::fs::write(
301 ui_dir.join("components").join("Card.fui"),
302 "<Stack><Text>card</Text></Stack>",
303 )
304 .unwrap();
305 std::fs::write(
306 ui_dir.join("App.fui"),
307 "---\nuse ui::components::{Button, Card};\n---\n<Stack><Card /><Button /></Stack>",
308 )
309 .unwrap();
310
311 let out_file = dir.join("out.rs");
312 generate_all(&ui_dir, &out_file).unwrap();
313
314 let content = std::fs::read_to_string(&out_file).unwrap();
315 assert!(content.contains("pub fn render_button()"));
316 assert!(content.contains("pub fn render_card()"));
317 assert!(content.contains("render_card()"));
318 assert!(content.contains("render_button()"));
319 std::fs::remove_dir_all(&dir).unwrap();
320 }
321
322 #[test]
323 fn test_generate_all_import_missing_file_errors() {
324 let (dir, ui_dir) = test_dir();
325
326 std::fs::write(ui_dir.join("App.fui"), "---\nuse ui::Missing;\n---\n<Stack></Stack>").unwrap();
327
328 let out_file = dir.join("out.rs");
329 let result = generate_all(&ui_dir, &out_file);
330 assert!(result.is_err());
331 let err = format!("{}", result.unwrap_err());
332 assert!(err.contains("Missing") || err.contains("not found"));
333 std::fs::remove_dir_all(&dir).unwrap();
334 }
335
336 #[test]
337 fn test_generate_all_with_gpui_import() {
338 let (dir, ui_dir) = test_dir();
339
340 std::fs::write(
341 ui_dir.join("App.fui"),
342 "---\nuse gpui::TextInput;\n---\n<Stack></Stack>",
343 )
344 .unwrap();
345
346 let out_file = dir.join("out.rs");
347 generate_all(&ui_dir, &out_file).unwrap();
348
349 let content = std::fs::read_to_string(&out_file).unwrap();
350 assert!(content.contains("use gpui::TextInput;"));
352 std::fs::remove_dir_all(&dir).unwrap();
353 }
354
355 #[test]
356 fn test_generate_all_recursive_scan() {
357 let (dir, ui_dir) = test_dir();
358
359 std::fs::create_dir_all(ui_dir.join("widgets")).unwrap();
361 std::fs::write(ui_dir.join("widgets").join("Footer.fui"), "<Text>footer</Text>").unwrap();
362 std::fs::write(
364 ui_dir.join("App.fui"),
365 "---\nuse ui::widgets::Footer;\n---\n<Stack><Footer /></Stack>",
366 )
367 .unwrap();
368
369 let out_file = dir.join("out.rs");
370 generate_all(&ui_dir, &out_file).unwrap();
371
372 let content = std::fs::read_to_string(&out_file).unwrap();
373 assert!(
374 content.contains("pub fn render_footer()"),
375 "content missing render_footer fn:\n{content}"
376 );
377 assert!(content.contains("render_footer()"));
378 std::fs::remove_dir_all(&dir).unwrap();
379 }
380
381 fn test_dir_with_src() -> (PathBuf, PathBuf, PathBuf) {
384 let id = LIB_TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
385 let dir = std::env::temp_dir().join(format!("fncc_sem_lib_test_{id}"));
386 let ui_dir = dir.join("ui");
387 let src_dir = dir.join("src");
388 std::fs::create_dir_all(&ui_dir).unwrap();
389 std::fs::create_dir_all(&src_dir).unwrap();
390 (dir, ui_dir, src_dir)
391 }
392
393 #[test]
394 fn test_generate_all_with_options_infers_state_from_rs() {
395 let (dir, ui_dir, src_dir) = test_dir_with_src();
396
397 std::fs::write(
399 ui_dir.join("App.fui"),
400 r#"<Stack direction="vertical" gap="12">
401 <Text>Count: {state.count}</Text>
402 <Button onclick="inc">+1</Button>
403</Stack>"#,
404 )
405 .unwrap();
406
407 std::fs::write(
409 src_dir.join("main.rs"),
410 "#[fncc::command]\nfn inc(state: &mut CounterState, cx: &mut Context<CounterState>) { state.count += 1; cx.notify(); }\n",
411 )
412 .unwrap();
413
414 let out_file = dir.join("out.rs");
415 generate_all_with_options(GenerateOptions {
416 ui_dir: &ui_dir,
417 out_file: &out_file,
418 src_dir: Some(&src_dir),
419 })
420 .unwrap();
421
422 let content = std::fs::read_to_string(&out_file).unwrap();
423 assert!(content.contains("impl Render for CounterState {"));
425 assert!(content.contains("__fncc_cmd_inc"));
426 std::fs::remove_dir_all(&dir).unwrap();
427 }
428
429 #[test]
430 fn test_generate_all_with_options_hard_error_on_missing_command() {
431 let (dir, ui_dir, src_dir) = test_dir_with_src();
432
433 std::fs::write(
434 ui_dir.join("App.fui"),
435 r#"<Button onclick="nonexistent">Click</Button>"#,
436 )
437 .unwrap();
438
439 std::fs::write(src_dir.join("main.rs"), "fn main() {}\n").unwrap();
441
442 let out_file = dir.join("out.rs");
443 let result = generate_all_with_options(GenerateOptions {
444 ui_dir: &ui_dir,
445 out_file: &out_file,
446 src_dir: Some(&src_dir),
447 });
448
449 assert!(result.is_err());
450 let err = format!("{}", result.unwrap_err());
451 assert!(err.contains("nonexistent"), "error should mention command name: {err}");
452 assert!(err.contains("not found"), "error should say not found: {err}");
453 std::fs::remove_dir_all(&dir).unwrap();
454 }
455
456 #[test]
457 fn test_generate_all_with_options_state_conflict_errors() {
458 let (dir, ui_dir, src_dir) = test_dir_with_src();
459
460 std::fs::write(
462 ui_dir.join("App.fui"),
463 "---\n@state OldState\n---\n<Button onclick=\"upd\">Update</Button>",
464 )
465 .unwrap();
466
467 std::fs::write(
468 src_dir.join("main.rs"),
469 "#[fncc::command]\nfn upd(state: &mut NewState, cx: &mut Context<NewState>) {}\n",
470 )
471 .unwrap();
472
473 let out_file = dir.join("out.rs");
474 let result = generate_all_with_options(GenerateOptions {
475 ui_dir: &ui_dir,
476 out_file: &out_file,
477 src_dir: Some(&src_dir),
478 });
479
480 assert!(result.is_err());
481 let err = format!("{}", result.unwrap_err());
482 assert!(
483 err.contains("@state OldState"),
484 "error should mention @state declaration: {err}"
485 );
486 assert!(err.contains("NewState"), "error should mention inferred type: {err}");
487 std::fs::remove_dir_all(&dir).unwrap();
488 }
489
490 #[test]
491 fn test_generate_all_with_options_stateless_remains_stateless() {
492 let (dir, ui_dir, src_dir) = test_dir_with_src();
493
494 std::fs::write(ui_dir.join("App.fui"), "<Text>hello</Text>").unwrap();
496 std::fs::write(src_dir.join("main.rs"), "fn main() {}\n").unwrap();
497
498 let out_file = dir.join("out.rs");
499 generate_all_with_options(GenerateOptions {
500 ui_dir: &ui_dir,
501 out_file: &out_file,
502 src_dir: Some(&src_dir),
503 })
504 .unwrap();
505
506 let content = std::fs::read_to_string(&out_file).unwrap();
507 assert!(content.contains("pub fn render_app()"));
509 assert!(!content.contains("impl Render for"));
510 std::fs::remove_dir_all(&dir).unwrap();
511 }
512
513 #[test]
514 fn test_generate_all_with_options_state_mismatch_across_commands() {
515 let (dir, ui_dir, src_dir) = test_dir_with_src();
516
517 std::fs::write(
518 ui_dir.join("App.fui"),
519 r#"<Stack>
520 <Button onclick="cmd_a">A</Button>
521 <Button onclick="cmd_b">B</Button>
522</Stack>"#,
523 )
524 .unwrap();
525
526 std::fs::write(
527 src_dir.join("main.rs"),
528 "#[fncc::command]\nfn cmd_a(s: &mut TypeA, cx: &mut Context<TypeA>) {}\n\
529 #[fncc::command]\nfn cmd_b(s: &mut TypeB, cx: &mut Context<TypeB>) {}\n",
530 )
531 .unwrap();
532
533 let out_file = dir.join("out.rs");
534 let result = generate_all_with_options(GenerateOptions {
535 ui_dir: &ui_dir,
536 out_file: &out_file,
537 src_dir: Some(&src_dir),
538 });
539
540 assert!(result.is_err());
541 let err = format!("{}", result.unwrap_err());
542 assert!(
543 err.contains("multiple state types"),
544 "error should mention multiple types: {err}"
545 );
546 std::fs::remove_dir_all(&dir).unwrap();
547 }
548
549 #[test]
550 fn test_generate_all_legacy_still_works_without_src_dir() {
551 let (dir, ui_dir) = test_dir();
553
554 std::fs::write(
555 ui_dir.join("App.fui"),
556 "---\n@state CounterState\n---\n<Button onclick=\"inc\">+1</Button>",
557 )
558 .unwrap();
559
560 let out_file = dir.join("out.rs");
561 generate_all(&ui_dir, &out_file).unwrap();
562
563 let content = std::fs::read_to_string(&out_file).unwrap();
564 assert!(content.contains("impl Render for CounterState {"));
565 std::fs::remove_dir_all(&dir).unwrap();
566 }
567}