1use std::collections::{HashMap, HashSet};
7use std::fs;
8use std::path::Path;
9
10#[derive(Debug, Clone)]
12pub struct DeadConfig {
13 pub min_name_len: usize,
14 pub test_file_patterns: Vec<String>,
15}
16
17impl Default for DeadConfig {
18 fn default() -> Self {
19 Self {
20 min_name_len: 4,
21 test_file_patterns: vec!["test".to_string(), "spec".to_string(), "mock".to_string()],
22 }
23 }
24}
25
26#[derive(Debug, Clone)]
27pub struct DeadCandidate {
28 pub name: String,
29 pub file: String,
30 pub line: usize,
31 pub confidence: Confidence,
32 pub reason: String,
33 pub total_occurrences: usize,
34 pub def_occurrences: usize,
35}
36
37#[derive(Debug, Clone, PartialEq)]
38pub enum Confidence {
39 High,
40 Medium,
41 Low,
42}
43
44const SKIP_DIRS: &[&str] = &[
45 "node_modules", "vendor", ".git", "target", "dist", "build",
46 "__pycache__", ".reliary", "venv", ".venv", "env",
47];
48
49fn extract_words(content: &str, min_len: usize) -> Vec<(String, usize)> {
52 let mut result = Vec::new();
53 let bytes = content.as_bytes();
54 let mut word_start: Option<usize> = None;
55 let mut line = 1usize;
56 let mut i = 0;
57 while i < bytes.len() {
58 let b = bytes[i];
59 if b == b'\n' { line += 1; }
60 let is_word_char = b.is_ascii_alphanumeric() || b == b'_';
61 let is_word_start_char = b.is_ascii_alphabetic() || b == b'_';
62 if word_start.is_some() {
63 if !is_word_char {
64 let word = &content[word_start.unwrap()..i];
65 if word.len() >= min_len && word.len() <= 40 {
66 result.push((word.to_lowercase(), line));
67 }
68 word_start = None;
69 }
70 } else if is_word_start_char {
71 word_start = Some(i);
72 }
73 i += 1;
74 }
75 if let Some(start) = word_start {
76 let word = &content[start..];
77 if word.len() >= min_len && word.len() <= 40 {
78 result.push((word.to_lowercase(), line));
79 }
80 }
81 result
82}
83
84fn extract_definitions(content: &str, min_len: usize) -> Vec<(String, usize)> {
87 let mut defs = Vec::new();
88 for (line_num, line) in content.lines().enumerate() {
89 let trimmed = line.trim_start();
90 let name = extract_def_name(trimmed);
93 if let Some(n) = name {
94 if n.len() >= min_len && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
95 defs.push((n.to_lowercase(), line_num + 1));
96 }
97 }
98 }
99 defs
100}
101
102fn extract_def_name(line: &str) -> Option<String> {
104 let mut s = line;
106 loop {
107 let stripped = s
108 .strip_prefix("pub ")
109 .or_else(|| s.strip_prefix("pub(crate) "))
110 .or_else(|| s.strip_prefix("pub(super) "))
111 .or_else(|| s.strip_prefix("export "))
112 .or_else(|| s.strip_prefix("async "))
113 .or_else(|| s.strip_prefix("static "))
114 .or_else(|| s.strip_prefix("extern "))
115 .or_else(|| s.strip_prefix("inline "))
116 .or_else(|| s.strip_prefix("virtual "))
117 .or_else(|| s.strip_prefix("override "))
118 .or_else(|| s.strip_prefix("abstract "))
119 .or_else(|| s.strip_prefix("private "))
120 .or_else(|| s.strip_prefix("protected "))
121 .or_else(|| s.strip_prefix("internal "));
122 match stripped {
123 Some(rest) => s = rest,
124 None => break,
125 }
126 }
127 for kw in &["fn ", "def ", "func ", "function ", "class ", "trait ", "struct ",
129 "enum ", "type ", "interface ", "impl ", "const ", "static "] {
130 if let Some(rest) = s.strip_prefix(kw) {
131 return extract_first_ident(rest);
133 }
134 }
135 for kw in &["let ", "var ", "val "] {
137 if let Some(rest) = s.strip_prefix(kw) {
138 let rest = rest.strip_prefix("mut ").unwrap_or(rest);
140 return extract_first_ident(rest);
141 }
142 }
143 None
144}
145
146fn extract_first_ident(s: &str) -> Option<String> {
147 let bytes = s.as_bytes();
148 let mut end = 0;
149 while end < bytes.len() {
150 let b = bytes[end];
151 if b.is_ascii_alphanumeric() || b == b'_' {
152 end += 1;
153 } else {
154 break;
155 }
156 }
157 if end > 0 {
158 Some(s[..end].to_string())
159 } else {
160 None
161 }
162}
163
164pub fn scan_repo(path: &str, config: &DeadConfig) -> Vec<DeadCandidate> {
167 let files: Vec<String> = collect_source_files(path);
169 if files.is_empty() {
170 return Vec::new();
171 }
172
173 let mut all_counts: HashMap<String, usize> = HashMap::new();
175 let mut all_defs: HashMap<String, Vec<(String, usize)>> = HashMap::new();
176
177 for file_path in &files {
178 let content = match fs::read_to_string(file_path) {
179 Ok(c) => c,
180 Err(_) => continue,
181 };
182
183 for (word, _) in extract_words(&content, config.min_name_len) {
185 *all_counts.entry(word).or_insert(0) += 1;
186 }
187
188 for (name, line) in extract_definitions(&content, config.min_name_len) {
190 all_defs.entry(name).or_default().push((file_path.clone(), line));
191 }
192 }
193
194 let mut results = Vec::new();
196 for (name, locations) in &all_defs {
197 if name.starts_with("__") || name.chars().all(|c| c.is_ascii_digit()) {
199 continue;
200 }
201 if is_entry_point(name) {
202 continue;
203 }
204
205 let total_occ = *all_counts.get(name).unwrap_or(&0);
206 let def_occ = locations.len();
207
208 if total_occ > def_occ {
210 continue;
211 }
212
213 let is_test_file = |p: &str| config.test_file_patterns.iter().any(|pat| p.contains(pat));
214 let def_non_test = locations.iter().filter(|(f, _)| !is_test_file(f)).count();
215
216 let confidence = if def_non_test == 0 {
217 Confidence::Low
218 } else if name.chars().all(|c| c.is_ascii_uppercase() || c == '_') && name.len() >= 5 {
219 Confidence::High
220 } else {
221 Confidence::Medium
222 };
223
224 let reason = match &confidence {
225 Confidence::High => format!("{} — exported but never imported", name),
226 Confidence::Medium => format!("{} — defined but never referenced outside definition", name),
227 Confidence::Low => format!("{} — potentially dead (test file)", name),
228 };
229
230 for (f, line) in locations {
231 results.push(DeadCandidate {
232 name: name.clone(),
233 file: f.clone(),
234 line: *line,
235 confidence: confidence.clone(),
236 reason: reason.clone(),
237 total_occurrences: total_occ,
238 def_occurrences: def_occ,
239 });
240 }
241 }
242
243 let mut seen = HashSet::new();
245 results.retain(|c| seen.insert((c.name.clone(), c.file.clone(), c.line)));
246
247 results.sort_by(|a, b| {
249 let score = |c: &Confidence| match c { Confidence::High => 3, Confidence::Medium => 2, Confidence::Low => 1 };
250 score(&b.confidence).cmp(&score(&a.confidence))
251 .then_with(|| a.name.cmp(&b.name))
252 });
253
254 results
255}
256
257fn collect_source_files(path: &str) -> Vec<String> {
258 let mut files = Vec::new();
259 let p = Path::new(path);
260 if p.is_file() {
261 if is_source_ext(p) {
262 files.push(path.to_string());
263 }
264 return files;
265 }
266 for entry in walkdir::WalkDir::new(path)
267 .into_iter()
268 .filter_entry(|e| {
269 !e.file_name().to_str().map(|s| {
270 SKIP_DIRS.contains(&s) || s.starts_with('.')
271 }).unwrap_or(false)
272 })
273 .flatten()
274 {
275 if entry.file_type().is_file() && is_source_ext(entry.path()) {
276 if let Some(s) = entry.path().to_str() {
277 files.push(s.to_string());
278 }
279 }
280 }
281 files
282}
283
284fn is_source_ext(path: &Path) -> bool {
285 let exts = ["rs", "py", "js", "ts", "go", "java", "c", "cpp", "h", "hpp",
286 "rb", "swift", "kt", "scala", "lua", "php", "sh", "jsx", "tsx"];
287 path.extension()
288 .and_then(|e| e.to_str())
289 .map(|e| exts.contains(&e))
290 .unwrap_or(false)
291}
292
293fn is_entry_point(name: &str) -> bool {
295 matches!(name, "main" | "__init__" | "__main__"
296 | "setup" | "teardown"
297 | "beforeeach" | "aftereach"
298 | "module_exit" | "module_init" | "dllmain" | "wmain" | "tmain"
299 | "drop" | "new" | "default" | "from" | "into")
300 || name.starts_with("test_")
301 || name.starts_with("test")
302 || name.ends_with("_test")
303}
304
305pub fn analyze_file(file: &str, _content: &str, config: &DeadConfig) -> Vec<DeadCandidate> {
307 let files = vec![file.to_string()];
308 analyze_files_cross(&files, config)
309}
310
311pub fn analyze_files(files: &[(String, String)], config: &DeadConfig) -> Vec<DeadCandidate> {
313 let mut all_counts: HashMap<String, usize> = HashMap::new();
314 let mut all_defs: HashMap<String, Vec<(String, usize)>> = HashMap::new();
315
316 for (file, content) in files {
317 for (word, _) in extract_words(content, config.min_name_len) {
318 *all_counts.entry(word).or_insert(0) += 1;
319 }
320 for (name, line) in extract_definitions(content, config.min_name_len) {
321 all_defs.entry(name).or_default().push((file.clone(), line));
322 }
323 }
324
325 let mut results = Vec::new();
326 for (name, locations) in &all_defs {
327 if name.starts_with("__") || is_entry_point(name) { continue; }
328 let total_occ = *all_counts.get(name).unwrap_or(&0);
329 let def_occ = locations.len();
330 if total_occ > def_occ { continue; }
331
332 let confidence = if name.chars().all(|c| c.is_ascii_uppercase() || c == '_') && name.len() >= 5 {
333 Confidence::High
334 } else if name.len() >= 5 {
335 Confidence::Medium
336 } else {
337 Confidence::Low
338 };
339 let reason = match &confidence {
340 Confidence::High => format!("{} — exported but never imported", name),
341 Confidence::Medium => format!("{} — defined but never referenced outside definition", name),
342 Confidence::Low => format!("{} — potentially dead (test file)", name),
343 };
344 for (f, line) in locations {
345 results.push(DeadCandidate {
346 name: name.clone(), file: f.clone(), line: *line,
347 confidence: confidence.clone(), reason: reason.clone(),
348 total_occurrences: total_occ, def_occurrences: def_occ,
349 });
350 }
351 }
352 let mut seen = HashSet::new();
353 results.retain(|c| seen.insert((c.name.clone(), c.file.clone(), c.line)));
354 results
355}
356
357fn analyze_files_cross(files: &[String], config: &DeadConfig) -> Vec<DeadCandidate> {
358 let file_contents: Vec<(String, String)> = files.iter().filter_map(|f| {
359 fs::read_to_string(f).ok().map(|c| (f.clone(), c))
360 }).collect();
361 analyze_files(&file_contents, config)
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn test_dead_function_cross_file() {
370 let files = vec![
372 ("mod1.rs".to_string(), "fn helper() {}".to_string()),
373 ("mod2.rs".to_string(), "fn main() { helper(); }".to_string()),
374 ];
375 let config = DeadConfig::default();
376 let results = analyze_files(&files, &config);
377 assert!(!results.iter().any(|c| c.name == "helper"));
379 }
380
381 #[test]
382 fn test_truly_dead_function() {
383 let files = vec![
384 ("test.rs".to_string(), "fn dead_func() {}".to_string()),
385 ];
386 let config = DeadConfig::default();
387 let results = analyze_files(&files, &config);
388 assert!(results.iter().any(|c| c.name == "dead_func"));
389 }
390
391 #[test]
392 fn test_definition_name_extraction() {
393 assert_eq!(extract_def_name("fn block_on<F: Future>(&self) {"), Some("block_on".to_string()));
394 assert_eq!(extract_def_name("pub fn block_on<F: Future>(&self) {"), Some("block_on".to_string()));
395 assert_eq!(extract_def_name("pub(crate) fn helper() {}"), Some("helper".to_string()));
396 assert_eq!(extract_def_name("async fn poll() {}"), Some("poll".to_string()));
397 assert_eq!(extract_def_name("def hello():"), Some("hello".to_string()));
398 assert_eq!(extract_def_name("struct Foo {"), Some("Foo".to_string()));
399 assert_eq!(extract_def_name("const MAX_SIZE: usize = 100;"), Some("MAX_SIZE".to_string()));
400 assert_eq!(extract_def_name("let x = 5;"), Some("x".to_string()));
401 assert_eq!(extract_def_name("// fn not_a_def()"), None);
402 }
403
404 #[test]
405 fn test_word_extraction_filters_keywords() {
406 let words: Vec<String> = extract_words("let x = self.foo();", 4)
407 .into_iter().map(|(w, _)| w).collect();
408 assert!(words.contains(&"self".to_string()));
410 assert!(!words.contains(&"foo".to_string()));
411 }
412
413 #[test]
414 fn test_no_false_positive_for_self() {
415 let files = vec![("test.rs".to_string(), "struct Foo {}\nimpl Foo { fn bar() -> Self { Self } }".to_string())];
417 let config = DeadConfig::default();
418 let results = analyze_files(&files, &config);
419 assert!(!results.iter().any(|c| c.name == "self"));
423 }
424
425 #[test]
426 fn test_scan_repo_finds_dead_in_fixture() {
427 let dir = std::env::temp_dir().join("reliary_dead_test");
429 let _ = std::fs::remove_dir_all(&dir);
430 std::fs::create_dir_all(&dir).unwrap();
431
432 std::fs::write(dir.join("defs.rs"), "pub fn live_fn() {}\npub fn orphan_fn() {}\n").unwrap();
435 std::fs::write(dir.join("caller.rs"), "fn main() { live_fn(); }\n").unwrap();
436
437 let config = DeadConfig::default();
438 let results = scan_repo(dir.to_str().unwrap(), &config);
439
440 let orphan = results.iter().find(|c| c.name == "orphan_fn");
442 assert!(orphan.is_some(), "orphan_fn should be flagged as dead");
443 assert_eq!(orphan.unwrap().total_occurrences, 1); let live = results.iter().find(|c| c.name == "live_fn");
447 assert!(live.is_none(), "live_fn should NOT be flagged as dead");
448
449 let main = results.iter().find(|c| c.name == "main");
451 assert!(main.is_none(), "main should not be flagged (entry point)");
452
453 std::fs::remove_dir_all(&dir).unwrap();
454 }
455
456 #[test]
457 fn test_scan_repo_skips_target_dirs() {
458 let dir = std::env::temp_dir().join("reliary_dead_skip_test");
459 let _ = std::fs::remove_dir_all(&dir);
460 std::fs::create_dir_all(dir.join("target")).unwrap();
461 std::fs::create_dir_all(dir.join("src")).unwrap();
462
463 std::fs::write(dir.join("src/main.rs"), "fn truly_dead() {}\n").unwrap();
465 std::fs::write(dir.join("target/build.rs"), "fn target_dead() {}\n").unwrap();
467
468 let config = DeadConfig::default();
469 let results = scan_repo(dir.to_str().unwrap(), &config);
470
471 assert!(results.iter().any(|c| c.name == "truly_dead"), "src dead fn should be found");
472 assert!(!results.iter().any(|c| c.name == "target_dead"), "target/ should be skipped");
473
474 std::fs::remove_dir_all(&dir).unwrap();
475 }
476
477 #[test]
478 fn test_scan_repo_empty_dir() {
479 let dir = std::env::temp_dir().join("reliary_dead_empty_test");
480 let _ = std::fs::remove_dir_all(&dir);
481 std::fs::create_dir_all(&dir).unwrap();
482
483 let config = DeadConfig::default();
484 let results = scan_repo(dir.to_str().unwrap(), &config);
485 assert!(results.is_empty(), "empty dir should return no results");
486
487 std::fs::remove_dir_all(&dir).unwrap();
488 }
489
490 #[test]
491 fn test_cross_file_call_prevents_dead_flag() {
492 let files = vec![
494 ("mod_a.rs".to_string(), "fn shared_helper() {}".to_string()),
495 ("mod_b.rs".to_string(), "fn caller_b() { shared_helper(); }".to_string()),
496 ("mod_c.rs".to_string(), "fn caller_c() { shared_helper(); }".to_string()),
497 ];
498 let config = DeadConfig::default();
499 let results = analyze_files(&files, &config);
500 assert!(!results.iter().any(|c| c.name == "shared_helper"),
501 "shared_helper is called from 2 other files — not dead");
502 }
503
504 #[test]
505 fn test_confidence_levels() {
506 let files = vec![
512 ("const.rs".to_string(), "const MAX_BUFFER_SIZE: usize = 4096;\n".to_string()),
513 ];
514 let config = DeadConfig::default();
515 let results = analyze_files(&files, &config);
516 let candidate = results.iter().find(|c| c.name == "max_buffer_size");
517 assert!(candidate.is_some(), "MAX_BUFFER_SIZE should be flagged");
518 assert_eq!(candidate.unwrap().confidence, Confidence::Medium,
520 "lowercased name gets Medium confidence");
521 }
522}