codelens_engine/
dead_code.rs1use 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 crate::import_graph::parsers::collect_top_level_funcs;
9use crate::import_graph::{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 pub confidence: String,
27}
28
29pub(super) fn is_ts_structural_likely(file: &str) -> bool {
37 let path = Path::new(file);
38 let ext_ok = matches!(
39 path.extension().and_then(|e| e.to_str()),
40 Some("ts" | "tsx")
41 );
42 if !ext_ok {
43 return false;
44 }
45 const STRUCTURAL_NAME_TOKENS: &[&str] = &[
46 "request",
47 "schema",
48 "types",
49 "contract",
50 "interface",
51 "model",
52 "dto",
53 ];
54 let name_lc = path
55 .file_name()
56 .and_then(|n| n.to_str())
57 .unwrap_or("")
58 .to_ascii_lowercase();
59 let parent_lc = path
60 .parent()
61 .and_then(|p| p.file_name())
62 .and_then(|n| n.to_str())
63 .unwrap_or("")
64 .to_ascii_lowercase();
65 STRUCTURAL_NAME_TOKENS
66 .iter()
67 .any(|kw| name_lc.contains(kw) || parent_lc.contains(kw))
68}
69
70pub(super) const CONFIDENCE_HIGH: &str = "high";
72
73pub(super) const CONFIDENCE_STRUCTURAL: &str = "needs_structural_evidence";
77
78pub(super) fn confidence_tier_for_file(file: &str) -> &'static str {
79 if is_ts_structural_likely(file) {
80 CONFIDENCE_STRUCTURAL
81 } else {
82 CONFIDENCE_HIGH
83 }
84}
85
86pub(super) fn is_entry_point_file(file: &str) -> bool {
88 let name = Path::new(file)
89 .file_name()
90 .and_then(|n| n.to_str())
91 .unwrap_or(file);
92 matches!(
93 name,
94 "__init__.py"
95 | "mod.rs"
96 | "lib.rs"
97 | "main.rs"
98 | "index.ts"
99 | "index.js"
100 | "index.tsx"
101 | "index.jsx"
102 )
103}
104
105pub(super) fn is_entry_point_symbol(name: &str) -> bool {
107 name == "main"
108 || name == "__init__"
109 || name == "setUp"
110 || name == "tearDown"
111 || name.starts_with("test_")
112 || name.starts_with("Test")
113}
114
115pub(super) fn has_decorator(lines: &[&str], symbol_line: usize) -> bool {
119 if symbol_line < 2 {
120 return false;
121 }
122 let mut idx = symbol_line - 2; loop {
125 match lines.get(idx) {
126 Some(line) if line.trim_start().starts_with('@') => return true,
127 Some(line) if line.trim().is_empty() => {} _ => return false,
129 }
130 if idx == 0 {
131 return false;
132 }
133 idx -= 1;
134 }
135}
136
137pub fn find_dead_code(
138 project: &ProjectRoot,
139 max_results: usize,
140 cache: &GraphCache,
141) -> Result<Vec<DeadCodeEntry>> {
142 let graph = cache.get_or_build(project)?;
143 let mut dead: Vec<_> = graph
144 .iter()
145 .filter(|(_, node)| node.imported_by.is_empty())
146 .map(|(file, _)| DeadCodeEntry {
147 file: file.clone(),
148 symbol: None,
149 reason: "no importers".to_owned(),
150 })
151 .collect();
152 dead.sort_by(|a, b| a.file.cmp(&b.file));
153 if max_results > 0 && dead.len() > max_results {
154 dead.truncate(max_results);
155 }
156 Ok(dead)
157}
158
159pub fn find_dead_code_v2(
160 project: &ProjectRoot,
161 max_results: usize,
162 cache: &GraphCache,
163) -> Result<Vec<DeadCodeEntryV2>> {
164 let mut results: Vec<DeadCodeEntryV2> = Vec::new();
165
166 let graph = cache.get_or_build(project)?;
168 for (file, node) in graph.iter() {
169 if node.imported_by.is_empty() && !is_entry_point_file(file) {
170 results.push(DeadCodeEntryV2 {
171 file: file.clone(),
172 symbol: None,
173 kind: None,
174 line: None,
175 reason: "no importers".to_owned(),
176 pass: 1,
177 confidence: confidence_tier_for_file(file).to_owned(),
178 });
179 }
180 }
181
182 let candidate_files = collect_candidate_files(project.as_path())?;
184 let mut all_callees: HashSet<String> = HashSet::new();
185 for path in &candidate_files {
186 for edge in extract_calls(path) {
187 all_callees.insert(edge.callee_name);
188 }
189 }
190
191 for path in &candidate_files {
192 let relative = project.to_relative(path);
193
194 if results.iter().any(|e| e.file == relative && e.pass == 1) {
195 continue;
196 }
197 if is_entry_point_file(&relative) {
198 continue;
199 }
200
201 let source = std::fs::read_to_string(path).unwrap_or_default();
202 let lines: Vec<&str> = source.lines().collect();
203
204 let edges = extract_calls(path);
205 let mut defined_funcs: HashMap<String, usize> = HashMap::new();
206 for edge in &edges {
207 defined_funcs.entry(edge.caller_name.clone()).or_insert(0);
208 }
209 collect_top_level_funcs(path, &source, &mut defined_funcs);
210
211 for (func_name, func_line) in defined_funcs {
212 if func_name == "<module>" {
213 continue;
214 }
215 if is_entry_point_symbol(&func_name) {
216 continue;
217 }
218 if func_line > 0 && has_decorator(&lines, func_line) {
219 continue;
220 }
221 if !all_callees.contains(&func_name) {
222 results.push(DeadCodeEntryV2 {
223 file: relative.clone(),
224 symbol: Some(func_name),
225 kind: Some("function".to_owned()),
226 line: if func_line > 0 { Some(func_line) } else { None },
227 reason: "unreferenced symbol".to_owned(),
228 pass: 2,
229 confidence: confidence_tier_for_file(&relative).to_owned(),
230 });
231 }
232 }
233 }
234
235 results.sort_by(|a, b| {
236 a.pass
237 .cmp(&b.pass)
238 .then(a.file.cmp(&b.file))
239 .then(a.symbol.cmp(&b.symbol))
240 });
241 if max_results > 0 && results.len() > max_results {
242 results.truncate(max_results);
243 }
244 Ok(results)
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
252 fn ts_request_files_downgrade_confidence() {
253 for path in [
256 "src/api/request.ts",
257 "src/api/RequestTypes.ts",
258 "src/server/schema.ts",
259 "src/contracts/UserContract.ts",
260 "src/types/index.ts",
261 "src/models/User.ts",
262 "src/dtos/CreateUser.ts",
263 "components/MyForm/types.tsx",
264 "lib/Interface.ts",
265 ] {
266 assert!(
267 is_ts_structural_likely(path),
268 "{path:?} should be downgraded"
269 );
270 assert_eq!(confidence_tier_for_file(path), CONFIDENCE_STRUCTURAL);
271 }
272 }
273
274 #[test]
275 fn non_ts_or_unrelated_files_keep_high_confidence() {
276 for path in [
277 "src/main.rs",
278 "src/utils.py",
279 "scripts/build.sh",
280 "src/app/page.tsx", "src/hooks/useGifStudio.ts",
282 "Cargo.toml",
283 "package.json",
284 ] {
285 assert!(
286 !is_ts_structural_likely(path),
287 "{path:?} should keep high confidence"
288 );
289 assert_eq!(confidence_tier_for_file(path), CONFIDENCE_HIGH);
290 }
291 }
292
293 #[test]
294 fn javascript_request_file_is_not_downgraded() {
295 assert!(!is_ts_structural_likely("src/api/request.js"));
299 assert_eq!(
300 confidence_tier_for_file("src/api/request.js"),
301 CONFIDENCE_HIGH
302 );
303 }
304}