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
88pub 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 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 assert_eq!(db.commands.len(), 1);
412 assert!(db.commands.contains_key("foo"));
413 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 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}