Skip to main content

asr/
graph.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::path::Path;
3
4use tree_sitter::{Node, Parser};
5
6use crate::language::{is_js_ts_like, tree_sitter_language};
7use crate::path_lookup::{normalize_path_to_slash, path_lookup_candidates};
8
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct Symbol {
11    pub name: String,
12    pub kind: String,
13    pub line: usize,
14}
15
16#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
17pub struct FileNode {
18    pub symbols: Vec<Symbol>,
19    pub raw_imports: Vec<String>,
20    pub depends_on: Vec<String>,
21    #[serde(skip)]
22    source: String,
23}
24
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct OrphanFile {
27    pub file_path: String,
28    pub symbols: Vec<Symbol>,
29    pub depends_on: Vec<String>,
30}
31
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33pub struct UnusedSymbol {
34    pub file_path: String,
35    pub symbol: Symbol,
36}
37
38#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
39pub struct DependencyGraph {
40    files: HashMap<String, FileNode>,
41    #[serde(skip)]
42    reverse: HashMap<String, Vec<String>>,
43}
44
45impl DependencyGraph {
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    pub fn add_file(&mut self, file_path: &str, source: &str, language: &str) {
51        let ts_lang = match tree_sitter_language(language) {
52            Some(l) => l,
53            None => return,
54        };
55        let mut parser = Parser::new();
56        if parser.set_language(&ts_lang).is_err() {
57            return;
58        }
59        let tree = match parser.parse(source, None) {
60            Some(t) => t,
61            None => return,
62        };
63        let root = tree.root_node();
64
65        let symbols = extract_symbols(source, language, &root);
66        let raw_imports = extract_imports(source, language, &root);
67
68        self.files.insert(
69            file_path.to_string(),
70            FileNode {
71                symbols,
72                raw_imports,
73                depends_on: Vec::new(),
74                source: source.to_string(),
75            },
76        );
77    }
78
79    pub fn resolve_dependencies(&mut self) {
80        let mut all_paths: Vec<String> = self.files.keys().cloned().collect();
81        all_paths.sort();
82        let file_stems: HashMap<String, Vec<String>> = build_stem_index(&all_paths);
83
84        let mut resolutions: Vec<(String, Vec<String>)> = self
85            .files
86            .iter()
87            .map(|(fp, node)| {
88                let mut deps: Vec<String> = node
89                    .raw_imports
90                    .iter()
91                    .filter_map(|imp| resolve_import(imp, fp, &file_stems, &all_paths))
92                    .filter(|dep| dep != fp)
93                    .collect::<HashSet<_>>()
94                    .into_iter()
95                    .collect();
96                deps.sort();
97                (fp.clone(), deps)
98            })
99            .collect();
100        resolutions.sort_by(|a, b| a.0.cmp(&b.0));
101
102        for (fp, deps) in resolutions {
103            if let Some(node) = self.files.get_mut(&fp) {
104                node.depends_on = deps;
105            }
106        }
107
108        self.build_reverse_index();
109    }
110
111    fn build_reverse_index(&mut self) {
112        self.reverse.clear();
113        for (fp, node) in &self.files {
114            for dep in &node.depends_on {
115                self.reverse
116                    .entry(dep.clone())
117                    .or_default()
118                    .push(fp.clone());
119            }
120        }
121        for dependents in self.reverse.values_mut() {
122            dependents.sort();
123            dependents.dedup();
124        }
125    }
126
127    pub fn deps(&self, file_path: &str) -> Option<&FileNode> {
128        self.files.get(file_path)
129    }
130
131    pub fn file_paths(&self) -> Vec<&str> {
132        let mut paths: Vec<&str> = self.files.keys().map(|path| path.as_str()).collect();
133        paths.sort();
134        paths
135    }
136
137    pub fn files(&self) -> Vec<(&str, &FileNode)> {
138        let mut files: Vec<(&str, &FileNode)> = self
139            .files
140            .iter()
141            .map(|(path, node)| (path.as_str(), node))
142            .collect();
143        files.sort_by(|a, b| a.0.cmp(b.0));
144        files
145    }
146
147    pub(crate) fn hydrate_sources_from_root(&mut self, root: &Path) {
148        for (file_path, node) in &mut self.files {
149            if !node.source.is_empty() {
150                continue;
151            }
152            if let Ok(source) = std::fs::read_to_string(root.join(file_path)) {
153                node.source = source;
154            }
155        }
156    }
157
158    pub fn resolve_file_path(&self, file_path: &str) -> Option<String> {
159        for candidate in path_lookup_candidates(file_path) {
160            if self.files.contains_key(&candidate) {
161                return Some(candidate);
162            }
163        }
164
165        let normalized_input = file_path.replace('\\', "/");
166        if Path::new(file_path).is_absolute() {
167            return self
168                .files
169                .keys()
170                .find(|path| normalized_input.ends_with(path.as_str()))
171                .cloned();
172        }
173
174        None
175    }
176
177    pub fn dependents(&self, file_path: &str) -> Vec<&str> {
178        self.reverse
179            .get(file_path)
180            .map(|v| v.iter().map(|s| s.as_str()).collect())
181            .unwrap_or_default()
182    }
183
184    pub fn impact(&self, file_path: &str) -> Vec<String> {
185        let mut visited = HashSet::new();
186        let mut queue = VecDeque::new();
187        queue.push_back(file_path.to_string());
188        visited.insert(file_path.to_string());
189
190        while let Some(current) = queue.pop_front() {
191            if let Some(deps) = self.reverse.get(&current) {
192                for dep in deps {
193                    if visited.insert(dep.clone()) {
194                        queue.push_back(dep.clone());
195                    }
196                }
197            }
198        }
199
200        visited.remove(file_path);
201        let mut result: Vec<String> = visited.into_iter().collect();
202        result.sort();
203        result
204    }
205
206    pub fn orphans(&self) -> Vec<OrphanFile> {
207        let mut results = Vec::new();
208        for (fp, node) in &self.files {
209            if is_entry_point(fp) {
210                continue;
211            }
212            let dep_count = self.reverse.get(fp).map(|v| v.len()).unwrap_or(0);
213            if dep_count == 0 {
214                results.push(OrphanFile {
215                    file_path: fp.clone(),
216                    symbols: node.symbols.clone(),
217                    depends_on: node.depends_on.clone(),
218                });
219            }
220        }
221        results.sort_by(|a, b| a.file_path.cmp(&b.file_path));
222        results
223    }
224
225    pub fn unused_symbols(&self) -> Vec<UnusedSymbol> {
226        let mut defined: HashMap<String, Vec<(String, Symbol)>> = HashMap::new();
227        for (fp, node) in &self.files {
228            for sym in &node.symbols {
229                defined
230                    .entry(sym.name.clone())
231                    .or_default()
232                    .push((fp.clone(), sym.clone()));
233            }
234        }
235
236        let mut imported_names: HashSet<String> = HashSet::new();
237        for node in self.files.values() {
238            for imp in &node.raw_imports {
239                if let Some(last) = imp.rsplit(&[':', '.', '/', '\\']).next() {
240                    imported_names.insert(last.to_string());
241                    let lower = last.to_lowercase();
242                    if lower != *last {
243                        imported_names.insert(lower);
244                    }
245                }
246                imported_names.insert(imp.clone());
247            }
248        }
249
250        let mut results = Vec::new();
251        for (name, locations) in &defined {
252            if name == "main" || name == "new" || name == "default" || name == "lib" {
253                continue;
254            }
255            let referenced =
256                imported_names.contains(name) || imported_names.contains(&name.to_lowercase());
257            if !referenced && locations.len() == 1 {
258                let (fp, sym) = &locations[0];
259                if is_entry_point(fp) {
260                    continue;
261                }
262                if let Some(node) = self.files.get(fp) {
263                    if symbol_used_in_source(&node.source, name, sym.line) {
264                        continue;
265                    }
266                }
267                let dep_count = self.reverse.get(fp).map(|v| v.len()).unwrap_or(0);
268                if dep_count == 0 {
269                    results.push(UnusedSymbol {
270                        file_path: fp.clone(),
271                        symbol: sym.clone(),
272                    });
273                }
274            }
275        }
276        results.sort_by(|a, b| {
277            a.file_path
278                .cmp(&b.file_path)
279                .then(a.symbol.line.cmp(&b.symbol.line))
280        });
281        results
282    }
283
284    pub fn file_count(&self) -> usize {
285        self.files.len()
286    }
287
288    pub fn edge_count(&self) -> usize {
289        self.files.values().map(|n| n.depends_on.len()).sum()
290    }
291}
292
293fn extract_symbols(source: &str, language: &str, root: &Node) -> Vec<Symbol> {
294    let mut symbols = Vec::new();
295    let mut cursor = root.walk();
296
297    for child in root.children(&mut cursor) {
298        if let Some(sym) = extract_symbol_from_node(source, language, &child) {
299            symbols.push(sym);
300        }
301    }
302
303    symbols
304}
305
306fn extract_symbol_from_node(source: &str, language: &str, node: &Node) -> Option<Symbol> {
307    let kind = node.kind();
308    let (sym_kind, name) = match language {
309        "rust" => match kind {
310            "function_item" => ("function", find_child_text(source, node, "name")?),
311            "struct_item" => ("struct", find_child_text(source, node, "name")?),
312            "enum_item" => ("enum", find_child_text(source, node, "name")?),
313            "trait_item" => ("trait", find_child_text(source, node, "name")?),
314            "impl_item" => ("impl", find_child_text(source, node, "type")?),
315            "mod_item" => ("module", find_child_text(source, node, "name")?),
316            "const_item" => ("const", find_child_text(source, node, "name")?),
317            "static_item" => ("static", find_child_text(source, node, "name")?),
318            "type_item" => ("type_alias", find_child_text(source, node, "name")?),
319            "macro_definition" => ("macro", find_child_text(source, node, "name")?),
320            _ => return None,
321        },
322        "python" => match kind {
323            "function_definition" => ("function", find_child_text(source, node, "name")?),
324            "class_definition" => ("class", find_child_text(source, node, "name")?),
325            "decorated_definition" => {
326                let inner = node.child_by_field_name("definition")?;
327                return extract_symbol_from_node(source, language, &inner);
328            }
329            _ => return None,
330        },
331        language if is_js_ts_like(language) => match kind {
332            "function_declaration" => ("function", find_child_text(source, node, "name")?),
333            "class_declaration" => ("class", find_child_text(source, node, "name")?),
334            "interface_declaration" => ("interface", find_child_text(source, node, "name")?),
335            "type_alias_declaration" => ("type_alias", find_child_text(source, node, "name")?),
336            "enum_declaration" => ("enum", find_child_text(source, node, "name")?),
337            "export_statement" => {
338                let mut c = node.walk();
339                for child in node.children(&mut c) {
340                    match child.kind() {
341                        "function_declaration"
342                        | "class_declaration"
343                        | "interface_declaration"
344                        | "type_alias_declaration"
345                        | "enum_declaration"
346                        | "lexical_declaration" => {
347                            return extract_symbol_from_node(source, language, &child);
348                        }
349                        _ => {}
350                    }
351                }
352                return None;
353            }
354            "lexical_declaration" | "variable_declaration" => {
355                let name = find_variable_name(source, node)?;
356                ("const", name)
357            }
358            _ => return None,
359        },
360        "go" => match kind {
361            "function_declaration" => ("function", find_child_text(source, node, "name")?),
362            "method_declaration" => ("method", find_child_text(source, node, "name")?),
363            "type_declaration" => {
364                let mut c = node.walk();
365                for child in node.children(&mut c) {
366                    if child.kind() == "type_spec" {
367                        if let Some(name) = find_child_text(source, &child, "name") {
368                            return Some(Symbol {
369                                name,
370                                kind: "type".to_string(),
371                                line: node.start_position().row + 1,
372                            });
373                        }
374                    }
375                }
376                return None;
377            }
378            _ => return None,
379        },
380        "java" => match kind {
381            "class_declaration" => ("class", find_child_text(source, node, "name")?),
382            "interface_declaration" => ("interface", find_child_text(source, node, "name")?),
383            "enum_declaration" => ("enum", find_child_text(source, node, "name")?),
384            "method_declaration" => ("method", find_child_text(source, node, "name")?),
385            "record_declaration" => ("record", find_child_text(source, node, "name")?),
386            _ => return None,
387        },
388        "c" => match kind {
389            "function_definition" => (
390                "function",
391                find_declarator_name(source, node)
392                    .unwrap_or_else(|| node_text(source, node).chars().take(40).collect()),
393            ),
394            _ => return None,
395        },
396        "cpp" => match kind {
397            "function_definition" => (
398                "function",
399                find_declarator_name(source, node)
400                    .unwrap_or_else(|| node_text(source, node).chars().take(40).collect()),
401            ),
402            "class_specifier" => ("class", find_child_text(source, node, "name")?),
403            "struct_specifier" => ("struct", find_child_text(source, node, "name")?),
404            "namespace_definition" => ("namespace", find_child_text(source, node, "name")?),
405            _ => return None,
406        },
407        _ => return None,
408    };
409
410    Some(Symbol {
411        name,
412        kind: sym_kind.to_string(),
413        line: node.start_position().row + 1,
414    })
415}
416
417fn extract_imports(source: &str, language: &str, root: &Node) -> Vec<String> {
418    let mut imports = Vec::new();
419    let mut cursor = root.walk();
420
421    for child in root.children(&mut cursor) {
422        match language {
423            "rust" => match child.kind() {
424                "use_declaration" => {
425                    if let Some(text) = extract_rust_use_path(source, &child) {
426                        imports.push(text);
427                    }
428                }
429                "mod_item" => {
430                    if child.child_by_field_name("body").is_none() {
431                        if let Some(name) = find_child_text(source, &child, "name") {
432                            imports.push(format!("mod:{name}"));
433                        }
434                    }
435                }
436                _ => {}
437            },
438            "python" => match child.kind() {
439                "import_statement" => {
440                    if let Some(name) = find_child_by_kind(source, &child, "dotted_name") {
441                        imports.push(name);
442                    }
443                }
444                "import_from_statement" => {
445                    if let Some(name) = child.child_by_field_name("module_name") {
446                        imports.push(node_text(source, &name));
447                    }
448                }
449                _ => {}
450            },
451            language if is_js_ts_like(language) => {
452                if child.kind() == "import_statement" {
453                    if let Some(src) = child.child_by_field_name("source") {
454                        let text = node_text(source, &src);
455                        let cleaned = text.trim_matches(|c| c == '\'' || c == '"');
456                        imports.push(cleaned.to_string());
457                    }
458                }
459            }
460            "go" => {
461                if child.kind() == "import_declaration" {
462                    extract_go_imports(source, &child, &mut imports);
463                }
464            }
465            "java" => {
466                if child.kind() == "import_declaration" {
467                    let text = node_text(source, &child);
468                    let cleaned = text
469                        .trim_start_matches("import ")
470                        .trim_start_matches("static ")
471                        .trim_end_matches(';')
472                        .trim();
473                    imports.push(cleaned.to_string());
474                }
475            }
476            "c" | "cpp" => {
477                if child.kind() == "preproc_include" {
478                    if let Some(path) = child.child_by_field_name("path") {
479                        let text = node_text(source, &path);
480                        let cleaned = text.trim_matches(|c| c == '"' || c == '<' || c == '>');
481                        imports.push(cleaned.to_string());
482                    }
483                }
484            }
485            _ => {}
486        }
487    }
488
489    imports
490}
491
492fn extract_rust_use_path(source: &str, node: &Node) -> Option<String> {
493    let mut cursor = node.walk();
494    for child in node.children(&mut cursor) {
495        if child.kind() == "use_tree"
496            || child.kind() == "scoped_identifier"
497            || child.kind() == "identifier"
498        {
499            return Some(node_text(source, &child));
500        }
501    }
502    let text = node_text(source, node);
503    let trimmed = text.trim_start_matches("use ").trim_end_matches(';').trim();
504    Some(trimmed.to_string())
505}
506
507fn extract_go_imports(source: &str, node: &Node, imports: &mut Vec<String>) {
508    let mut cursor = node.walk();
509    for child in node.children(&mut cursor) {
510        if child.kind() == "import_spec_list" {
511            let mut inner_cursor = child.walk();
512            for spec in child.children(&mut inner_cursor) {
513                if spec.kind() == "import_spec" {
514                    if let Some(path) = spec.child_by_field_name("path") {
515                        let text = node_text(source, &path);
516                        let cleaned = text.trim_matches('"');
517                        imports.push(cleaned.to_string());
518                    }
519                }
520            }
521        } else if child.kind() == "import_spec" {
522            if let Some(path) = child.child_by_field_name("path") {
523                let text = node_text(source, &path);
524                let cleaned = text.trim_matches('"');
525                imports.push(cleaned.to_string());
526            }
527        }
528    }
529}
530
531fn find_variable_name(source: &str, node: &Node) -> Option<String> {
532    if let Some(d) = node.child_by_field_name("declarator") {
533        return find_child_text(source, &d, "name");
534    }
535    let mut c = node.walk();
536    for child in node.children(&mut c) {
537        if child.kind() == "variable_declarator" {
538            return find_child_text(source, &child, "name");
539        }
540    }
541    None
542}
543
544fn find_child_text(source: &str, node: &Node, field: &str) -> Option<String> {
545    node.child_by_field_name(field)
546        .map(|n| node_text(source, &n))
547}
548
549fn find_child_by_kind(source: &str, node: &Node, kind: &str) -> Option<String> {
550    let mut cursor = node.walk();
551    for child in node.children(&mut cursor) {
552        if child.kind() == kind {
553            return Some(node_text(source, &child));
554        }
555    }
556    None
557}
558
559fn find_declarator_name(source: &str, node: &Node) -> Option<String> {
560    let declarator = node.child_by_field_name("declarator")?;
561    if let Some(name) = declarator.child_by_field_name("declarator") {
562        return Some(node_text(source, &name));
563    }
564    Some(node_text(source, &declarator))
565}
566
567fn node_text(source: &str, node: &Node) -> String {
568    source[node.byte_range()].to_string()
569}
570
571const ENTRY_POINT_NAMES: &[&str] = &[
572    "main.rs",
573    "lib.rs",
574    "mod.rs",
575    "main.ts",
576    "main.tsx",
577    "main.js",
578    "main.jsx",
579    "index.ts",
580    "index.tsx",
581    "index.js",
582    "index.jsx",
583    "App.tsx",
584    "App.ts",
585    "App.js",
586    "App.jsx",
587    "app.tsx",
588    "app.ts",
589    "app.js",
590    "app.jsx",
591    "main.go",
592    "main.py",
593    "main.java",
594    "__init__.py",
595    "main.c",
596    "main.cpp",
597];
598
599fn symbol_used_in_source(source: &str, name: &str, def_line: usize) -> bool {
600    for (i, line) in source.lines().enumerate() {
601        let line_num = i + 1;
602        if line_num == def_line {
603            continue;
604        }
605        if line.contains(name) {
606            return true;
607        }
608    }
609    false
610}
611
612fn is_entry_point(file_path: &str) -> bool {
613    let name = Path::new(file_path)
614        .file_name()
615        .and_then(|n| n.to_str())
616        .unwrap_or("");
617    ENTRY_POINT_NAMES.contains(&name)
618}
619
620fn build_stem_index(paths: &[String]) -> HashMap<String, Vec<String>> {
621    let mut index: HashMap<String, Vec<String>> = HashMap::new();
622    for path in paths {
623        let p = Path::new(path);
624        if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
625            index
626                .entry(stem.to_lowercase())
627                .or_default()
628                .push(path.clone());
629        }
630    }
631    for candidates in index.values_mut() {
632        candidates.sort();
633        candidates.dedup();
634    }
635    index
636}
637
638fn resolve_import(
639    raw_import: &str,
640    source_file: &str,
641    stem_index: &HashMap<String, Vec<String>>,
642    all_paths: &[String],
643) -> Option<String> {
644    // Rust mod declaration: mod:foo → foo.rs or foo/mod.rs in same directory
645    if let Some(mod_name) = raw_import.strip_prefix("mod:") {
646        let source_dir = Path::new(source_file).parent().unwrap_or(Path::new(""));
647        let candidate1 = source_dir
648            .join(format!("{mod_name}.rs"))
649            .to_string_lossy()
650            .to_string();
651        let candidate2 = source_dir
652            .join(mod_name)
653            .join("mod.rs")
654            .to_string_lossy()
655            .to_string();
656        for path in all_paths {
657            if *path == candidate1 || *path == candidate2 {
658                return Some(path.clone());
659            }
660        }
661        if let Some(candidates) = stem_index.get(&mod_name.to_lowercase()) {
662            let source_dir_path = Path::new(source_file).parent().unwrap_or(Path::new(""));
663            return find_closest(candidates, source_dir_path);
664        }
665        return None;
666    }
667
668    // Rust: crate::module::item → module
669    if raw_import.starts_with("crate::") || raw_import.starts_with("super::") {
670        let parts: Vec<&str> = raw_import.split("::").collect();
671        // Try to find the module file from path segments
672        for i in (1..parts.len()).rev() {
673            let stem = parts[i].to_lowercase();
674            if let Some(candidates) = stem_index.get(&stem) {
675                let source_dir = Path::new(source_file).parent().unwrap_or(Path::new(""));
676                if let Some(best) = find_closest(candidates, source_dir) {
677                    return Some(best);
678                }
679            }
680        }
681        return None;
682    }
683
684    // Alias paths: @/lib/templates → src/lib/templates
685    if let Some(without_alias) = raw_import.strip_prefix("@/") {
686        let last_segment = Path::new(without_alias)
687            .file_stem()
688            .and_then(|s| s.to_str())
689            .unwrap_or(without_alias);
690        for path in all_paths {
691            let without_ext = Path::new(path).with_extension("");
692            let path_str = without_ext.to_string_lossy();
693            if path_str.ends_with(without_alias) {
694                return Some(path.clone());
695            }
696        }
697        if let Some(candidates) = stem_index.get(&last_segment.to_lowercase()) {
698            let source_dir = Path::new(source_file).parent().unwrap_or(Path::new(""));
699            return find_closest_with_stem(candidates, source_dir, last_segment);
700        }
701        return None;
702    }
703
704    // Relative paths: ./foo, ../foo
705    if raw_import.starts_with('.') {
706        let source_dir = Path::new(source_file).parent().unwrap_or(Path::new(""));
707        let candidate_base = normalize_path_to_slash(&source_dir.join(raw_import));
708        return resolve_relative_import(source_file, &candidate_base, all_paths);
709    }
710
711    // Python dotted imports: os.path → os/path.py
712    if raw_import.contains('.') && !raw_import.contains('/') {
713        let as_path = raw_import.replace('.', "/");
714        let last_part = raw_import.rsplit('.').next().unwrap_or(raw_import);
715        if let Some(candidates) = stem_index.get(&last_part.to_lowercase()) {
716            for c in candidates {
717                if c.replace('/', ".").contains(raw_import) || c.contains(&as_path) {
718                    return Some(c.clone());
719                }
720            }
721            if candidates.len() == 1 {
722                return Some(candidates[0].clone());
723            }
724        }
725        return None;
726    }
727
728    // Simple name match
729    let stem = Path::new(raw_import)
730        .file_stem()
731        .unwrap_or(raw_import.as_ref())
732        .to_string_lossy()
733        .to_lowercase();
734    if let Some(candidates) = stem_index.get(&stem) {
735        let source_dir = Path::new(source_file).parent().unwrap_or(Path::new(""));
736        return find_closest(candidates, source_dir);
737    }
738
739    None
740}
741
742fn resolve_relative_import(
743    source_file: &str,
744    candidate_base: &str,
745    all_paths: &[String],
746) -> Option<String> {
747    let index_base = format!("{candidate_base}/index");
748    let mut candidates = Vec::new();
749
750    for path in all_paths {
751        let without_ext = normalize_path_to_slash(&Path::new(path).with_extension(""));
752        let kind_rank = if without_ext == candidate_base {
753            0
754        } else if without_ext == index_base {
755            1
756        } else {
757            continue;
758        };
759        candidates.push((
760            kind_rank,
761            import_extension_rank(source_file, path),
762            path.clone(),
763        ));
764    }
765
766    candidates.sort();
767    candidates.into_iter().map(|(_, _, path)| path).next()
768}
769
770fn import_extension_rank(source_file: &str, candidate: &str) -> usize {
771    let source_ext = Path::new(source_file)
772        .extension()
773        .and_then(|ext| ext.to_str())
774        .unwrap_or("")
775        .to_ascii_lowercase();
776    let candidate_ext = Path::new(candidate)
777        .extension()
778        .and_then(|ext| ext.to_str())
779        .unwrap_or("")
780        .to_ascii_lowercase();
781
782    match source_ext.as_str() {
783        "ts" => match candidate_ext.as_str() {
784            "ts" => 0,
785            "tsx" => 1,
786            "js" => 2,
787            "jsx" => 3,
788            _ => 50,
789        },
790        "tsx" => match candidate_ext.as_str() {
791            "tsx" => 0,
792            "ts" => 1,
793            "jsx" => 2,
794            "js" => 3,
795            _ => 50,
796        },
797        "js" => match candidate_ext.as_str() {
798            "js" => 0,
799            "jsx" => 1,
800            "ts" => 2,
801            "tsx" => 3,
802            _ => 50,
803        },
804        "jsx" => match candidate_ext.as_str() {
805            "jsx" => 0,
806            "js" => 1,
807            "tsx" => 2,
808            "ts" => 3,
809            _ => 50,
810        },
811        _ if source_ext == candidate_ext => 0,
812        _ => 50,
813    }
814}
815
816fn find_closest(candidates: &[String], source_dir: &Path) -> Option<String> {
817    if candidates.len() == 1 {
818        return Some(candidates[0].clone());
819    }
820    let source_str = source_dir.to_string_lossy();
821    candidates
822        .iter()
823        .min_by(|a, b| {
824            let a_prefix = common_prefix_len(&source_str, a);
825            let b_prefix = common_prefix_len(&source_str, b);
826            b_prefix.cmp(&a_prefix).then_with(|| a.cmp(b))
827        })
828        .cloned()
829}
830
831fn find_closest_with_stem(
832    candidates: &[String],
833    source_dir: &Path,
834    exact_stem: &str,
835) -> Option<String> {
836    let exact_matches: Vec<&String> = candidates
837        .iter()
838        .filter(|c| {
839            Path::new(c.as_str())
840                .file_stem()
841                .and_then(|s| s.to_str())
842                .map(|s| s == exact_stem)
843                .unwrap_or(false)
844        })
845        .collect();
846
847    if exact_matches.len() == 1 {
848        return Some(exact_matches[0].clone());
849    }
850    if !exact_matches.is_empty() {
851        let source_str = source_dir.to_string_lossy();
852        return exact_matches
853            .iter()
854            .min_by(|a, b| {
855                let a_prefix = common_prefix_len(&source_str, a);
856                let b_prefix = common_prefix_len(&source_str, b);
857                b_prefix.cmp(&a_prefix).then_with(|| a.cmp(b))
858            })
859            .map(|c| (*c).clone());
860    }
861    find_closest(candidates, source_dir)
862}
863
864fn common_prefix_len(a: &str, b: &str) -> usize {
865    a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
866}
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871
872    #[test]
873    fn test_rust_symbols_and_imports() {
874        let source = r#"
875use crate::model::Chunk;
876use std::collections::HashMap;
877
878pub fn search(query: &str) -> Vec<Chunk> {
879    Vec::new()
880}
881
882struct Index {
883    chunks: Vec<Chunk>,
884}
885"#;
886        let mut graph = DependencyGraph::new();
887        graph.add_file("src/search.rs", source, "rust");
888        let node = graph.files.get("src/search.rs").unwrap();
889        assert_eq!(node.symbols.len(), 2);
890        assert_eq!(node.symbols[0].name, "search");
891        assert_eq!(node.symbols[0].kind, "function");
892        assert_eq!(node.symbols[1].name, "Index");
893        assert_eq!(node.symbols[1].kind, "struct");
894        assert!(node.raw_imports.len() >= 2);
895    }
896
897    #[test]
898    fn test_python_symbols_and_imports() {
899        let source = r#"
900import os
901from pathlib import Path
902
903class FileWalker:
904    def walk(self):
905        pass
906
907def main():
908    pass
909"#;
910        let mut graph = DependencyGraph::new();
911        graph.add_file("walker.py", source, "python");
912        let node = graph.files.get("walker.py").unwrap();
913        assert_eq!(node.symbols.len(), 2);
914        assert_eq!(node.symbols[0].name, "FileWalker");
915        assert_eq!(node.symbols[1].name, "main");
916        assert!(node.raw_imports.len() >= 2);
917    }
918
919    #[test]
920    fn test_impact_analysis() {
921        let mut graph = DependencyGraph::new();
922        graph.files.insert(
923            "a.rs".to_string(),
924            FileNode {
925                symbols: vec![],
926                raw_imports: vec![],
927                depends_on: vec!["b.rs".to_string()],
928                source: String::new(),
929            },
930        );
931        graph.files.insert(
932            "b.rs".to_string(),
933            FileNode {
934                symbols: vec![],
935                raw_imports: vec![],
936                depends_on: vec!["c.rs".to_string()],
937                source: String::new(),
938            },
939        );
940        graph.files.insert(
941            "c.rs".to_string(),
942            FileNode {
943                symbols: vec![],
944                raw_imports: vec![],
945                depends_on: vec![],
946                source: String::new(),
947            },
948        );
949        graph.build_reverse_index();
950
951        let impact = graph.impact("c.rs");
952        assert!(impact.contains(&"b.rs".to_string()));
953        assert!(impact.contains(&"a.rs".to_string()));
954    }
955
956    #[test]
957    fn test_javascript_imports() {
958        let source = r#"
959import { useState } from 'react';
960import utils from './utils';
961
962function App() {
963    return null;
964}
965"#;
966        let mut graph = DependencyGraph::new();
967        graph.add_file("src/App.js", source, "javascript");
968        let node = graph.files.get("src/App.js").unwrap();
969        assert!(node.raw_imports.contains(&"react".to_string()));
970        assert!(node.raw_imports.contains(&"./utils".to_string()));
971    }
972
973    #[test]
974    fn test_relative_parent_import_resolves_to_local_file() {
975        let mut graph = DependencyGraph::new();
976        graph.add_file(
977            "src/components/App.ts",
978            "import { helper } from '../utils/helpers';\nexport function App() { return helper(); }",
979            "typescript",
980        );
981        graph.add_file(
982            "src/utils/helpers.ts",
983            "export function helper() { return 1; }",
984            "typescript",
985        );
986        graph.resolve_dependencies();
987
988        let node = graph.deps("src/components/App.ts").unwrap();
989        assert_eq!(node.depends_on, vec!["src/utils/helpers.ts".to_string()]);
990    }
991
992    #[test]
993    fn test_resolve_file_path_normalizes_cli_input() {
994        let mut graph = DependencyGraph::new();
995        graph.files.insert(
996            "src/main.rs".to_string(),
997            FileNode {
998                symbols: vec![],
999                raw_imports: vec![],
1000                depends_on: vec![],
1001                source: String::new(),
1002            },
1003        );
1004
1005        assert_eq!(
1006            graph.resolve_file_path("src/main.rs"),
1007            Some("src/main.rs".to_string())
1008        );
1009        assert_eq!(
1010            graph.resolve_file_path("./src/main.rs"),
1011            Some("src/main.rs".to_string())
1012        );
1013    }
1014
1015    #[test]
1016    fn test_tsx_symbols_and_imports() {
1017        let source = r#"
1018import React from 'react';
1019import { helper } from './helper';
1020
1021export function Button() {
1022    return <button>{helper()}</button>;
1023}
1024"#;
1025        let mut graph = DependencyGraph::new();
1026        graph.add_file("src/Button.tsx", source, "tsx");
1027        graph.add_file(
1028            "src/helper.ts",
1029            "export function helper() { return 'ok'; }",
1030            "typescript",
1031        );
1032        graph.resolve_dependencies();
1033
1034        let node = graph.deps("src/Button.tsx").unwrap();
1035        let names: Vec<&str> = node.symbols.iter().map(|s| s.name.as_str()).collect();
1036        assert!(names.contains(&"Button"), "missing Button, got: {names:?}");
1037        assert!(node.raw_imports.contains(&"./helper".to_string()));
1038        assert_eq!(node.depends_on, vec!["src/helper.ts".to_string()]);
1039    }
1040
1041    #[test]
1042    fn test_typescript_export_symbols() {
1043        let source = r#"
1044import { db } from './firebase';
1045
1046export async function getUser(uid: string) {
1047    return null;
1048}
1049
1050export const createPage = async (data: any) => {
1051    return null;
1052};
1053
1054export type PageData = {
1055    slug: string;
1056};
1057
1058export interface UserProfile {
1059    name: string;
1060}
1061
1062const internal = () => {};
1063
1064export default function MainComponent() {
1065    return null;
1066}
1067"#;
1068        let mut graph = DependencyGraph::new();
1069        graph.add_file("src/lib/firestore.ts", source, "typescript");
1070        let node = graph.files.get("src/lib/firestore.ts").unwrap();
1071        let names: Vec<&str> = node.symbols.iter().map(|s| s.name.as_str()).collect();
1072        assert!(
1073            names.contains(&"getUser"),
1074            "missing getUser, got: {names:?}"
1075        );
1076        assert!(
1077            names.contains(&"createPage"),
1078            "missing createPage, got: {names:?}"
1079        );
1080        assert!(
1081            names.contains(&"PageData"),
1082            "missing PageData, got: {names:?}"
1083        );
1084        assert!(
1085            names.contains(&"UserProfile"),
1086            "missing UserProfile, got: {names:?}"
1087        );
1088        assert!(
1089            names.contains(&"MainComponent"),
1090            "missing MainComponent, got: {names:?}"
1091        );
1092        assert!(
1093            names.contains(&"internal"),
1094            "missing internal, got: {names:?}"
1095        );
1096    }
1097
1098    #[test]
1099    fn dependency_resolution_is_sorted_and_stable() {
1100        let mut graph = DependencyGraph::new();
1101        graph.add_file(
1102            "src/app.ts",
1103            "import { z } from './z';\nimport { a } from './a';\nexport function app() { return a() + z(); }",
1104            "typescript",
1105        );
1106        graph.add_file(
1107            "src/z.ts",
1108            "export function z() { return 1; }",
1109            "typescript",
1110        );
1111        graph.add_file(
1112            "src/a.ts",
1113            "export function a() { return 1; }",
1114            "typescript",
1115        );
1116
1117        graph.resolve_dependencies();
1118
1119        let node = graph.deps("src/app.ts").unwrap();
1120        assert_eq!(
1121            node.depends_on,
1122            vec!["src/a.ts".to_string(), "src/z.ts".to_string()]
1123        );
1124        assert_eq!(graph.dependents("src/a.ts"), vec!["src/app.ts"]);
1125        assert_eq!(
1126            graph.file_paths(),
1127            vec!["src/a.ts", "src/app.ts", "src/z.ts"]
1128        );
1129    }
1130
1131    #[test]
1132    fn typescript_relative_import_prefers_typescript_over_same_stem_rust_file() {
1133        let mut graph = DependencyGraph::new();
1134        graph.add_file(
1135            "src/app.ts",
1136            "import { retryBackoff } from './retry';\nexport function app() { return retryBackoff(); }",
1137            "typescript",
1138        );
1139        graph.add_file(
1140            "src/retry.rs",
1141            "pub fn retry_backoff() -> u64 { 100 }",
1142            "rust",
1143        );
1144        graph.add_file(
1145            "src/retry.ts",
1146            "export function retryBackoff() { return 100; }",
1147            "typescript",
1148        );
1149
1150        graph.resolve_dependencies();
1151
1152        let node = graph.deps("src/app.ts").unwrap();
1153        assert_eq!(node.depends_on, vec!["src/retry.ts".to_string()]);
1154        assert_eq!(graph.dependents("src/retry.ts"), vec!["src/app.ts"]);
1155        assert!(graph.dependents("src/retry.rs").is_empty());
1156    }
1157
1158    #[test]
1159    fn hydrate_sources_restores_skipped_cached_source() {
1160        let unique = std::time::SystemTime::now()
1161            .duration_since(std::time::UNIX_EPOCH)
1162            .expect("system time should be after unix epoch")
1163            .as_nanos();
1164        let root = std::env::temp_dir().join(format!("asr-graph-hydrate-{unique}"));
1165        std::fs::create_dir_all(root.join("src")).expect("source root should be created");
1166        std::fs::write(
1167            root.join("src/feature.rs"),
1168            "pub fn used() { helper(); }\nfn helper() {}\n",
1169        )
1170        .expect("source should be written");
1171
1172        let mut graph = DependencyGraph::new();
1173        graph.files.insert(
1174            "src/feature.rs".to_string(),
1175            FileNode {
1176                symbols: vec![Symbol {
1177                    name: "helper".to_string(),
1178                    kind: "function".to_string(),
1179                    line: 2,
1180                }],
1181                raw_imports: vec![],
1182                depends_on: vec![],
1183                source: String::new(),
1184            },
1185        );
1186
1187        assert_eq!(graph.unused_symbols().len(), 1);
1188        graph.hydrate_sources_from_root(&root);
1189        assert!(graph.unused_symbols().is_empty());
1190
1191        let _ = std::fs::remove_dir_all(root);
1192    }
1193}