Skip to main content

reliary_dead/
lib.rs

1//! Grammar-free dead code detection (V13: cross-file, ported from carrion).
2//!
3//! Key difference from V1: scans ALL files and merges global occurrence counts
4//! BEFORE testing for deadness. A function called from another file will NOT
5//! be flagged as dead. This matches carrion's approach.
6use std::collections::{HashMap, HashSet};
7use std::fs;
8use std::path::Path;
9
10/// Configuration for dead code analysis
11#[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
49/// Regex-free word tokenizer: extracts identifiers matching [A-Za-z_][A-Za-z0-9_]{3,40}
50/// Grammar-free — works on any language. Filters keywords by min length.
51fn 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
84/// Grammar-free definition detection: captures the NAME after a definition keyword.
85/// Uses line-prefix matching (same as carrion).
86fn 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        // Match definition keywords followed by the name.
91        // Grammar-free: covers Rust, Python, JS, Go, Java, C/C++ patterns.
92        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
102/// Extract the defined name from a line starting with a definition keyword.
103fn extract_def_name(line: &str) -> Option<String> {
104    // Skip leading visibility/modifier keywords to find the real keyword.
105    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    // Now check for the actual definition keyword.
128    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            // Extract the first identifier from rest.
132            return extract_first_ident(rest);
133        }
134    }
135    // `let` / `var` — extract the name after the keyword.
136    for kw in &["let ", "var ", "val "] {
137        if let Some(rest) = s.strip_prefix(kw) {
138            // For let/var, skip `mut` if present.
139            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
164/// V13: Cross-file dead code detection (ported from carrion).
165/// Scans all files, merges global occurrence counts, then tests deadness.
166pub fn scan_repo(path: &str, config: &DeadConfig) -> Vec<DeadCandidate> {
167    // Collect source files.
168    let files: Vec<String> = collect_source_files(path);
169    if files.is_empty() {
170        return Vec::new();
171    }
172
173    // Global maps: name → total occurrences, name → definition locations.
174    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        // Count total word occurrences (cross-file).
184        for (word, _) in extract_words(&content, config.min_name_len) {
185            *all_counts.entry(word).or_insert(0) += 1;
186        }
187
188        // Collect definitions (with proper name extraction).
189        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    // Test deadness: total_occurrences <= def_occurrences.
195    let mut results = Vec::new();
196    for (name, locations) in &all_defs {
197        // Skip dunders and all-digit names.
198        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        // V13 cross-file: if total > def, the symbol is used elsewhere. Not dead.
209        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    // Dedup by (name, file, line).
244    let mut seen = HashSet::new();
245    results.retain(|c| seen.insert((c.name.clone(), c.file.clone(), c.line)));
246
247    // Sort by confidence (high first), then by name.
248    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
293/// Well-known entry-point names that are public-API by convention.
294fn 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
305// Backward-compat: analyze_file delegates to scan_repo for the single file.
306pub 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
311/// V13: cross-file analysis (carrion-style).
312pub 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        // helper is defined in mod1.rs but called from mod2.rs — NOT dead.
371        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        // helper appears in 2 files (1 def + 1 call), so total > def. Not dead.
378        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        // "self" is 4 chars, included. "foo" is 3 chars, excluded.
409        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        // "Self" should not appear as a dead candidate because it's not a definition name.
416        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        // "bar" appears in def + body, so not dead.
420        // "foo" appears in def + impl, so not dead.
421        // "self" appears twice, def is "impl" which captures "Foo" not "Self".
422        assert!(!results.iter().any(|c| c.name == "self"));
423    }
424
425    #[test]
426    fn test_scan_repo_finds_dead_in_fixture() {
427        // Create a temp dir with 2 files: one with a dead fn, one with a live fn.
428        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        // live_fn is called from caller.rs → not dead.
433        // orphan_fn is defined but never called → dead.
434        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        // orphan_fn should be flagged as dead.
441        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); // only the definition
444
445        // live_fn should NOT be flagged (called from caller.rs).
446        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        // main is an entry point — never flagged.
450        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        // Dead fn in src/ should be found.
464        std::fs::write(dir.join("src/main.rs"), "fn truly_dead() {}\n").unwrap();
465        // Dead fn in target/ should be SKIPPED.
466        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        // function defined in module A, called from module B, C → not dead.
493        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        // ALL_CAPS constant with no usage → High confidence.
507        // Note: names are lowercased during extraction, so the ALL_CAPS check
508        // in scan_repo tests the ORIGINAL case. analyze_files also lowercases.
509        // The High confidence path requires name.chars().all(is_ascii_uppercase).
510        // Since lowercased "max_buffer_size" is all lowercase, it gets Medium.
511        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        // Names are lowercased → can't be ALL_CAPS → Medium confidence.
519        assert_eq!(candidate.unwrap().confidence, Confidence::Medium,
520                   "lowercased name gets Medium confidence");
521    }
522}