Skip to main content

reliary_dead/
lib.rs

1#![forbid(unsafe_code)]
2//! Grammar-free dead code detection (V13: cross-file, ported from carrion).
3//!
4//! Key difference from V1: scans ALL files and merges global occurrence counts
5//! BEFORE testing for deadness. A function called from another file will NOT
6//! be flagged as dead. This matches carrion's approach.
7
8use std::collections::{HashMap, HashSet};
9use std::fs;
10use std::path::Path;
11
12/// Configuration for dead code analysis
13#[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
51/// Regex-free word tokenizer: extracts identifiers matching [A-Za-z_][A-Za-z0-9_]{3,40}
52/// Grammar-free — works on any language. Filters keywords by min length.
53fn 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
86/// Grammar-free definition detection: captures (name, line, exported).
87/// `exported` is true when the declaration carries a visibility/export
88/// modifier (`pub`, `export`, `public`, …) — an exported-but-unused symbol is
89/// the strongest dead-code signal, so it drives `Confidence::High`.
90fn 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        // Match definition keywords followed by the name.
95        // Grammar-free: covers Rust, Python, JS, Go, Java, C/C++ patterns.
96        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
106/// Whether a declaration line carries a visibility/export modifier. Grammar-free
107/// prefix scan (the same modifier set `extract_def_name` strips).
108fn 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                // Mark exported once we pass the first visibility modifier.
129                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
141/// Extract the defined name from a line starting with a definition keyword.
142fn extract_def_name(line: &str) -> Option<String> {
143    // Skip leading visibility/modifier keywords to find the real keyword.
144    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    // Now check for the actual definition keyword.
167    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            // Extract the first identifier from rest.
171            return extract_first_ident(rest);
172        }
173    }
174    // `let` / `var` — extract the name after the keyword.
175    for kw in &["let ", "var ", "val "] {
176        if let Some(rest) = s.strip_prefix(kw) {
177            // For let/var, skip `mut` if present.
178            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
203/// V13: Cross-file dead code detection (ported from carrion).
204/// Scans all files, merges global occurrence counts, then tests deadness.
205pub fn scan_repo(path: &str, config: &DeadConfig) -> Vec<DeadCandidate> {
206    // Collect source files.
207    let files: Vec<String> = collect_source_files(path);
208    if files.is_empty() {
209        return Vec::new();
210    }
211
212    // Global maps: name → total occurrences, name → (definition locations, exported).
213    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        // Count total word occurrences (cross-file).
223        for (word, _) in extract_words(&content, config.min_name_len) {
224            *all_counts.entry(word).or_insert(0) += 1;
225        }
226
227        // Collect definitions (with proper name extraction + visibility).
228        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    // Test deadness: total_occurrences <= def_occurrences.
236    let mut results = Vec::new();
237    for (name, (locations, exported)) in &all_defs {
238        // Skip dunders and all-digit names.
239        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        // V13 cross-file: if total > def, the symbol is used elsewhere. Not dead.
250        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        // V80: `High` = exported (pub/export/public) but never used — the
258        // strongest signal. The old check required an ALL-CAPS name, but names
259        // are lowercased at extraction, so `High` was unreachable.
260        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    // Dedup by (name, file, line).
288    let mut seen = HashSet::new();
289    results.retain(|c| seen.insert((c.name.clone(), c.file.clone(), c.line)));
290
291    // Sort by confidence (high first), then by name.
292    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
337/// Well-known entry-point names that are public-API by convention.
338fn 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
349// Backward-compat: analyze_file delegates to scan_repo for the single file.
350pub 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
355/// V13: cross-file analysis (carrion-style).
356pub 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        // V80: exported-but-unused → High (the old ALL-CAPS check was dead code
379        // because names are lowercased at extraction).
380        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        // helper is defined in mod1.rs but called from mod2.rs — NOT dead.
419        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        // helper appears in 2 files (1 def + 1 call), so total > def. Not dead.
426        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        // "self" is 4 chars, included. "foo" is 3 chars, excluded.
457        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        // "Self" should not appear as a dead candidate because it's not a definition name.
464        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        // "bar" appears in def + body, so not dead.
468        // "foo" appears in def + impl, so not dead.
469        // "self" appears twice, def is "impl" which captures "Foo" not "Self".
470        assert!(!results.iter().any(|c| c.name == "self"));
471    }
472
473    #[test]
474    fn test_scan_repo_finds_dead_in_fixture() {
475        // Create a temp dir with 2 files: one with a dead fn, one with a live fn.
476        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        // live_fn is called from caller.rs → not dead.
481        // orphan_fn is defined but never called → dead.
482        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        // orphan_fn should be flagged as dead.
489        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); // only the definition
492
493        // live_fn should NOT be flagged (called from caller.rs).
494        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        // main is an entry point — never flagged.
498        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        // Dead fn in src/ should be found.
512        std::fs::write(dir.join("src/main.rs"), "fn truly_dead() {}\n").unwrap();
513        // Dead fn in target/ should be SKIPPED.
514        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        // function defined in module A, called from module B, C → not dead.
541        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        // V80: High = exported-but-unused; Medium = private-but-unused.
555        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}