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, Clone)]
21pub struct ComponentDef {
22 pub name: String,
23 pub render_fn: String,
24 pub source_path: String,
25 pub props_type: Option<String>,
26 pub props_fields: Vec<PropField>,
27}
28
29#[derive(Debug, Clone)]
30pub struct PropField {
31 pub name: String,
32 pub type_expr: String,
33 pub is_optional: bool,
34}
35
36#[derive(Debug)]
37pub enum Diagnostic {
38 CommandNotFound {
39 command: String,
40 fui_file: String,
41 },
42 StateTypeConflict {
43 fui_file: String,
44 declared: String,
45 inferred: String,
46 },
47 StateTypeMismatch {
48 fui_file: String,
49 types: Vec<String>,
50 },
51 DuplicateCommand {
52 name: String,
53 first_file: String,
54 second_file: String,
55 },
56}
57
58impl std::fmt::Display for Diagnostic {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 match self {
61 Diagnostic::CommandNotFound { command, fui_file } => {
62 write!(
63 f,
64 "in '{fui_file}': command `onclick=\"{command}\"` references `#[fncc::command] fn {command}()` which was not found in any Rust source file"
65 )
66 }
67 Diagnostic::StateTypeConflict {
68 fui_file,
69 declared,
70 inferred,
71 } => {
72 write!(
73 f,
74 "in '{fui_file}': `@state {declared}` conflicts with inferred state type `{inferred}` from command signatures — remove `@state` or align the types"
75 )
76 }
77 Diagnostic::StateTypeMismatch { fui_file, types } => {
78 write!(
79 f,
80 "in '{fui_file}': commands reference multiple state types ({}) — a component can only have one state type",
81 types.join(", ")
82 )
83 }
84 Diagnostic::DuplicateCommand {
85 name,
86 first_file,
87 second_file,
88 } => {
89 write!(
90 f,
91 "duplicate `#[fncc::command] fn {name}()` found in '{first_file}' and '{second_file}' — command names must be unique"
92 )
93 }
94 }
95 }
96}
97
98#[derive(Debug, Default)]
99pub struct SemanticDb {
100 pub commands: HashMap<String, CommandDef>,
101 pub components: HashMap<String, ComponentDef>,
102 pub props_types: HashMap<String, Vec<PropField>>,
103 pub diagnostics: Vec<Diagnostic>,
104}
105
106pub fn analyze_rs_files(src_dir: &Path) -> Result<SemanticDb, anyhow::Error> {
109 let mut db = SemanticDb::default();
110 let mut files = Vec::new();
111 collect_rs_files(src_dir, &mut files)?;
112
113 for path in files {
114 let content = std::fs::read_to_string(&path).map_err(|e| anyhow::anyhow!("failed to read {:?}: {e}", path))?;
115 let file_name = path.to_string_lossy().to_string();
116 let commands = extract_commands(&content, &file_name);
117 for cmd in commands {
118 if let Some(existing) = db.commands.get(&cmd.name) {
119 db.diagnostics.push(Diagnostic::DuplicateCommand {
120 name: cmd.name.clone(),
121 first_file: existing.file.clone(),
122 second_file: cmd.file,
123 });
124 } else {
125 db.commands.insert(cmd.name.clone(), cmd);
126 }
127 }
128
129 let props = extract_props_types(&content, &file_name);
130 for (name, fields) in props {
131 db.props_types.entry(name).or_insert(fields);
132 }
133 }
134
135 Ok(db)
136}
137
138fn collect_rs_files(dir: &Path, files: &mut Vec<std::path::PathBuf>) -> Result<(), anyhow::Error> {
139 if !dir.is_dir() {
140 return Ok(());
141 }
142 for entry in std::fs::read_dir(dir).map_err(|e| anyhow::anyhow!("failed to read dir {:?}: {e}", dir))? {
143 let entry = entry.map_err(|e| anyhow::anyhow!("failed to read entry: {e}"))?;
144 let path = entry.path();
145 if path.is_dir() {
146 collect_rs_files(&path, files)?;
147 } else if path.extension().is_some_and(|ext| ext == "rs") {
148 files.push(path);
149 }
150 }
151 Ok(())
152}
153
154fn extract_commands(content: &str, file_name: &str) -> Vec<CommandDef> {
155 let syntax = match syn::parse_file(content) {
156 Ok(file) => file,
157 Err(_) => return Vec::new(),
158 };
159
160 let mut commands = Vec::new();
161 extract_from_items(&syntax.items, file_name, &mut commands);
162 commands
163}
164
165fn extract_from_items(items: &[Item], file_name: &str, commands: &mut Vec<CommandDef>) {
166 for item in items {
167 match item {
168 Item::Fn(func) => {
169 if !is_command_attr(&func.attrs) {
170 continue;
171 }
172
173 let name = func.sig.ident.to_string();
174 let arg_count = func.sig.inputs.len();
175
176 let (level, state_type) = match arg_count {
177 0 => (CommandLevel::Level1, None),
178 1 => (CommandLevel::Level2, None),
179 2 => {
180 let st = extract_state_type_from_first_arg(&func.sig.inputs);
181 (CommandLevel::Level3, st)
182 }
183 _ => continue,
184 };
185
186 commands.push(CommandDef {
187 name,
188 level,
189 state_type,
190 file: file_name.to_string(),
191 });
192 }
193 Item::Mod(m) => {
194 if let Some((_, nested_items)) = &m.content {
195 extract_from_items(nested_items, file_name, commands);
196 }
197 }
198 _ => {}
199 }
200 }
201}
202
203fn is_command_attr(attrs: &[Attribute]) -> bool {
204 attrs.iter().any(|attr| {
205 let path = attr.path();
206 match path.segments.len() {
207 1 => path.segments.last().is_some_and(|s| s.ident == "command"),
208 2 => path.segments[0].ident == "fncc" && path.segments[1].ident == "command",
209 _ => false,
210 }
211 })
212}
213
214fn extract_props_types(content: &str, file_name: &str) -> HashMap<String, Vec<PropField>> {
217 let syntax = match syn::parse_file(content) {
218 Ok(file) => file,
219 Err(_) => return HashMap::new(),
220 };
221
222 let mut result = HashMap::new();
223 collect_props_from_items(&syntax.items, file_name, &mut result);
224 result
225}
226
227#[allow(clippy::only_used_in_recursion)]
228fn collect_props_from_items(items: &[Item], _file_name: &str, result: &mut HashMap<String, Vec<PropField>>) {
229 for item in items {
230 match item {
231 Item::Struct(s) => {
232 if !has_props_derive(&s.attrs) {
233 continue;
234 }
235 if result.contains_key(&s.ident.to_string()) {
236 continue;
237 }
238 let fields = match &s.fields {
239 syn::Fields::Named(n) => n.named.iter().filter_map(extract_prop_field).collect(),
240 _ => continue,
241 };
242 result.insert(s.ident.to_string(), fields);
243 }
244 Item::Mod(m) => {
245 if let Some((_, nested_items)) = &m.content {
246 collect_props_from_items(nested_items, _file_name, result);
247 }
248 }
249 _ => {}
250 }
251 }
252}
253
254fn has_props_derive(attrs: &[syn::Attribute]) -> bool {
255 attrs
256 .iter()
257 .any(|attr| attr.path().is_ident("derive") && attr.parse_args_with(PropsDerive::parse_multi).is_ok())
258}
259
260struct PropsDerive;
261
262impl PropsDerive {
263 fn parse_multi(input: syn::parse::ParseStream) -> syn::Result<Self> {
265 while !input.is_empty() {
266 if input.peek(syn::Ident) {
268 let path: syn::Path = input.parse()?;
269 if path.segments.last().is_some_and(|s| s.ident == "Props") {
270 return Ok(PropsDerive);
271 }
272 } else {
273 return Err(input.error("expected identifier or path"));
274 }
275 let _ = input.parse::<syn::Token![,]>();
277 }
278 Err(input.error("expected `Props` or `fncc::Props` in derive list"))
279 }
280}
281
282fn extract_prop_field(field: &syn::Field) -> Option<PropField> {
283 let name = field.ident.as_ref()?.to_string();
284 let ty = &field.ty;
285 let type_expr = quote::quote!(#ty).to_string();
286 let is_optional = is_option_type(ty);
287 Some(PropField {
288 name,
289 type_expr,
290 is_optional,
291 })
292}
293
294fn is_option_type(ty: &Type) -> bool {
296 if let Type::Path(type_path) = ty
297 && let Some(segment) = type_path.path.segments.last()
298 {
299 return segment.ident == "Option";
300 }
301 false
302}
303
304fn extract_state_type_from_first_arg(inputs: &syn::punctuated::Punctuated<FnArg, syn::Token![,]>) -> Option<String> {
305 let first = inputs.first()?;
306 let typed = match first {
307 FnArg::Typed(t) => t,
308 _ => return None,
309 };
310
311 let ref_type = match typed.ty.as_ref() {
312 Type::Reference(r) if r.mutability.is_some() => r,
313 _ => return None,
314 };
315
316 match ref_type.elem.as_ref() {
317 Type::Path(type_path) => type_path.path.segments.last().map(|s| s.ident.to_string()),
318 _ => None,
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn test_extract_commands_empty_file() {
328 let cmds = extract_commands("fn main() {}", "test.rs");
329 assert!(cmds.is_empty());
330 }
331
332 #[test]
333 fn test_extract_level1_command() {
334 let src = "#[fncc::command]\nfn greet() { println!(\"hi\"); }";
335 let cmds = extract_commands(src, "test.rs");
336 assert_eq!(cmds.len(), 1);
337 assert_eq!(cmds[0].name, "greet");
338 assert_eq!(cmds[0].level, CommandLevel::Level1);
339 assert!(cmds[0].state_type.is_none());
340 }
341
342 #[test]
343 fn test_extract_level2_command() {
344 let src = "#[fncc::command]\nfn handle_click(_: &ClickEvent) {}";
345 let cmds = extract_commands(src, "test.rs");
346 assert_eq!(cmds.len(), 1);
347 assert_eq!(cmds[0].name, "handle_click");
348 assert_eq!(cmds[0].level, CommandLevel::Level2);
349 assert!(cmds[0].state_type.is_none());
350 }
351
352 #[test]
353 fn test_extract_level3_command() {
354 let src =
355 "#[fncc::command]\nfn inc(state: &mut CounterState, cx: &mut Context<CounterState>) { state.count += 1; }";
356 let cmds = extract_commands(src, "test.rs");
357 assert_eq!(cmds.len(), 1);
358 assert_eq!(cmds[0].name, "inc");
359 assert_eq!(cmds[0].level, CommandLevel::Level3);
360 assert_eq!(cmds[0].state_type.as_deref(), Some("CounterState"));
361 }
362
363 #[test]
364 fn test_extract_multiple_commands() {
365 let src = r#"
366#[fncc::command]
367fn a() {}
368
369#[fncc::command]
370fn b(_: &ClickEvent) {}
371
372#[fncc::command]
373fn c(s: &mut AppState, cx: &mut Context<AppState>) {}
374"#;
375 let cmds = extract_commands(src, "test.rs");
376 assert_eq!(cmds.len(), 3);
377 }
378
379 #[test]
380 fn test_extract_non_command_ignored() {
381 let src = r#"
382#[derive(Default)]
383struct Foo {}
384
385fn bar() {}
386"#;
387 let cmds = extract_commands(src, "test.rs");
388 assert!(cmds.is_empty());
389 }
390
391 #[test]
392 fn test_diagnostic_display_command_not_found() {
393 let d = Diagnostic::CommandNotFound {
394 command: "foo".into(),
395 fui_file: "App.fui".into(),
396 };
397 let msg = d.to_string();
398 assert!(msg.contains("foo"));
399 assert!(msg.contains("App.fui"));
400 assert!(msg.contains("not found"));
401 }
402
403 #[test]
404 fn test_diagnostic_display_state_conflict() {
405 let d = Diagnostic::StateTypeConflict {
406 fui_file: "App.fui".into(),
407 declared: "A".into(),
408 inferred: "B".into(),
409 };
410 let msg = d.to_string();
411 assert!(msg.contains("@state A"));
412 assert!(msg.contains("B"));
413 }
414
415 #[test]
416 fn test_analyze_rs_files_empty_dir() {
417 let dir = std::env::temp_dir().join("fncc_semantic_test_empty");
418 let _ = std::fs::remove_dir_all(&dir);
419 std::fs::create_dir_all(&dir).unwrap();
420 let db = analyze_rs_files(&dir).unwrap();
421 assert!(db.commands.is_empty());
422 assert!(db.diagnostics.is_empty());
423 let _ = std::fs::remove_dir_all(&dir);
424 }
425
426 #[test]
427 fn test_analyze_rs_files_finds_command() {
428 let dir = std::env::temp_dir().join("fncc_semantic_test_cmd");
429 let _ = std::fs::remove_dir_all(&dir);
430 std::fs::create_dir_all(&dir).unwrap();
431 std::fs::write(
432 dir.join("main.rs"),
433 "#[fncc::command]\nfn inc(s: &mut S, cx: &mut Context<S>) {}\n",
434 )
435 .unwrap();
436 let db = analyze_rs_files(&dir).unwrap();
437 assert_eq!(db.commands.len(), 1);
438 assert_eq!(db.commands.get("inc").unwrap().level, CommandLevel::Level3);
439 let _ = std::fs::remove_dir_all(&dir);
440 }
441
442 #[test]
443 fn test_analyze_rs_files_ignores_non_rs_files() {
444 let dir = std::env::temp_dir().join("fncc_semantic_test_ignore");
445 let _ = std::fs::remove_dir_all(&dir);
446 std::fs::create_dir_all(&dir).unwrap();
447 std::fs::write(dir.join("not_rust.txt"), "not a rust file").unwrap();
448 let db = analyze_rs_files(&dir).unwrap();
449 assert!(db.commands.is_empty());
450 let _ = std::fs::remove_dir_all(&dir);
451 }
452
453 #[test]
454 fn test_analyze_rs_files_recursive() {
455 let dir = std::env::temp_dir().join("fncc_semantic_test_recursive");
456 let _ = std::fs::remove_dir_all(&dir);
457 std::fs::create_dir_all(dir.join("nested")).unwrap();
458 std::fs::write(dir.join("nested").join("cmds.rs"), "#[fncc::command]\nfn a() {}").unwrap();
459 let db = analyze_rs_files(&dir).unwrap();
460 assert_eq!(db.commands.len(), 1);
461 let _ = std::fs::remove_dir_all(&dir);
462 }
463
464 #[test]
465 fn test_extract_state_type_generic_context() {
466 let src = "#[fncc::command]\nfn upd(state: &mut MyState, cx: &mut Context<MyState>) {}";
467 let cmds = extract_commands(src, "test.rs");
468 assert_eq!(cmds.len(), 1);
469 assert_eq!(cmds[0].state_type.as_deref(), Some("MyState"));
470 }
471
472 #[test]
473 fn test_extract_state_type_module_qualified() {
474 let src = "#[fncc::command]\nfn upd(state: &mut some::DeepState, cx: &mut Context<some::DeepState>) {}";
475 let cmds = extract_commands(src, "test.rs");
476 assert_eq!(cmds.len(), 1);
477 assert_eq!(cmds[0].state_type.as_deref(), Some("DeepState"));
478 }
479
480 #[test]
481 fn test_extract_syntax_error_skips_file() {
482 let cmds = extract_commands("this is not valid rust @@@", "bad.rs");
483 assert!(cmds.is_empty());
484 }
485
486 #[test]
487 fn test_extract_commands_from_inline_module() {
488 let src = r#"
489mod handlers {
490 #[fncc::command]
491 fn handle_click(_: &ClickEvent) {}
492
493 mod nested {
494 #[fncc::command]
495 fn deep(state: &mut Inner, cx: &mut Context<Inner>) {}
496 }
497}
498
499#[fncc::command]
500fn top_level() {}
501"#;
502 let cmds = extract_commands(src, "test.rs");
503 assert_eq!(cmds.len(), 3);
504 let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
505 assert!(names.contains(&"handle_click"));
506 assert!(names.contains(&"deep"));
507 assert!(names.contains(&"top_level"));
508
509 let deep = cmds.iter().find(|c| c.name == "deep").unwrap();
511 assert_eq!(deep.state_type.as_deref(), Some("Inner"));
512 }
513
514 #[test]
515 fn test_analyze_rs_files_duplicate_command_detected() {
516 let dir = std::env::temp_dir().join("fncc_semantic_test_dup");
517 let _ = std::fs::remove_dir_all(&dir);
518 std::fs::create_dir_all(&dir).unwrap();
519
520 std::fs::write(dir.join("a.rs"), "#[fncc::command]\nfn foo() {}\n").unwrap();
521 std::fs::write(dir.join("b.rs"), "#[fncc::command]\nfn foo() {}\n").unwrap();
522
523 let db = analyze_rs_files(&dir).unwrap();
524 assert_eq!(db.commands.len(), 1);
526 assert!(db.commands.contains_key("foo"));
527 assert_eq!(db.diagnostics.len(), 1);
529 let diag = &db.diagnostics[0];
530 match diag {
531 Diagnostic::DuplicateCommand { name, .. } => assert_eq!(name, "foo"),
532 other => panic!("expected DuplicateCommand, got {other:?}"),
533 }
534
535 let _ = std::fs::remove_dir_all(&dir);
536 }
537
538 #[test]
539 fn test_diagnostic_display_duplicate_command() {
540 let d = Diagnostic::DuplicateCommand {
541 name: "foo".into(),
542 first_file: "a.rs".into(),
543 second_file: "b.rs".into(),
544 };
545 let msg = d.to_string();
546 assert!(msg.contains("duplicate"));
547 assert!(msg.contains("foo"));
548 assert!(msg.contains("a.rs"));
549 assert!(msg.contains("b.rs"));
550 }
551
552 #[test]
553 fn test_analyze_rs_files_unique_commands_no_diagnostics() {
554 let dir = std::env::temp_dir().join("fncc_semantic_test_unique");
555 let _ = std::fs::remove_dir_all(&dir);
556 std::fs::create_dir_all(&dir).unwrap();
557
558 std::fs::write(dir.join("a.rs"), "#[fncc::command]\nfn foo() {}\n").unwrap();
559 std::fs::write(dir.join("b.rs"), "#[fncc::command]\nfn bar() {}\n").unwrap();
560
561 let db = analyze_rs_files(&dir).unwrap();
562 assert_eq!(db.commands.len(), 2);
563 assert!(db.diagnostics.is_empty());
564
565 let _ = std::fs::remove_dir_all(&dir);
566 }
567
568 #[test]
569 fn test_analyze_rs_files_duplicate_deterministic_first_wins() {
570 let dir = std::env::temp_dir().join("fncc_semantic_test_dup_det");
573 let _ = std::fs::remove_dir_all(&dir);
574 std::fs::create_dir_all(&dir).unwrap();
575
576 std::fs::write(dir.join("z_first.rs"), "#[fncc::command]\nfn cmd() {}\n").unwrap();
577 std::fs::write(dir.join("a_second.rs"), "#[fncc::command]\nfn cmd() {}\n").unwrap();
578
579 let db = analyze_rs_files(&dir).unwrap();
580 assert_eq!(db.diagnostics.len(), 1);
581
582 let _ = std::fs::remove_dir_all(&dir);
583 }
584}