Skip to main content

codelens_engine/import_graph/
dead_code.rs

1use crate::call_graph::extract_calls;
2use crate::project::ProjectRoot;
3use anyhow::Result;
4use serde::Serialize;
5use std::collections::{HashMap, HashSet};
6use std::path::Path;
7
8use super::parsers::collect_top_level_funcs;
9use super::{DeadCodeEntry, GraphCache, collect_candidate_files};
10
11#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
12pub struct DeadCodeEntryV2 {
13    pub file: String,
14    pub symbol: Option<String>,
15    pub kind: Option<String>,
16    pub line: Option<usize>,
17    pub reason: String,
18    pub pass: u8,
19}
20
21/// Exception file names that should not be flagged as dead (entry points / init files).
22pub(super) fn is_entry_point_file(file: &str) -> bool {
23    let name = Path::new(file)
24        .file_name()
25        .and_then(|n| n.to_str())
26        .unwrap_or(file);
27    matches!(
28        name,
29        "__init__.py"
30            | "mod.rs"
31            | "lib.rs"
32            | "main.rs"
33            | "index.ts"
34            | "index.js"
35            | "index.tsx"
36            | "index.jsx"
37    )
38}
39
40/// Exception symbol names that should not be flagged as dead.
41pub(super) fn is_entry_point_symbol(name: &str) -> bool {
42    name == "main"
43        || name == "__init__"
44        || name == "setUp"
45        || name == "tearDown"
46        || name.starts_with("test_")
47        || name.starts_with("Test")
48}
49
50/// Check whether any line preceding a symbol definition starts with `@`
51/// (decorator pattern). Scans upward through stacked decorators.
52/// `lines` is the 0-indexed source lines; `symbol_line` is 1-indexed.
53pub(super) fn has_decorator(lines: &[&str], symbol_line: usize) -> bool {
54    if symbol_line < 2 {
55        return false;
56    }
57    // Scan upward from the line before the definition
58    let mut idx = symbol_line - 2; // convert to 0-indexed, then go one line back
59    loop {
60        match lines.get(idx) {
61            Some(line) if line.trim_start().starts_with('@') => return true,
62            Some(line) if line.trim().is_empty() => {} // skip blank lines between decorators
63            _ => return false,
64        }
65        if idx == 0 {
66            return false;
67        }
68        idx -= 1;
69    }
70}
71
72pub fn find_dead_code(
73    project: &ProjectRoot,
74    max_results: usize,
75    cache: &GraphCache,
76) -> Result<Vec<DeadCodeEntry>> {
77    let graph = cache.get_or_build(project)?;
78    let mut dead: Vec<_> = graph
79        .iter()
80        .filter(|(_, node)| node.imported_by.is_empty())
81        .map(|(file, _)| DeadCodeEntry {
82            file: file.clone(),
83            symbol: None,
84            reason: "no importers".to_owned(),
85        })
86        .collect();
87    dead.sort_by(|a, b| a.file.cmp(&b.file));
88    if max_results > 0 && dead.len() > max_results {
89        dead.truncate(max_results);
90    }
91    Ok(dead)
92}
93
94pub fn find_dead_code_v2(
95    project: &ProjectRoot,
96    max_results: usize,
97    cache: &GraphCache,
98) -> Result<Vec<DeadCodeEntryV2>> {
99    let mut results: Vec<DeadCodeEntryV2> = Vec::new();
100
101    // ── Pass 1: unreferenced files ────────────────────────────────────────────
102    let graph = cache.get_or_build(project)?;
103    for (file, node) in graph.iter() {
104        if node.imported_by.is_empty() && !is_entry_point_file(file) {
105            results.push(DeadCodeEntryV2 {
106                file: file.clone(),
107                symbol: None,
108                kind: None,
109                line: None,
110                reason: "no importers".to_owned(),
111                pass: 1,
112            });
113        }
114    }
115
116    // ── Pass 2: unreferenced symbols ─────────────────────────────────────────
117    let candidate_files = collect_candidate_files(project.as_path())?;
118    let mut all_callees: HashSet<String> = HashSet::new();
119    for path in &candidate_files {
120        for edge in extract_calls(path) {
121            all_callees.insert(edge.callee_name);
122        }
123    }
124
125    for path in &candidate_files {
126        let relative = project.to_relative(path);
127
128        if results.iter().any(|e| e.file == relative && e.pass == 1) {
129            continue;
130        }
131        if is_entry_point_file(&relative) {
132            continue;
133        }
134
135        let source = std::fs::read_to_string(path).unwrap_or_default();
136        let lines: Vec<&str> = source.lines().collect();
137
138        let edges = extract_calls(path);
139        let mut defined_funcs: HashMap<String, usize> = HashMap::new();
140        for edge in &edges {
141            defined_funcs.entry(edge.caller_name.clone()).or_insert(0);
142        }
143        collect_top_level_funcs(path, &source, &mut defined_funcs);
144
145        for (func_name, func_line) in defined_funcs {
146            if func_name == "<module>" {
147                continue;
148            }
149            if is_entry_point_symbol(&func_name) {
150                continue;
151            }
152            if func_line > 0 && has_decorator(&lines, func_line) {
153                continue;
154            }
155            if !all_callees.contains(&func_name) {
156                results.push(DeadCodeEntryV2 {
157                    file: relative.clone(),
158                    symbol: Some(func_name),
159                    kind: Some("function".to_owned()),
160                    line: if func_line > 0 { Some(func_line) } else { None },
161                    reason: "unreferenced symbol".to_owned(),
162                    pass: 2,
163                });
164            }
165        }
166    }
167
168    results.sort_by(|a, b| {
169        a.pass
170            .cmp(&b.pass)
171            .then(a.file.cmp(&b.file))
172            .then(a.symbol.cmp(&b.symbol))
173    });
174    if max_results > 0 && results.len() > max_results {
175        results.truncate(max_results);
176    }
177    Ok(results)
178}