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)> {
89 let mut defs = Vec::new();
90 for (line_num, line) in content.lines().enumerate() {
91 let trimmed = line.trim_start();
92 let name = extract_def_name(trimmed);
95 if let Some(n) = name {
96 if n.len() >= min_len && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
97 defs.push((n.to_lowercase(), line_num + 1));
98 }
99 }
100 }
101 defs
102}
103
104fn extract_def_name(line: &str) -> Option<String> {
106 let mut s = line;
108 loop {
109 let stripped = s
110 .strip_prefix("pub ")
111 .or_else(|| s.strip_prefix("pub(crate) "))
112 .or_else(|| s.strip_prefix("pub(super) "))
113 .or_else(|| s.strip_prefix("export "))
114 .or_else(|| s.strip_prefix("async "))
115 .or_else(|| s.strip_prefix("static "))
116 .or_else(|| s.strip_prefix("extern "))
117 .or_else(|| s.strip_prefix("inline "))
118 .or_else(|| s.strip_prefix("virtual "))
119 .or_else(|| s.strip_prefix("override "))
120 .or_else(|| s.strip_prefix("abstract "))
121 .or_else(|| s.strip_prefix("private "))
122 .or_else(|| s.strip_prefix("protected "))
123 .or_else(|| s.strip_prefix("internal "));
124 match stripped {
125 Some(rest) => s = rest,
126 None => break,
127 }
128 }
129 for kw in &["fn ", "def ", "func ", "function ", "class ", "trait ", "struct ",
131 "enum ", "type ", "interface ", "impl ", "const ", "static "] {
132 if let Some(rest) = s.strip_prefix(kw) {
133 return extract_first_ident(rest);
135 }
136 }
137 for kw in &["let ", "var ", "val "] {
139 if let Some(rest) = s.strip_prefix(kw) {
140 let rest = rest.strip_prefix("mut ").unwrap_or(rest);
142 return extract_first_ident(rest);
143 }
144 }
145 None
146}
147
148fn extract_first_ident(s: &str) -> Option<String> {
149 let bytes = s.as_bytes();
150 let mut end = 0;
151 while end < bytes.len() {
152 let b = bytes[end];
153 if b.is_ascii_alphanumeric() || b == b'_' {
154 end += 1;
155 } else {
156 break;
157 }
158 }
159 if end > 0 {
160 Some(s[..end].to_string())
161 } else {
162 None
163 }
164}
165
166pub fn scan_repo(path: &str, config: &DeadConfig) -> Vec<DeadCandidate> {
169 let files: Vec<String> = collect_source_files(path);
171 if files.is_empty() {
172 return Vec::new();
173 }
174
175 let mut all_counts: HashMap<String, usize> = HashMap::new();
177 let mut all_defs: HashMap<String, Vec<(String, usize)>> = HashMap::new();
178
179 for file_path in &files {
180 let content = match fs::read_to_string(file_path) {
181 Ok(c) => c,
182 Err(_) => continue,
183 };
184
185 for (word, _) in extract_words(&content, config.min_name_len) {
187 *all_counts.entry(word).or_insert(0) += 1;
188 }
189
190 for (name, line) in extract_definitions(&content, config.min_name_len) {
192 all_defs.entry(name).or_default().push((file_path.clone(), line));
193 }
194 }
195
196 let mut results = Vec::new();
198 for (name, locations) in &all_defs {
199 if name.starts_with("__") || name.chars().all(|c| c.is_ascii_digit()) {
201 continue;
202 }
203 if is_entry_point(name) {
204 continue;
205 }
206
207 let total_occ = *all_counts.get(name).unwrap_or(&0);
208 let def_occ = locations.len();
209
210 if total_occ > def_occ {
212 continue;
213 }
214
215 let is_test_file = |p: &str| config.test_file_patterns.iter().any(|pat| p.contains(pat));
216 let def_non_test = locations.iter().filter(|(f, _)| !is_test_file(f)).count();
217
218 let confidence = if def_non_test == 0 {
219 Confidence::Low
220 } else if name.chars().all(|c| c.is_ascii_uppercase() || c == '_') && name.len() >= 5 {
221 Confidence::High
222 } else {
223 Confidence::Medium
224 };
225
226 let reason = match &confidence {
227 Confidence::High => format!("{} — exported but never imported", name),
228 Confidence::Medium => format!("{} — defined but never referenced outside definition", name),
229 Confidence::Low => format!("{} — potentially dead (test file)", name),
230 };
231
232 for (f, line) in locations {
233 results.push(DeadCandidate {
234 name: name.clone(),
235 file: f.clone(),
236 line: *line,
237 confidence: confidence.clone(),
238 reason: reason.clone(),
239 total_occurrences: total_occ,
240 def_occurrences: def_occ,
241 });
242 }
243 }
244
245 let mut seen = HashSet::new();
247 results.retain(|c| seen.insert((c.name.clone(), c.file.clone(), c.line)));
248
249 results.sort_by(|a, b| {
251 let score = |c: &Confidence| match c { Confidence::High => 3, Confidence::Medium => 2, Confidence::Low => 1 };
252 score(&b.confidence).cmp(&score(&a.confidence))
253 .then_with(|| a.name.cmp(&b.name))
254 });
255
256 results
257}
258
259fn collect_source_files(path: &str) -> Vec<String> {
260 let mut files = Vec::new();
261 let p = Path::new(path);
262 if p.is_file() {
263 if is_source_ext(p) {
264 files.push(path.to_string());
265 }
266 return files;
267 }
268 for entry in walkdir::WalkDir::new(path)
269 .into_iter()
270 .filter_entry(|e| {
271 !e.file_name().to_str().map(|s| {
272 SKIP_DIRS.contains(&s) || s.starts_with('.')
273 }).unwrap_or(false)
274 })
275 .flatten()
276 {
277 if entry.file_type().is_file() && is_source_ext(entry.path()) {
278 if let Some(s) = entry.path().to_str() {
279 files.push(s.to_string());
280 }
281 }
282 }
283 files
284}
285
286fn is_source_ext(path: &Path) -> bool {
287 let exts = ["rs", "py", "js", "ts", "go", "java", "c", "cpp", "h", "hpp",
288 "rb", "swift", "kt", "scala", "lua", "php", "sh", "jsx", "tsx"];
289 path.extension()
290 .and_then(|e| e.to_str())
291 .map(|e| exts.contains(&e))
292 .unwrap_or(false)
293}
294
295fn is_entry_point(name: &str) -> bool {
297 matches!(name, "main" | "__init__" | "__main__"
298 | "setup" | "teardown"
299 | "beforeeach" | "aftereach"
300 | "module_exit" | "module_init" | "dllmain" | "wmain" | "tmain"
301 | "drop" | "new" | "default" | "from" | "into")
302 || name.starts_with("test_")
303 || name.starts_with("test")
304 || name.ends_with("_test")
305}
306
307pub fn analyze_file(file: &str, _content: &str, config: &DeadConfig) -> Vec<DeadCandidate> {
309 let files = vec![file.to_string()];
310 analyze_files_cross(&files, config)
311}
312
313pub fn analyze_files(files: &[(String, String)], config: &DeadConfig) -> Vec<DeadCandidate> {
315 let mut all_counts: HashMap<String, usize> = HashMap::new();
316 let mut all_defs: HashMap<String, Vec<(String, usize)>> = HashMap::new();
317
318 for (file, content) in files {
319 for (word, _) in extract_words(content, config.min_name_len) {
320 *all_counts.entry(word).or_insert(0) += 1;
321 }
322 for (name, line) in extract_definitions(content, config.min_name_len) {
323 all_defs.entry(name).or_default().push((file.clone(), line));
324 }
325 }
326
327 let mut results = Vec::new();
328 for (name, locations) in &all_defs {
329 if name.starts_with("__") || is_entry_point(name) { continue; }
330 let total_occ = *all_counts.get(name).unwrap_or(&0);
331 let def_occ = locations.len();
332 if total_occ > def_occ { continue; }
333
334 let confidence = if name.chars().all(|c| c.is_ascii_uppercase() || c == '_') && name.len() >= 5 {
335 Confidence::High
336 } else if name.len() >= 5 {
337 Confidence::Medium
338 } else {
339 Confidence::Low
340 };
341 let reason = match &confidence {
342 Confidence::High => format!("{} — exported but never imported", name),
343 Confidence::Medium => format!("{} — defined but never referenced outside definition", name),
344 Confidence::Low => format!("{} — potentially dead (test file)", name),
345 };
346 for (f, line) in locations {
347 results.push(DeadCandidate {
348 name: name.clone(), file: f.clone(), line: *line,
349 confidence: confidence.clone(), reason: reason.clone(),
350 total_occurrences: total_occ, def_occurrences: def_occ,
351 });
352 }
353 }
354 let mut seen = HashSet::new();
355 results.retain(|c| seen.insert((c.name.clone(), c.file.clone(), c.line)));
356 results
357}
358
359fn analyze_files_cross(files: &[String], config: &DeadConfig) -> Vec<DeadCandidate> {
360 let file_contents: Vec<(String, String)> = files.iter().filter_map(|f| {
361 fs::read_to_string(f).ok().map(|c| (f.clone(), c))
362 }).collect();
363 analyze_files(&file_contents, config)
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 #[test]
371 fn test_dead_function_cross_file() {
372 let files = vec![
374 ("mod1.rs".to_string(), "fn helper() {}".to_string()),
375 ("mod2.rs".to_string(), "fn main() { helper(); }".to_string()),
376 ];
377 let config = DeadConfig::default();
378 let results = analyze_files(&files, &config);
379 assert!(!results.iter().any(|c| c.name == "helper"));
381 }
382
383 #[test]
384 fn test_truly_dead_function() {
385 let files = vec![
386 ("test.rs".to_string(), "fn dead_func() {}".to_string()),
387 ];
388 let config = DeadConfig::default();
389 let results = analyze_files(&files, &config);
390 assert!(results.iter().any(|c| c.name == "dead_func"));
391 }
392
393 #[test]
394 fn test_definition_name_extraction() {
395 assert_eq!(extract_def_name("fn block_on<F: Future>(&self) {"), Some("block_on".to_string()));
396 assert_eq!(extract_def_name("pub fn block_on<F: Future>(&self) {"), Some("block_on".to_string()));
397 assert_eq!(extract_def_name("pub(crate) fn helper() {}"), Some("helper".to_string()));
398 assert_eq!(extract_def_name("async fn poll() {}"), Some("poll".to_string()));
399 assert_eq!(extract_def_name("def hello():"), Some("hello".to_string()));
400 assert_eq!(extract_def_name("struct Foo {"), Some("Foo".to_string()));
401 assert_eq!(extract_def_name("const MAX_SIZE: usize = 100;"), Some("MAX_SIZE".to_string()));
402 assert_eq!(extract_def_name("let x = 5;"), Some("x".to_string()));
403 assert_eq!(extract_def_name("// fn not_a_def()"), None);
404 }
405
406 #[test]
407 fn test_word_extraction_filters_keywords() {
408 let words: Vec<String> = extract_words("let x = self.foo();", 4)
409 .into_iter().map(|(w, _)| w).collect();
410 assert!(words.contains(&"self".to_string()));
412 assert!(!words.contains(&"foo".to_string()));
413 }
414
415 #[test]
416 fn test_no_false_positive_for_self() {
417 let files = vec![("test.rs".to_string(), "struct Foo {}\nimpl Foo { fn bar() -> Self { Self } }".to_string())];
419 let config = DeadConfig::default();
420 let results = analyze_files(&files, &config);
421 assert!(!results.iter().any(|c| c.name == "self"));
425 }
426
427 #[test]
428 fn test_scan_repo_finds_dead_in_fixture() {
429 let dir = std::env::temp_dir().join("reliary_dead_test");
431 let _ = std::fs::remove_dir_all(&dir);
432 std::fs::create_dir_all(&dir).unwrap();
433
434 std::fs::write(dir.join("defs.rs"), "pub fn live_fn() {}\npub fn orphan_fn() {}\n").unwrap();
437 std::fs::write(dir.join("caller.rs"), "fn main() { live_fn(); }\n").unwrap();
438
439 let config = DeadConfig::default();
440 let results = scan_repo(dir.to_str().unwrap(), &config);
441
442 let orphan = results.iter().find(|c| c.name == "orphan_fn");
444 assert!(orphan.is_some(), "orphan_fn should be flagged as dead");
445 assert_eq!(orphan.unwrap().total_occurrences, 1); let live = results.iter().find(|c| c.name == "live_fn");
449 assert!(live.is_none(), "live_fn should NOT be flagged as dead");
450
451 let main = results.iter().find(|c| c.name == "main");
453 assert!(main.is_none(), "main should not be flagged (entry point)");
454
455 std::fs::remove_dir_all(&dir).unwrap();
456 }
457
458 #[test]
459 fn test_scan_repo_skips_target_dirs() {
460 let dir = std::env::temp_dir().join("reliary_dead_skip_test");
461 let _ = std::fs::remove_dir_all(&dir);
462 std::fs::create_dir_all(dir.join("target")).unwrap();
463 std::fs::create_dir_all(dir.join("src")).unwrap();
464
465 std::fs::write(dir.join("src/main.rs"), "fn truly_dead() {}\n").unwrap();
467 std::fs::write(dir.join("target/build.rs"), "fn target_dead() {}\n").unwrap();
469
470 let config = DeadConfig::default();
471 let results = scan_repo(dir.to_str().unwrap(), &config);
472
473 assert!(results.iter().any(|c| c.name == "truly_dead"), "src dead fn should be found");
474 assert!(!results.iter().any(|c| c.name == "target_dead"), "target/ should be skipped");
475
476 std::fs::remove_dir_all(&dir).unwrap();
477 }
478
479 #[test]
480 fn test_scan_repo_empty_dir() {
481 let dir = std::env::temp_dir().join("reliary_dead_empty_test");
482 let _ = std::fs::remove_dir_all(&dir);
483 std::fs::create_dir_all(&dir).unwrap();
484
485 let config = DeadConfig::default();
486 let results = scan_repo(dir.to_str().unwrap(), &config);
487 assert!(results.is_empty(), "empty dir should return no results");
488
489 std::fs::remove_dir_all(&dir).unwrap();
490 }
491
492 #[test]
493 fn test_cross_file_call_prevents_dead_flag() {
494 let files = vec![
496 ("mod_a.rs".to_string(), "fn shared_helper() {}".to_string()),
497 ("mod_b.rs".to_string(), "fn caller_b() { shared_helper(); }".to_string()),
498 ("mod_c.rs".to_string(), "fn caller_c() { shared_helper(); }".to_string()),
499 ];
500 let config = DeadConfig::default();
501 let results = analyze_files(&files, &config);
502 assert!(!results.iter().any(|c| c.name == "shared_helper"),
503 "shared_helper is called from 2 other files — not dead");
504 }
505
506 #[test]
507 fn test_confidence_levels() {
508 let files = vec![
514 ("const.rs".to_string(), "const MAX_BUFFER_SIZE: usize = 4096;\n".to_string()),
515 ];
516 let config = DeadConfig::default();
517 let results = analyze_files(&files, &config);
518 let candidate = results.iter().find(|c| c.name == "max_buffer_size");
519 assert!(candidate.is_some(), "MAX_BUFFER_SIZE should be flagged");
520 assert_eq!(candidate.unwrap().confidence, Confidence::Medium,
522 "lowercased name gets Medium confidence");
523 }
524}