Skip to main content

reliary_dead/
lib.rs

1/// Grammar-free dead code detection.
2/// Uses occurrence counting: an identifier is dead if total_occurrences == definition_occurrences.
3
4use std::collections::{HashMap, HashSet};
5
6/// Configuration for dead code analysis
7#[derive(Debug, Clone)]
8pub struct DeadConfig {
9    pub min_name_len: usize,
10    pub test_file_patterns: Vec<String>,
11}
12
13impl Default for DeadConfig {
14    fn default() -> Self {
15        Self {
16            min_name_len: 3,
17            test_file_patterns: vec!["test".to_string(), "spec".to_string(), "mock".to_string()],
18        }
19    }
20}
21
22#[derive(Debug, Clone)]
23pub struct DeadCandidate {
24    pub name: String,
25    pub file: String,
26    pub line: usize,
27    pub confidence: Confidence,
28    pub reason: String,
29}
30
31#[derive(Debug, Clone, PartialEq)]
32pub enum Confidence {
33    High,
34    Medium,
35    Low,
36}
37
38/// Analyze a single file for dead identifiers
39pub fn analyze_file(file: &str, content: &str, config: &DeadConfig) -> Vec<DeadCandidate> {
40    let lines: Vec<&str> = content.lines().collect();
41    let mut identifiers: HashMap<String, (Vec<usize>, Vec<usize>)> = HashMap::new();
42    // Maps: identifier → (definition_lines, total_occurrence_lines)
43
44    for (i, line) in lines.iter().enumerate() {
45        // Find all identifiers in this line
46        for token in line.split(|c: char| !c.is_alphanumeric() && c != '_') {
47            if token.len() < config.min_name_len { continue; }
48            if !token.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') { continue; }
49            if !token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { continue; }
50
51            // Check if this line is a definition site
52            let t = line.trim();
53            let is_def = t.starts_with("fn ") || t.starts_with("def ")
54                || t.starts_with("class ") || t.starts_with("struct ")
55                || t.starts_with("enum ") || t.starts_with("trait ")
56                || t.starts_with("const ") || t.starts_with("let ")
57                || t.starts_with("var ") || t.starts_with("function ")
58                || t.starts_with("public ") || t.starts_with("private ")
59                || line.contains(&format!("fn {}", token))
60                || line.contains(&format!("def {}", token));
61
62            let entry = identifiers.entry(token.to_string()).or_insert((Vec::new(), Vec::new()));
63            if is_def {
64                entry.0.push(i);
65            }
66            entry.1.push(i);
67        }
68    }
69
70    let mut candidates = Vec::new();
71    let is_test_file = config.test_file_patterns.iter().any(|p| file.contains(p));
72
73    for (name, (def_lines, total_lines)) in identifiers {
74        // Dead: defined but never used outside its own definition line
75        if def_lines.is_empty() { continue; }
76        let def_only = def_lines.iter().all(|def_line| {
77            total_lines.iter().all(|&tl| tl == *def_line)
78        });
79        if !def_only { continue; }
80
81        let confidence = if is_test_file {
82            Confidence::Low
83        } else if name.chars().all(|c| c.is_ascii_uppercase() || c == '_') && name.len() >= 5 {
84            Confidence::High
85        } else if name.len() >= 5 {
86            Confidence::Medium
87        } else {
88            Confidence::Low
89        };
90
91        let reason = match &confidence {
92            Confidence::High => format!("{} — exported but never imported", name),
93            Confidence::Medium => format!("{} — defined but never referenced outside definition", name),
94            Confidence::Low => format!("{} — potentially dead (test file)", name),
95        };
96
97        for &dl in &def_lines {
98            candidates.push(DeadCandidate {
99                name: name.clone(),
100                file: file.to_string(),
101                line: dl + 1,
102                confidence: confidence.clone(),
103                reason: reason.clone(),
104            });
105        }
106    }
107
108    candidates
109}
110
111/// Analyze multiple files and return aggregated dead code results
112pub fn analyze_files(files: &[(String, String)], config: &DeadConfig) -> Vec<DeadCandidate> {
113    let mut all = Vec::new();
114    for (file, content) in files {
115        all.extend(analyze_file(file, content, config));
116    }
117    // Dedup by name
118    let mut seen = HashSet::new();
119    all.into_iter().filter(|c| seen.insert(c.name.clone())).collect()
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_dead_function() {
128        let content = "fn helper() {}\nfn main() { helper(); }";
129        let config = DeadConfig::default();
130        let results = analyze_file("test.rs", content, &config);
131        // helper is used, main is used
132        let dead: Vec<_> = results.iter().filter(|c| c.name == "helper").collect();
133        assert!(dead.is_empty()); // helper is called
134    }
135
136    #[test]
137    fn test_unused_function() {
138        let content = "fn dead_func() {}\nfn main() {}";
139        let config = DeadConfig::default();
140        let results = analyze_file("test.rs", content, &config);
141        assert!(results.iter().any(|c| c.name == "dead_func"));
142    }
143
144    #[test]
145    fn test_test_file_low_confidence() {
146        let content = "fn helper() {}\n";
147        let config = DeadConfig::default();
148        let results = analyze_file("test_helper.rs", content, &config);
149        assert!(!results.is_empty());
150        assert_eq!(results[0].confidence, Confidence::Low);
151    }
152
153    #[test]
154    fn test_high_confidence_constant() {
155        let content = "const API_KEY = 123;\n";
156        let config = DeadConfig::default();
157        let results = analyze_file("config.rs", content, &config);
158        // constants with all-caps names get high confidence
159        let high: Vec<_> = results.iter().filter(|c| c.confidence == Confidence::High).collect();
160        assert!(!high.is_empty());
161    }
162}