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, 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
106/// Analyze Rust source files in `src_dir` looking for `#[fncc::command]` functions
107/// and `#[derive(Props)]` structs.
108pub 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
214/// Extract props type fields from structs with `#[derive(Props)]`.
215/// Returns a map of struct name → fields.
216fn 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    /// Accept `Props` or `fncc::Props` (and skip non-matching idents in `derive(...)`).
264    fn parse_multi(input: syn::parse::ParseStream) -> syn::Result<Self> {
265        while !input.is_empty() {
266            // Try a path like `fncc::Props` or just `Props`
267            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            // Skip optional comma
276            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
294/// Detect whether a type is `Option<T>` (with any inner type).
295fn 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        // deep should have Inner as state type
510        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        // Should still have foo (first one wins)
525        assert_eq!(db.commands.len(), 1);
526        assert!(db.commands.contains_key("foo"));
527        // Should have a duplicate diagnostic
528        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        // Regardless of file traversal order, the first definition encountered
571        // is retained and the second is a diagnostic.
572        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}