Skip to main content

codelens_engine/import_graph/
mod.rs

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
14// ── Re-exports ───────────────────────────────────────────────────────────────
15
16pub 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
21/// Use lang_registry as the single source of truth for supported extensions.
22pub fn is_import_supported(ext: &str) -> bool {
23    crate::lang_registry::supports_imports(ext)
24}
25
26// ── Types ────────────────────────────────────────────────────────────────────
27
28#[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
58// ── GraphCache ───────────────────────────────────────────────────────────────
59
60pub struct GraphCache {
61    inner: Mutex<GraphCacheInner>,
62    /// Monotonically increasing counter -- bumped on every invalidation.
63    generation: AtomicU64,
64}
65
66struct GraphCacheInner {
67    graph: Option<Arc<HashMap<String, FileNode>>>,
68    /// Generation at which this cache entry was built.
69    built_generation: u64,
70}
71
72impl GraphCache {
73    /// Create a new cache.  The `_ttl_secs` parameter is kept for API
74    /// compatibility but no longer used -- invalidation is generation-based.
75    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), // start at 1 so default 0 is always stale
82        }
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    /// Return per-file PageRank scores from the cached graph.
103    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    /// Bump the generation counter, causing the next `get_or_build` to rebuild.
112    pub fn invalidate(&self) {
113        self.generation.fetch_add(1, Ordering::Release);
114    }
115
116    /// Current generation (for diagnostics / testing).
117    pub fn generation(&self) -> u64 {
118        self.generation.load(Ordering::Relaxed)
119    }
120}
121
122// ── Public API functions ─────────────────────────────────────────────────────
123
124pub 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(&current) {
145            continue;
146        }
147        if current != target {
148            result.insert(current.clone(), depth);
149        }
150
151        let Some(node) = graph.get(&current) 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
200/// PageRank over the import graph (damping=0.85, 20 iterations).
201fn 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
255/// Public accessor for the import graph, used by sibling modules (e.g. circular).
256pub(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
263// ── Graph building (internal) ────────────────────────────────────────────────
264
265fn build_graph(project: &ProjectRoot) -> Result<HashMap<String, FileNode>> {
266    // Try to load from SQLite first
267    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    // Fallback: scan files directly
276    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// ── Tests ────────────────────────────────────────────────────────────────────
344
345#[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}