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