Skip to main content

fncc_core/
lib.rs

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
12/// Recursively collect all .fui files under `dir`.
13fn 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
30/// Structured parsed file for two-pass processing.
31struct ParsedFile {
32    path: std::path::PathBuf,
33    /// Path relative to ui_dir, forward-slash-separated, without extension.
34    /// e.g. "components/Header"
35    relative_stem: String,
36    ast: parser::Document,
37}
38
39/// Configuration for the fncc compilation pipeline.
40/// Use `GenerateOptions::new(ui_dir, out_file)` for default (legacy) behavior,
41/// or set `src_dir` to enable semantic analysis (state inference, command validation).
42pub struct GenerateOptions<'a> {
43    pub ui_dir: &'a Path,
44    pub out_file: &'a Path,
45    /// If `Some`, enables semantic analysis (scans `.rs` files for `#[fncc::command]`).
46    /// Expected to point to the Rust source directory (e.g. `"src/"`).
47    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
60/// Legacy entry point: parse all .fui files under `ui_dir` and write
61/// generated Rust code into `out_file`. No semantic analysis.
62pub fn generate_all(ui_dir: &Path, out_file: &Path) -> Result<()> {
63    generate_all_with_options(GenerateOptions::new(ui_dir, out_file))
64}
65
66/// Full entry point: parse `.fui` files and optionally run semantic analysis
67/// (state inference, command validation) when `opts.src_dir` is `Some`.
68pub 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    // Pass 1: Parse all files
74    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        // Compute relative stem from ui_dir
80        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    // Build import resolution index: "ui::path::Component" -> render function name
98    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    // Validate imports: every FuiPath import must exist in the index
107    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    // Semantic analysis (optional): scan .rs files for #[fncc::command]
123    let semantic_db = if let Some(src_dir) = opts.src_dir {
124        let db = semantic::analyze_rs_files(src_dir)?;
125        // Extraction-level diagnostics (e.g. duplicate commands) are hard errors
126        if let Some(diag) = db.diagnostics.first() {
127            anyhow::bail!("{}", diag);
128        }
129        Some(db)
130    } else {
131        None
132    };
133
134    // Per-file state type resolution: @state or inferred from commands
135    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    // Pass 2: Generate code with resolved imports
184    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        // Function name uses file stem ("App"), not root element name
242        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        // Function name uses file stem ("App"), not root element name
258        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        // Header.fui — stateless component
269        std::fs::write(ui_dir.join("Header.fui"), "<Text size=\"xl\">Welcome</Text>").unwrap();
270        // App.fui — imports Header
271        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        // Header generates its own render function
282        assert!(content.contains("pub fn render_header()"));
283        // App uses the imported component
284        assert!(content.contains("render_header()"));
285        // No ui:: import leaks into generated Rust
286        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        // gpui imports are real Rust — preserved in frontmatter
351        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        // File in subdirectory
360        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        // File in root
363        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    // --- Semantic analysis tests ---
382
383    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        // .fui file without @state
398        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        // .rs file with Level 3 command
408        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        // Should generate impl Render for CounterState (inferred from command)
424        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        // No .rs file with #[fncc::command] nonexistent
440        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        // .fui with @state that conflicts with command signature
461        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        // Stateless component, no @state, no Level 3 commands
495        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        // Should be stateless — standalone render function
508        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        // Verify that legacy generate_all (no src_dir) still works unchanged
552        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}