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