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 the NAME after a definition keyword.
87/// Uses line-prefix matching (same as carrion).
88fn 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        // Match definition keywords followed by the name.
93        // Grammar-free: covers Rust, Python, JS, Go, Java, C/C++ patterns.
94        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
104/// Extract the defined name from a line starting with a definition keyword.
105fn extract_def_name(line: &str) -> Option<String> {
106    // Skip leading visibility/modifier keywords to find the real keyword.
107    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    // Now check for the actual definition keyword.
130    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            // Extract the first identifier from rest.
134            return extract_first_ident(rest);
135        }
136    }
137    // `let` / `var` — extract the name after the keyword.
138    for kw in &["let ", "var ", "val "] {
139        if let Some(rest) = s.strip_prefix(kw) {
140            // For let/var, skip `mut` if present.
141            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
166/// V13: Cross-file dead code detection (ported from carrion).
167/// Scans all files, merges global occurrence counts, then tests deadness.
168pub fn scan_repo(path: &str, config: &DeadConfig) -> Vec<DeadCandidate> {
169    // Collect source files.
170    let files: Vec<String> = collect_source_files(path);
171    if files.is_empty() {
172        return Vec::new();
173    }
174
175    // Global maps: name → total occurrences, name → definition locations.
176    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        // Count total word occurrences (cross-file).
186        for (word, _) in extract_words(&content, config.min_name_len) {
187            *all_counts.entry(word).or_insert(0) += 1;
188        }
189
190        // Collect definitions (with proper name extraction).
191        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    // Test deadness: total_occurrences <= def_occurrences.
197    let mut results = Vec::new();
198    for (name, locations) in &all_defs {
199        // Skip dunders and all-digit names.
200        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        // V13 cross-file: if total > def, the symbol is used elsewhere. Not dead.
211        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    // Dedup by (name, file, line).
246    let mut seen = HashSet::new();
247    results.retain(|c| seen.insert((c.name.clone(), c.file.clone(), c.line)));
248
249    // Sort by confidence (high first), then by name.
250    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
295/// Well-known entry-point names that are public-API by convention.
296fn 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
307// Backward-compat: analyze_file delegates to scan_repo for the single file.
308pub 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
313/// V13: cross-file analysis (carrion-style).
314pub 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        // helper is defined in mod1.rs but called from mod2.rs — NOT dead.
373        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        // helper appears in 2 files (1 def + 1 call), so total > def. Not dead.
380        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        // "self" is 4 chars, included. "foo" is 3 chars, excluded.
411        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        // "Self" should not appear as a dead candidate because it's not a definition name.
418        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        // "bar" appears in def + body, so not dead.
422        // "foo" appears in def + impl, so not dead.
423        // "self" appears twice, def is "impl" which captures "Foo" not "Self".
424        assert!(!results.iter().any(|c| c.name == "self"));
425    }
426
427    #[test]
428    fn test_scan_repo_finds_dead_in_fixture() {
429        // Create a temp dir with 2 files: one with a dead fn, one with a live fn.
430        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        // live_fn is called from caller.rs → not dead.
435        // orphan_fn is defined but never called → dead.
436        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        // orphan_fn should be flagged as dead.
443        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); // only the definition
446
447        // live_fn should NOT be flagged (called from caller.rs).
448        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        // main is an entry point — never flagged.
452        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        // Dead fn in src/ should be found.
466        std::fs::write(dir.join("src/main.rs"), "fn truly_dead() {}\n").unwrap();
467        // Dead fn in target/ should be SKIPPED.
468        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        // function defined in module A, called from module B, C → not dead.
495        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        // ALL_CAPS constant with no usage → High confidence.
509        // Note: names are lowercased during extraction, so the ALL_CAPS check
510        // in scan_repo tests the ORIGINAL case. analyze_files also lowercases.
511        // The High confidence path requires name.chars().all(is_ascii_uppercase).
512        // Since lowercased "max_buffer_size" is all lowercase, it gets Medium.
513        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        // Names are lowercased → can't be ALL_CAPS → Medium confidence.
521        assert_eq!(candidate.unwrap().confidence, Confidence::Medium,
522                   "lowercased name gets Medium confidence");
523    }
524}