Skip to main content

fncc_core/
semantic.rs

1use std::collections::HashMap;
2use std::path::Path;
3use syn::{Attribute, FnArg, Item, Type};
4
5#[derive(Debug, Clone, PartialEq)]
6pub enum CommandLevel {
7    Level1,
8    Level2,
9    Level3,
10}
11
12#[derive(Debug, Clone)]
13pub struct CommandDef {
14    pub name: String,
15    pub level: CommandLevel,
16    pub state_type: Option<String>,
17    pub file: String,
18}
19
20#[derive(Debug)]
21pub enum Diagnostic {
22    CommandNotFound {
23        command: String,
24        fui_file: String,
25    },
26    StateTypeConflict {
27        fui_file: String,
28        declared: String,
29        inferred: String,
30    },
31    StateTypeMismatch {
32        fui_file: String,
33        types: Vec<String>,
34    },
35    DuplicateCommand {
36        name: String,
37        first_file: String,
38        second_file: String,
39    },
40}
41
42impl std::fmt::Display for Diagnostic {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Diagnostic::CommandNotFound { command, fui_file } => {
46                write!(
47                    f,
48                    "in '{fui_file}': command `onclick=\"{command}\"` references `#[fncc::command] fn {command}()` which was not found in any Rust source file"
49                )
50            }
51            Diagnostic::StateTypeConflict {
52                fui_file,
53                declared,
54                inferred,
55            } => {
56                write!(
57                    f,
58                    "in '{fui_file}': `@state {declared}` conflicts with inferred state type `{inferred}` from command signatures — remove `@state` or align the types"
59                )
60            }
61            Diagnostic::StateTypeMismatch { fui_file, types } => {
62                write!(
63                    f,
64                    "in '{fui_file}': commands reference multiple state types ({}) — a component can only have one state type",
65                    types.join(", ")
66                )
67            }
68            Diagnostic::DuplicateCommand {
69                name,
70                first_file,
71                second_file,
72            } => {
73                write!(
74                    f,
75                    "duplicate `#[fncc::command] fn {name}()` found in '{first_file}' and '{second_file}' — command names must be unique"
76                )
77            }
78        }
79    }
80}
81
82#[derive(Debug, Default)]
83pub struct SemanticDb {
84    pub commands: HashMap<String, CommandDef>,
85    pub diagnostics: Vec<Diagnostic>,
86}
87
88/// Analyze Rust source files in `src_dir` looking for `#[fncc::command]` functions.
89pub fn analyze_rs_files(src_dir: &Path) -> Result<SemanticDb, anyhow::Error> {
90    let mut db = SemanticDb::default();
91    let mut files = Vec::new();
92    collect_rs_files(src_dir, &mut files)?;
93
94    for path in files {
95        let content = std::fs::read_to_string(&path).map_err(|e| anyhow::anyhow!("failed to read {:?}: {e}", path))?;
96        let file_name = path.to_string_lossy().to_string();
97        let commands = extract_commands(&content, &file_name);
98        for cmd in commands {
99            if let Some(existing) = db.commands.get(&cmd.name) {
100                db.diagnostics.push(Diagnostic::DuplicateCommand {
101                    name: cmd.name.clone(),
102                    first_file: existing.file.clone(),
103                    second_file: cmd.file,
104                });
105            } else {
106                db.commands.insert(cmd.name.clone(), cmd);
107            }
108        }
109    }
110
111    Ok(db)
112}
113
114fn collect_rs_files(dir: &Path, files: &mut Vec<std::path::PathBuf>) -> Result<(), anyhow::Error> {
115    if !dir.is_dir() {
116        return Ok(());
117    }
118    for entry in std::fs::read_dir(dir).map_err(|e| anyhow::anyhow!("failed to read dir {:?}: {e}", dir))? {
119        let entry = entry.map_err(|e| anyhow::anyhow!("failed to read entry: {e}"))?;
120        let path = entry.path();
121        if path.is_dir() {
122            collect_rs_files(&path, files)?;
123        } else if path.extension().is_some_and(|ext| ext == "rs") {
124            files.push(path);
125        }
126    }
127    Ok(())
128}
129
130fn extract_commands(content: &str, file_name: &str) -> Vec<CommandDef> {
131    let syntax = match syn::parse_file(content) {
132        Ok(file) => file,
133        Err(_) => return Vec::new(),
134    };
135
136    let mut commands = Vec::new();
137    extract_from_items(&syntax.items, file_name, &mut commands);
138    commands
139}
140
141fn extract_from_items(items: &[Item], file_name: &str, commands: &mut Vec<CommandDef>) {
142    for item in items {
143        match item {
144            Item::Fn(func) => {
145                if !is_command_attr(&func.attrs) {
146                    continue;
147                }
148
149                let name = func.sig.ident.to_string();
150                let arg_count = func.sig.inputs.len();
151
152                let (level, state_type) = match arg_count {
153                    0 => (CommandLevel::Level1, None),
154                    1 => (CommandLevel::Level2, None),
155                    2 => {
156                        let st = extract_state_type_from_first_arg(&func.sig.inputs);
157                        (CommandLevel::Level3, st)
158                    }
159                    _ => continue,
160                };
161
162                commands.push(CommandDef {
163                    name,
164                    level,
165                    state_type,
166                    file: file_name.to_string(),
167                });
168            }
169            Item::Mod(m) => {
170                if let Some((_, nested_items)) = &m.content {
171                    extract_from_items(nested_items, file_name, commands);
172                }
173            }
174            _ => {}
175        }
176    }
177}
178
179fn is_command_attr(attrs: &[Attribute]) -> bool {
180    attrs.iter().any(|attr| {
181        let path = attr.path();
182        match path.segments.len() {
183            1 => path.segments.last().is_some_and(|s| s.ident == "command"),
184            2 => path.segments[0].ident == "fncc" && path.segments[1].ident == "command",
185            _ => false,
186        }
187    })
188}
189
190fn extract_state_type_from_first_arg(inputs: &syn::punctuated::Punctuated<FnArg, syn::Token![,]>) -> Option<String> {
191    let first = inputs.first()?;
192    let typed = match first {
193        FnArg::Typed(t) => t,
194        _ => return None,
195    };
196
197    let ref_type = match typed.ty.as_ref() {
198        Type::Reference(r) if r.mutability.is_some() => r,
199        _ => return None,
200    };
201
202    match ref_type.elem.as_ref() {
203        Type::Path(type_path) => type_path.path.segments.last().map(|s| s.ident.to_string()),
204        _ => None,
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_extract_commands_empty_file() {
214        let cmds = extract_commands("fn main() {}", "test.rs");
215        assert!(cmds.is_empty());
216    }
217
218    #[test]
219    fn test_extract_level1_command() {
220        let src = "#[fncc::command]\nfn greet() { println!(\"hi\"); }";
221        let cmds = extract_commands(src, "test.rs");
222        assert_eq!(cmds.len(), 1);
223        assert_eq!(cmds[0].name, "greet");
224        assert_eq!(cmds[0].level, CommandLevel::Level1);
225        assert!(cmds[0].state_type.is_none());
226    }
227
228    #[test]
229    fn test_extract_level2_command() {
230        let src = "#[fncc::command]\nfn handle_click(_: &ClickEvent) {}";
231        let cmds = extract_commands(src, "test.rs");
232        assert_eq!(cmds.len(), 1);
233        assert_eq!(cmds[0].name, "handle_click");
234        assert_eq!(cmds[0].level, CommandLevel::Level2);
235        assert!(cmds[0].state_type.is_none());
236    }
237
238    #[test]
239    fn test_extract_level3_command() {
240        let src =
241            "#[fncc::command]\nfn inc(state: &mut CounterState, cx: &mut Context<CounterState>) { state.count += 1; }";
242        let cmds = extract_commands(src, "test.rs");
243        assert_eq!(cmds.len(), 1);
244        assert_eq!(cmds[0].name, "inc");
245        assert_eq!(cmds[0].level, CommandLevel::Level3);
246        assert_eq!(cmds[0].state_type.as_deref(), Some("CounterState"));
247    }
248
249    #[test]
250    fn test_extract_multiple_commands() {
251        let src = r#"
252#[fncc::command]
253fn a() {}
254
255#[fncc::command]
256fn b(_: &ClickEvent) {}
257
258#[fncc::command]
259fn c(s: &mut AppState, cx: &mut Context<AppState>) {}
260"#;
261        let cmds = extract_commands(src, "test.rs");
262        assert_eq!(cmds.len(), 3);
263    }
264
265    #[test]
266    fn test_extract_non_command_ignored() {
267        let src = r#"
268#[derive(Default)]
269struct Foo {}
270
271fn bar() {}
272"#;
273        let cmds = extract_commands(src, "test.rs");
274        assert!(cmds.is_empty());
275    }
276
277    #[test]
278    fn test_diagnostic_display_command_not_found() {
279        let d = Diagnostic::CommandNotFound {
280            command: "foo".into(),
281            fui_file: "App.fui".into(),
282        };
283        let msg = d.to_string();
284        assert!(msg.contains("foo"));
285        assert!(msg.contains("App.fui"));
286        assert!(msg.contains("not found"));
287    }
288
289    #[test]
290    fn test_diagnostic_display_state_conflict() {
291        let d = Diagnostic::StateTypeConflict {
292            fui_file: "App.fui".into(),
293            declared: "A".into(),
294            inferred: "B".into(),
295        };
296        let msg = d.to_string();
297        assert!(msg.contains("@state A"));
298        assert!(msg.contains("B"));
299    }
300
301    #[test]
302    fn test_analyze_rs_files_empty_dir() {
303        let dir = std::env::temp_dir().join("fncc_semantic_test_empty");
304        let _ = std::fs::remove_dir_all(&dir);
305        std::fs::create_dir_all(&dir).unwrap();
306        let db = analyze_rs_files(&dir).unwrap();
307        assert!(db.commands.is_empty());
308        assert!(db.diagnostics.is_empty());
309        let _ = std::fs::remove_dir_all(&dir);
310    }
311
312    #[test]
313    fn test_analyze_rs_files_finds_command() {
314        let dir = std::env::temp_dir().join("fncc_semantic_test_cmd");
315        let _ = std::fs::remove_dir_all(&dir);
316        std::fs::create_dir_all(&dir).unwrap();
317        std::fs::write(
318            dir.join("main.rs"),
319            "#[fncc::command]\nfn inc(s: &mut S, cx: &mut Context<S>) {}\n",
320        )
321        .unwrap();
322        let db = analyze_rs_files(&dir).unwrap();
323        assert_eq!(db.commands.len(), 1);
324        assert_eq!(db.commands.get("inc").unwrap().level, CommandLevel::Level3);
325        let _ = std::fs::remove_dir_all(&dir);
326    }
327
328    #[test]
329    fn test_analyze_rs_files_ignores_non_rs_files() {
330        let dir = std::env::temp_dir().join("fncc_semantic_test_ignore");
331        let _ = std::fs::remove_dir_all(&dir);
332        std::fs::create_dir_all(&dir).unwrap();
333        std::fs::write(dir.join("not_rust.txt"), "not a rust file").unwrap();
334        let db = analyze_rs_files(&dir).unwrap();
335        assert!(db.commands.is_empty());
336        let _ = std::fs::remove_dir_all(&dir);
337    }
338
339    #[test]
340    fn test_analyze_rs_files_recursive() {
341        let dir = std::env::temp_dir().join("fncc_semantic_test_recursive");
342        let _ = std::fs::remove_dir_all(&dir);
343        std::fs::create_dir_all(dir.join("nested")).unwrap();
344        std::fs::write(dir.join("nested").join("cmds.rs"), "#[fncc::command]\nfn a() {}").unwrap();
345        let db = analyze_rs_files(&dir).unwrap();
346        assert_eq!(db.commands.len(), 1);
347        let _ = std::fs::remove_dir_all(&dir);
348    }
349
350    #[test]
351    fn test_extract_state_type_generic_context() {
352        let src = "#[fncc::command]\nfn upd(state: &mut MyState, cx: &mut Context<MyState>) {}";
353        let cmds = extract_commands(src, "test.rs");
354        assert_eq!(cmds.len(), 1);
355        assert_eq!(cmds[0].state_type.as_deref(), Some("MyState"));
356    }
357
358    #[test]
359    fn test_extract_state_type_module_qualified() {
360        let src = "#[fncc::command]\nfn upd(state: &mut some::DeepState, cx: &mut Context<some::DeepState>) {}";
361        let cmds = extract_commands(src, "test.rs");
362        assert_eq!(cmds.len(), 1);
363        assert_eq!(cmds[0].state_type.as_deref(), Some("DeepState"));
364    }
365
366    #[test]
367    fn test_extract_syntax_error_skips_file() {
368        let cmds = extract_commands("this is not valid rust @@@", "bad.rs");
369        assert!(cmds.is_empty());
370    }
371
372    #[test]
373    fn test_extract_commands_from_inline_module() {
374        let src = r#"
375mod handlers {
376    #[fncc::command]
377    fn handle_click(_: &ClickEvent) {}
378
379    mod nested {
380        #[fncc::command]
381        fn deep(state: &mut Inner, cx: &mut Context<Inner>) {}
382    }
383}
384
385#[fncc::command]
386fn top_level() {}
387"#;
388        let cmds = extract_commands(src, "test.rs");
389        assert_eq!(cmds.len(), 3);
390        let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
391        assert!(names.contains(&"handle_click"));
392        assert!(names.contains(&"deep"));
393        assert!(names.contains(&"top_level"));
394
395        // deep should have Inner as state type
396        let deep = cmds.iter().find(|c| c.name == "deep").unwrap();
397        assert_eq!(deep.state_type.as_deref(), Some("Inner"));
398    }
399
400    #[test]
401    fn test_analyze_rs_files_duplicate_command_detected() {
402        let dir = std::env::temp_dir().join("fncc_semantic_test_dup");
403        let _ = std::fs::remove_dir_all(&dir);
404        std::fs::create_dir_all(&dir).unwrap();
405
406        std::fs::write(dir.join("a.rs"), "#[fncc::command]\nfn foo() {}\n").unwrap();
407        std::fs::write(dir.join("b.rs"), "#[fncc::command]\nfn foo() {}\n").unwrap();
408
409        let db = analyze_rs_files(&dir).unwrap();
410        // Should still have foo (first one wins)
411        assert_eq!(db.commands.len(), 1);
412        assert!(db.commands.contains_key("foo"));
413        // Should have a duplicate diagnostic
414        assert_eq!(db.diagnostics.len(), 1);
415        let diag = &db.diagnostics[0];
416        match diag {
417            Diagnostic::DuplicateCommand { name, .. } => assert_eq!(name, "foo"),
418            other => panic!("expected DuplicateCommand, got {other:?}"),
419        }
420
421        let _ = std::fs::remove_dir_all(&dir);
422    }
423
424    #[test]
425    fn test_diagnostic_display_duplicate_command() {
426        let d = Diagnostic::DuplicateCommand {
427            name: "foo".into(),
428            first_file: "a.rs".into(),
429            second_file: "b.rs".into(),
430        };
431        let msg = d.to_string();
432        assert!(msg.contains("duplicate"));
433        assert!(msg.contains("foo"));
434        assert!(msg.contains("a.rs"));
435        assert!(msg.contains("b.rs"));
436    }
437
438    #[test]
439    fn test_analyze_rs_files_unique_commands_no_diagnostics() {
440        let dir = std::env::temp_dir().join("fncc_semantic_test_unique");
441        let _ = std::fs::remove_dir_all(&dir);
442        std::fs::create_dir_all(&dir).unwrap();
443
444        std::fs::write(dir.join("a.rs"), "#[fncc::command]\nfn foo() {}\n").unwrap();
445        std::fs::write(dir.join("b.rs"), "#[fncc::command]\nfn bar() {}\n").unwrap();
446
447        let db = analyze_rs_files(&dir).unwrap();
448        assert_eq!(db.commands.len(), 2);
449        assert!(db.diagnostics.is_empty());
450
451        let _ = std::fs::remove_dir_all(&dir);
452    }
453
454    #[test]
455    fn test_analyze_rs_files_duplicate_deterministic_first_wins() {
456        // Regardless of file traversal order, the first definition encountered
457        // is retained and the second is a diagnostic.
458        let dir = std::env::temp_dir().join("fncc_semantic_test_dup_det");
459        let _ = std::fs::remove_dir_all(&dir);
460        std::fs::create_dir_all(&dir).unwrap();
461
462        std::fs::write(dir.join("z_first.rs"), "#[fncc::command]\nfn cmd() {}\n").unwrap();
463        std::fs::write(dir.join("a_second.rs"), "#[fncc::command]\nfn cmd() {}\n").unwrap();
464
465        let db = analyze_rs_files(&dir).unwrap();
466        assert_eq!(db.diagnostics.len(), 1);
467
468        let _ = std::fs::remove_dir_all(&dir);
469    }
470}