1pub(crate) mod parsers;
2mod resolvers;
3
4use crate::db::{IndexDb, index_db_path};
5use crate::project::{ProjectRoot, collect_files};
6use anyhow::{Result, bail};
7use serde::Serialize;
8use std::collections::{HashMap, HashSet, VecDeque};
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::{Arc, Mutex};
12
13pub use parsers::extract_imports_for_file;
16pub use parsers::extract_imports_from_source;
17pub use resolvers::resolve_module_for_file;
18
19pub fn is_import_supported(ext: &str) -> bool {
21 crate::lang_registry::supports_imports(ext)
22}
23
24#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
27pub struct BlastRadiusEntry {
28 pub file: String,
29 pub depth: usize,
30}
31
32#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
33pub struct ImporterEntry {
34 pub file: String,
35}
36
37#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
38pub struct ImportanceEntry {
39 pub file: String,
40 pub score: String,
41}
42
43#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
44pub struct DeadCodeEntry {
45 pub file: String,
46 pub symbol: Option<String>,
47 pub reason: String,
48}
49
50#[derive(Debug, Clone)]
51pub struct FileNode {
52 pub(crate) imports: HashSet<String>,
53 pub(crate) imported_by: HashSet<String>,
54}
55
56pub struct GraphCache {
59 inner: Mutex<GraphCacheInner>,
60 generation: AtomicU64,
62}
63
64struct GraphCacheInner {
65 graph: Option<Arc<HashMap<String, FileNode>>>,
66 pagerank: Option<Arc<HashMap<String, f64>>>,
70 semantic_scores: Option<Arc<HashMap<String, f64>>>,
73 built_generation: u64,
75}
76
77impl GraphCache {
78 pub fn new(_ttl_secs: u64) -> Self {
81 Self {
82 inner: Mutex::new(GraphCacheInner {
83 graph: None,
84 pagerank: None,
85 semantic_scores: None,
86 built_generation: 0,
87 }),
88 generation: AtomicU64::new(1), }
90 }
91
92 pub fn get_or_build(&self, project: &ProjectRoot) -> Result<Arc<HashMap<String, FileNode>>> {
93 let current_gen = self.generation.load(Ordering::Acquire);
94 let mut inner = self
95 .inner
96 .lock()
97 .map_err(|_| anyhow::anyhow!("graph cache lock poisoned"))?;
98 if let Some(graph) = &inner.graph
99 && inner.built_generation == current_gen
100 {
101 return Ok(Arc::clone(graph));
102 }
103 let graph = Arc::new(build_graph(project)?);
104 inner.graph = Some(Arc::clone(&graph));
105 inner.pagerank = None;
111 inner.built_generation = current_gen;
112 Ok(graph)
113 }
114
115 pub fn file_pagerank_scores(&self, project: &ProjectRoot) -> Arc<HashMap<String, f64>> {
120 let current_gen = self.generation.load(Ordering::Acquire);
123 if let Ok(inner) = self.inner.lock()
124 && inner.built_generation == current_gen
125 && let Some(pr) = inner.pagerank.as_ref()
126 {
127 let scores = if let Some(semantic) = inner.semantic_scores.as_ref() {
128 let merged: HashMap<String, f64> = pr
129 .iter()
130 .map(|(file, pr_score)| {
131 let sem_boost = semantic.get(file).copied().unwrap_or(0.0);
132 let boosted = pr_score * (1.0 + sem_boost).min(5.0);
134 (file.clone(), boosted)
135 })
136 .collect();
137 Arc::new(merged)
138 } else {
139 Arc::clone(pr)
140 };
141 return scores;
142 }
143 let graph = match self.get_or_build(project) {
145 Ok(g) => g,
146 Err(_) => return Arc::new(HashMap::new()),
147 };
148 let pr = Arc::new(compute_pagerank(&graph));
149 if let Ok(mut inner) = self.inner.lock() {
150 let sem = inner.semantic_scores.as_ref();
151 let result = if let Some(semantic) = sem {
152 let merged: HashMap<String, f64> = pr
153 .iter()
154 .map(|(file, pr_score)| {
155 let sem_boost = semantic.get(file).copied().unwrap_or(0.0);
156 let boosted = pr_score * (1.0 + sem_boost).min(5.0);
157 (file.clone(), boosted)
158 })
159 .collect();
160 Arc::new(merged)
161 } else {
162 Arc::clone(&pr)
163 };
164 let still_current = self.generation.load(Ordering::Acquire);
169 if inner.built_generation == still_current {
170 inner.pagerank = Some(pr);
171 }
172 result
173 } else {
174 Arc::clone(&pr)
175 }
176 }
177
178 pub fn invalidate(&self) {
180 self.generation.fetch_add(1, Ordering::Release);
181 }
182
183 pub fn generation(&self) -> u64 {
185 self.generation.load(Ordering::Relaxed)
186 }
187
188 pub fn inject_semantic_scores(&self, scores: Arc<HashMap<String, f64>>) {
192 if let Ok(mut inner) = self.inner.lock() {
193 inner.semantic_scores = Some(scores);
194 }
195 }
196}
197
198pub fn supports_import_graph(file_path: &str) -> bool {
201 crate::lang_registry::supports_imports_for_path(Path::new(file_path))
202}
203
204pub fn get_blast_radius(
205 project: &ProjectRoot,
206 file_path: &str,
207 max_depth: usize,
208 cache: &GraphCache,
209) -> Result<Vec<BlastRadiusEntry>> {
210 if !supports_import_graph(file_path) {
211 bail!("unsupported import-graph language for '{file_path}'");
212 }
213
214 let graph = cache.get_or_build(project)?;
215 let target = normalize_key(file_path);
216 let mut result = HashMap::new();
217 let mut queue = VecDeque::from([(target.clone(), 0usize)]);
218
219 while let Some((current, depth)) = queue.pop_front() {
220 if depth > max_depth || result.contains_key(¤t) {
221 continue;
222 }
223 if current != target {
224 result.insert(current.clone(), depth);
225 }
226
227 let Some(node) = graph.get(¤t) else {
228 continue;
229 };
230 for importer in &node.imported_by {
231 if !result.contains_key(importer) {
232 queue.push_back((importer.clone(), depth + 1));
233 }
234 }
235 }
236
237 let mut entries: Vec<_> = result
238 .into_iter()
239 .map(|(file, depth)| BlastRadiusEntry { file, depth })
240 .collect();
241 entries.sort_by(|a, b| a.depth.cmp(&b.depth).then(a.file.cmp(&b.file)));
242 Ok(entries)
243}
244
245pub fn get_importers(
246 project: &ProjectRoot,
247 file_path: &str,
248 max_results: usize,
249 cache: &GraphCache,
250) -> Result<Vec<ImporterEntry>> {
251 if !supports_import_graph(file_path) {
252 bail!("unsupported import-graph language for '{file_path}'");
253 }
254
255 let graph = cache.get_or_build(project)?;
256 let target = normalize_key(file_path);
257 let importers = graph
258 .get(&target)
259 .map(|node| {
260 let mut entries = node
261 .imported_by
262 .iter()
263 .cloned()
264 .map(|file| ImporterEntry { file })
265 .collect::<Vec<_>>();
266 entries.sort_by(|a, b| a.file.cmp(&b.file));
267 if max_results > 0 && entries.len() > max_results {
268 entries.truncate(max_results);
269 }
270 entries
271 })
272 .unwrap_or_default();
273 Ok(importers)
274}
275
276fn compute_pagerank(graph: &HashMap<String, FileNode>) -> HashMap<String, f64> {
278 if graph.is_empty() {
279 return HashMap::new();
280 }
281 let damping = 0.85;
282 let n = graph.len() as f64;
283 let mut scores: HashMap<String, f64> = graph.keys().cloned().map(|k| (k, 1.0 / n)).collect();
284 let out_degree: HashMap<&str, usize> = graph
285 .iter()
286 .map(|(k, node)| (k.as_str(), node.imports.len()))
287 .collect();
288 for _ in 0..20 {
289 let mut next: HashMap<String, f64> = HashMap::new();
290 for (key, node) in graph.iter() {
291 let mut incoming = 0.0;
292 for importer in &node.imported_by {
293 let importer_score = scores.get(importer).copied().unwrap_or(0.0);
294 let degree = out_degree
295 .get(importer.as_str())
296 .copied()
297 .unwrap_or(1)
298 .max(1) as f64;
299 incoming += importer_score / degree;
300 }
301 next.insert(key.clone(), (1.0 - damping) / n + damping * incoming);
302 }
303 scores = next;
304 }
305 scores
306}
307
308pub fn get_importance(
309 project: &ProjectRoot,
310 top_n: usize,
311 cache: &GraphCache,
312) -> Result<Vec<ImportanceEntry>> {
313 let graph = cache.get_or_build(project)?;
314 let scores = compute_pagerank(&graph);
315
316 let mut ranked: Vec<_> = scores.into_iter().collect();
317 ranked.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
318 let mut entries: Vec<_> = ranked
319 .into_iter()
320 .map(|(file, score)| ImportanceEntry {
321 file,
322 score: format!("{score:.4}"),
323 })
324 .collect();
325 if top_n > 0 && entries.len() > top_n {
326 entries.truncate(top_n);
327 }
328 Ok(entries)
329}
330
331pub(crate) fn build_graph_pub(
333 project: &ProjectRoot,
334 cache: &GraphCache,
335) -> Result<Arc<HashMap<String, FileNode>>> {
336 cache.get_or_build(project)
337}
338
339fn build_graph(project: &ProjectRoot) -> Result<HashMap<String, FileNode>> {
342 let db_path = index_db_path(project.as_path());
344 if db_path.is_file()
345 && let Ok(db) = IndexDb::open(&db_path)
346 && db.file_count()? > 0
347 {
348 return build_graph_from_db(&db);
349 }
350
351 build_graph_from_files(project)
353}
354
355fn build_graph_from_db(db: &IndexDb) -> Result<HashMap<String, FileNode>> {
356 let db_graph = db.build_import_graph()?;
357 let mut graph = HashMap::new();
358 for (path, (imports, imported_by)) in db_graph {
359 graph.insert(
360 path,
361 FileNode {
362 imports: imports.into_iter().collect(),
363 imported_by: imported_by.into_iter().collect(),
364 },
365 );
366 }
367 Ok(graph)
368}
369
370fn build_graph_from_files(project: &ProjectRoot) -> Result<HashMap<String, FileNode>> {
371 let files = collect_candidate_files(project.as_path())?;
372 let mut graph = HashMap::new();
373
374 for file in &files {
375 let rel = project.to_relative(file);
376 let imports = parsers::extract_imports(file)
377 .into_iter()
378 .filter_map(|module| resolvers::resolve_module(project, file, &module))
379 .collect::<HashSet<_>>();
380 graph.insert(
381 rel.clone(),
382 FileNode {
383 imports,
384 imported_by: HashSet::new(),
385 },
386 );
387 }
388
389 let edges: Vec<(String, String)> = graph
390 .iter()
391 .flat_map(|(from_file, node)| {
392 node.imports
393 .iter()
394 .cloned()
395 .map(|to_file| (from_file.clone(), to_file))
396 .collect::<Vec<_>>()
397 })
398 .collect();
399
400 for (from_file, to_file) in edges {
401 if let Some(node) = graph.get_mut(&to_file) {
402 node.imported_by.insert(from_file);
403 }
404 }
405
406 Ok(graph)
407}
408
409pub(crate) fn collect_candidate_files(root: &Path) -> Result<Vec<PathBuf>> {
410 collect_files(root, |path| {
411 crate::lang_registry::supports_imports_for_path(path)
412 })
413}
414
415fn normalize_key(file_path: &str) -> String {
416 file_path.replace('\\', "/")
417}
418
419#[cfg(test)]
422mod tests {
423 use super::{
424 GraphCache, get_blast_radius, get_importance, get_importers, supports_import_graph,
425 };
426 use crate::ProjectRoot;
427 use crate::dead_code::find_dead_code;
428 use std::fs;
429
430 #[test]
431 fn calculates_python_blast_radius() {
432 let dir = temp_project_dir("python");
433 fs::write(
434 dir.join("main.py"),
435 "from utils import greet\n\ndef main():\n return greet()\n",
436 )
437 .expect("write main");
438 fs::write(
439 dir.join("utils.py"),
440 "from models import User\n\ndef greet():\n return User()\n",
441 )
442 .expect("write utils");
443 fs::write(dir.join("models.py"), "class User:\n pass\n").expect("write models");
444
445 let project = ProjectRoot::new(&dir).expect("project");
446 let cache = GraphCache::new(0);
447 let radius = get_blast_radius(&project, "models.py", 3, &cache).expect("blast radius");
448 assert_eq!(
449 radius,
450 vec![
451 super::BlastRadiusEntry {
452 file: "utils.py".to_owned(),
453 depth: 1,
454 },
455 super::BlastRadiusEntry {
456 file: "main.py".to_owned(),
457 depth: 2,
458 },
459 ]
460 );
461 }
462
463 #[test]
464 fn calculates_typescript_blast_radius() {
465 let dir = temp_project_dir("typescript");
466 fs::create_dir_all(dir.join("lib")).expect("mkdir");
467 fs::write(
468 dir.join("app.ts"),
469 "import { greet } from './lib/greet'\nconsole.log(greet())\n",
470 )
471 .expect("write app");
472 fs::write(
473 dir.join("lib/greet.ts"),
474 "import { User } from './user'\nexport const greet = () => new User()\n",
475 )
476 .expect("write greet");
477 fs::write(dir.join("lib/user.ts"), "export class User {}\n").expect("write user");
478
479 let project = ProjectRoot::new(&dir).expect("project");
480 let cache = GraphCache::new(0);
481 let radius = get_blast_radius(&project, "lib/user.ts", 3, &cache).expect("blast radius");
482 assert_eq!(
483 radius,
484 vec![
485 super::BlastRadiusEntry {
486 file: "lib/greet.ts".to_owned(),
487 depth: 1,
488 },
489 super::BlastRadiusEntry {
490 file: "app.ts".to_owned(),
491 depth: 2,
492 },
493 ]
494 );
495 }
496
497 #[test]
498 fn reports_supported_extensions() {
499 assert!(supports_import_graph("main.py"));
500 assert!(supports_import_graph("main.ts"));
501 assert!(supports_import_graph("Main.java"));
502 assert!(supports_import_graph("main.go"));
503 assert!(supports_import_graph("main.kt"));
504 assert!(supports_import_graph("main.rs"));
505 assert!(supports_import_graph("main.rb"));
506 assert!(supports_import_graph("main.c"));
507 assert!(supports_import_graph("main.cpp"));
508 assert!(supports_import_graph("main.h"));
509 assert!(supports_import_graph("main.php"));
510 assert!(supports_import_graph("main.swift"));
511 assert!(supports_import_graph("main.scala"));
512 assert!(supports_import_graph("main.css"));
513 }
514
515 #[test]
516 fn extracts_go_imports() {
517 let content = r#"
518package main
519
520import "fmt"
521import (
522 "os"
523 "path/filepath"
524)
525"#;
526 let imports = super::parsers::extract_go_imports(content);
527 assert!(imports.contains(&"fmt".to_owned()), "single import");
528 assert!(imports.contains(&"os".to_owned()), "block import os");
529 assert!(
530 imports.contains(&"path/filepath".to_owned()),
531 "block import path"
532 );
533 }
534
535 #[test]
536 fn extracts_java_imports() {
537 let content = "import com.example.Foo;\nimport static com.example.Utils.helper;\n";
538 let imports = super::parsers::extract_java_imports(content);
539 assert!(imports.contains(&"com.example.Foo".to_owned()));
540 assert!(imports.contains(&"com.example.Utils.helper".to_owned()));
541 }
542
543 #[test]
544 fn extracts_kotlin_imports() {
545 let content = "import com.example.Foo\nimport com.example.Bar as B\n";
546 let imports = super::parsers::extract_kotlin_imports(content);
547 assert!(imports.contains(&"com.example.Foo".to_owned()));
548 assert!(imports.contains(&"com.example.Bar".to_owned()));
549 }
550
551 #[test]
552 fn extracts_rust_imports() {
553 let content = "use crate::utils;\nuse super::models;\nmod config;\n";
554 let imports = super::parsers::extract_rust_imports(content);
555 assert!(imports.contains(&"crate::utils".to_owned()));
556 assert!(imports.contains(&"super::models".to_owned()));
557 assert!(imports.contains(&"config".to_owned()));
558 }
559
560 #[test]
561 fn extracts_rust_pub_mod_and_pub_use() {
562 let content =
563 "pub mod symbols;\npub(crate) mod db;\npub use crate::project::ProjectRoot;\n";
564 let imports = super::parsers::extract_rust_imports(content);
565 assert!(
566 imports.contains(&"symbols".to_owned()),
567 "pub mod should be captured"
568 );
569 assert!(
570 imports.contains(&"db".to_owned()),
571 "pub(crate) mod should be captured"
572 );
573 assert!(
574 imports.contains(&"crate::project::ProjectRoot".to_owned()),
575 "pub use should be captured"
576 );
577 }
578
579 #[test]
580 fn extracts_rust_brace_group_imports() {
581 let content = "use crate::{symbols, db};\nuse crate::foo::{Bar, Baz};\n";
582 let imports = super::parsers::extract_rust_imports(content);
583 assert!(
584 imports.contains(&"crate::symbols".to_owned()),
585 "brace group item 1"
586 );
587 assert!(
588 imports.contains(&"crate::db".to_owned()),
589 "brace group item 2"
590 );
591 assert!(
592 imports.contains(&"crate::foo::Bar".to_owned()),
593 "nested brace 1"
594 );
595 assert!(
596 imports.contains(&"crate::foo::Baz".to_owned()),
597 "nested brace 2"
598 );
599 }
600
601 #[test]
602 fn extracts_ruby_imports() {
603 let content = "require \"json\"\nrequire_relative \"../lib/helper\"\nload \"tasks.rb\"\n";
604 let imports = super::parsers::extract_ruby_imports(content);
605 assert!(imports.contains(&"json".to_owned()));
606 assert!(imports.contains(&"../lib/helper".to_owned()));
607 assert!(imports.contains(&"tasks.rb".to_owned()));
608 }
609
610 #[test]
611 fn extracts_c_imports() {
612 let content = "#include \"mylib.h\"\n#include <stdio.h>\n";
613 let imports = super::parsers::extract_c_imports(content);
614 assert!(imports.contains(&"mylib.h".to_owned()));
615 assert!(imports.contains(&"stdio.h".to_owned()));
616 }
617
618 #[test]
619 fn extracts_php_imports() {
620 let content =
621 "use App\\Http\\Controllers\\HomeController;\nrequire \"vendor/autoload.php\";\n";
622 let imports = super::parsers::extract_php_imports(content);
623 assert!(imports.contains(&"App\\Http\\Controllers\\HomeController".to_owned()));
624 assert!(imports.contains(&"vendor/autoload.php".to_owned()));
625 }
626
627 #[test]
628 fn returns_importers() {
629 let dir = temp_project_dir("importers");
630 fs::write(
631 dir.join("main.py"),
632 "from utils import greet\n\ndef main():\n return greet()\n",
633 )
634 .expect("write main");
635 fs::write(
636 dir.join("worker.py"),
637 "from utils import greet\n\ndef run():\n return greet()\n",
638 )
639 .expect("write worker");
640 fs::write(dir.join("utils.py"), "def greet():\n return 1\n").expect("write utils");
641
642 let project = ProjectRoot::new(&dir).expect("project");
643 let cache = GraphCache::new(0);
644 let importers = get_importers(&project, "utils.py", 10, &cache).expect("importers");
645 assert_eq!(
646 importers,
647 vec![
648 super::ImporterEntry {
649 file: "main.py".to_owned(),
650 },
651 super::ImporterEntry {
652 file: "worker.py".to_owned(),
653 },
654 ]
655 );
656 }
657
658 #[test]
659 fn returns_importance_ranking() {
660 let dir = temp_project_dir("importance");
661 fs::write(
662 dir.join("main.py"),
663 "from utils import greet\n\ndef main():\n return greet()\n",
664 )
665 .expect("write main");
666 fs::write(
667 dir.join("worker.py"),
668 "from utils import greet\n\ndef run():\n return greet()\n",
669 )
670 .expect("write worker");
671 fs::write(
672 dir.join("utils.py"),
673 "from models import User\n\ndef greet():\n return User()\n",
674 )
675 .expect("write utils");
676 fs::write(dir.join("models.py"), "class User:\n pass\n").expect("write models");
677
678 let project = ProjectRoot::new(&dir).expect("project");
679 let cache = GraphCache::new(0);
680 let ranking = get_importance(&project, 10, &cache).expect("importance");
681 assert!(!ranking.is_empty());
682 assert_eq!(
683 ranking.first().map(|it| it.file.as_str()),
684 Some("models.py")
685 );
686 assert!(ranking.iter().all(|it| !it.score.is_empty()));
687 }
688
689 #[test]
690 fn returns_dead_code_candidates() {
691 let dir = temp_project_dir("dead-code");
692 fs::write(
693 dir.join("main.py"),
694 "from utils import greet\n\ndef main():\n return greet()\n",
695 )
696 .expect("write main");
697 fs::write(dir.join("utils.py"), "def greet():\n return 1\n").expect("write utils");
698 fs::write(dir.join("unused.py"), "def helper():\n return 2\n").expect("write unused");
699
700 let project = ProjectRoot::new(&dir).expect("project");
701 let cache = GraphCache::new(0);
702 let dead = find_dead_code(&project, 10, &cache).expect("dead code");
703 assert_eq!(
704 dead,
705 vec![
706 super::DeadCodeEntry {
707 file: "main.py".to_owned(),
708 symbol: None,
709 reason: "no importers".to_owned(),
710 },
711 super::DeadCodeEntry {
712 file: "unused.py".to_owned(),
713 symbol: None,
714 reason: "no importers".to_owned(),
715 },
716 ]
717 );
718 }
719
720 #[test]
721 fn resolves_cross_crate_workspace_imports() {
722 let dir = temp_project_dir("cross-crate");
723 let core_src = dir.join("crates").join("codelens-core").join("src");
724 let mcp_src = dir.join("crates").join("codelens-mcp").join("src");
725 fs::create_dir_all(&core_src).expect("mkdir core/src");
726 fs::create_dir_all(&mcp_src).expect("mkdir mcp/src");
727
728 fs::write(
729 dir.join("crates").join("codelens-core").join("Cargo.toml"),
730 "[package]\nname = \"codelens-core\"\n",
731 )
732 .expect("write core Cargo.toml");
733 fs::write(
734 dir.join("crates").join("codelens-mcp").join("Cargo.toml"),
735 "[package]\nname = \"codelens-mcp\"\n",
736 )
737 .expect("write mcp Cargo.toml");
738
739 fs::write(core_src.join("project.rs"), "pub struct ProjectRoot;\n")
740 .expect("write project.rs");
741
742 let main_rs = mcp_src.join("main.rs");
743 fs::write(
744 &main_rs,
745 "use codelens_core::project::ProjectRoot;\nfn main() {}\n",
746 )
747 .expect("write main.rs");
748
749 let project = ProjectRoot::new(&dir).expect("project");
750
751 let resolved = super::resolvers::resolve_module_for_file(
752 &project,
753 &main_rs,
754 "codelens_core::project::ProjectRoot",
755 );
756 assert_eq!(
757 resolved,
758 Some("crates/codelens-core/src/project.rs".to_owned()),
759 "cross-crate import should resolve to crates/codelens-core/src/project.rs"
760 );
761 }
762
763 fn temp_project_dir(name: &str) -> std::path::PathBuf {
764 let dir = std::env::temp_dir().join(format!(
765 "codelens-core-import-graph-{name}-{}",
766 std::time::SystemTime::now()
767 .duration_since(std::time::UNIX_EPOCH)
768 .expect("time")
769 .as_nanos()
770 ));
771 fs::create_dir_all(&dir).expect("create tempdir");
772 dir
773 }
774}