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(¤t) {
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 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 if raw_import.starts_with("crate::") || raw_import.starts_with("super::") {
670 let parts: Vec<&str> = raw_import.split("::").collect();
671 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 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 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 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 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}