Skip to main content

cha_core/plugins/
dead_code.rs

1use crate::{AnalysisContext, Finding, Location, Plugin, Severity, SmellCategory};
2
3/// Detect non-exported functions/classes that may be dead code.
4///
5/// When `ctx.project` is available, the check is project-aware: a symbol is
6/// genuinely "dead" only if it's neither referenced in its own file nor called
7/// from any other file. Without `ctx.project`, falls back to single-file
8/// text search (legacy mode used in unit tests).
9pub struct DeadCodeAnalyzer;
10
11impl Plugin for DeadCodeAnalyzer {
12    fn name(&self) -> &str {
13        "dead_code"
14    }
15
16    fn smells(&self) -> Vec<String> {
17        vec!["dead_code".into()]
18    }
19
20    fn description(&self) -> &str {
21        "Unexported and unreferenced code"
22    }
23
24    fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
25        // C/C++ files using token-concatenation macros (#define X(n) foo_##n)
26        // create function references invisible to AST and to ProjectQuery's
27        // call graph (parsers don't macro-expand). Skip the file rather than
28        // report false positives — common pattern in SVG parsers, dispatch
29        // tables, X-macros.
30        if matches!(ctx.model.language.as_str(), "c" | "cpp")
31            && has_token_concat_macros(&ctx.file.content)
32        {
33            return vec![];
34        }
35
36        let mut findings = Vec::new();
37        check_dead_functions(ctx, &mut findings);
38        check_dead_classes(ctx, &mut findings);
39        findings
40    }
41}
42
43/// Detect `#define ... ##` patterns indicating token-concatenation macros.
44/// These hide function references from any project-wide call graph because
45/// parsers operate on pre-expansion source. Conservative: scan for `#define`
46/// lines containing `##` (cheap, no false negatives for the pattern).
47fn has_token_concat_macros(content: &str) -> bool {
48    let mut in_define = false;
49    for line in content.lines() {
50        let trimmed = line.trim_start();
51        if trimmed.starts_with("#define") {
52            in_define = true;
53        }
54        if in_define && trimmed.contains("##") {
55            return true;
56        }
57        if in_define && !line.trim_end().ends_with('\\') {
58            in_define = false;
59        }
60    }
61    false
62}
63
64/// Flag unexported, unreferenced functions as potential dead code.
65fn check_dead_functions(ctx: &AnalysisContext, findings: &mut Vec<Finding>) {
66    for f in &ctx.model.functions {
67        if f.is_exported || is_entry_point(&f.name) {
68            continue;
69        }
70        if is_in_file_referenced(&ctx.file.content, &f.name, f.start_line, f.end_line) {
71            continue;
72        }
73        if let Some(p) = ctx.project
74            && p.is_called_externally(&f.name, &ctx.file.path)
75        {
76            continue;
77        }
78        findings.push(make_dead_code_finding(
79            ctx,
80            f.start_line,
81            f.name_col,
82            f.name_end_col,
83            &f.name,
84            "Function",
85        ));
86    }
87}
88
89/// Flag unexported, unreferenced classes as potential dead code.
90fn check_dead_classes(ctx: &AnalysisContext, findings: &mut Vec<Finding>) {
91    for c in &ctx.model.classes {
92        if c.is_exported {
93            continue;
94        }
95        if is_in_file_referenced(&ctx.file.content, &c.name, c.start_line, c.end_line) {
96            continue;
97        }
98        if let Some(p) = ctx.project
99            && p.is_called_externally(&c.name, &ctx.file.path)
100        {
101            continue;
102        }
103        findings.push(make_dead_code_finding(
104            ctx,
105            c.start_line,
106            c.name_col,
107            c.name_end_col,
108            &c.name,
109            "Class",
110        ));
111    }
112}
113
114/// Build a dead code finding for a given symbol.
115fn make_dead_code_finding(
116    ctx: &AnalysisContext,
117    start_line: usize,
118    name_col: usize,
119    name_end_col: usize,
120    name: &str,
121    kind: &str,
122) -> Finding {
123    Finding {
124        smell_name: "dead_code".into(),
125        category: SmellCategory::Dispensables,
126        severity: Severity::Hint,
127        location: Location {
128            path: ctx.file.path.clone(),
129            start_line,
130            start_col: name_col,
131            end_line: start_line,
132            end_col: name_end_col,
133            name: Some(name.to_string()),
134        },
135        message: format!("{} `{}` is not exported and may be unused", kind, name),
136        suggested_refactorings: vec!["Remove dead code".into()],
137        ..Default::default()
138    }
139}
140
141/// Check if `name` is referenced inside the same file outside its definition lines.
142fn is_in_file_referenced(content: &str, name: &str, def_start: usize, def_end: usize) -> bool {
143    for (i, line) in content.lines().enumerate() {
144        let line_num = i + 1;
145        if line_num >= def_start && line_num <= def_end {
146            continue;
147        }
148        if line.contains(name) {
149            return true;
150        }
151    }
152    false
153}
154
155/// Names that are entry points or framework callbacks, not dead code.
156fn is_entry_point(name: &str) -> bool {
157    matches!(name, "main" | "new" | "default" | "drop" | "fmt")
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn macro_detection_finds_simple_concat() {
166        let src = "#define FN(name) handle_##name##_attr\n";
167        assert!(has_token_concat_macros(src));
168    }
169
170    #[test]
171    fn macro_detection_finds_multiline_concat() {
172        let src = "#define X(a, b) \\\n    foo_##a##_##b\n";
173        assert!(has_token_concat_macros(src));
174    }
175
176    #[test]
177    fn macro_detection_ignores_concat_outside_define() {
178        let src = "// this comment has ## in it\nlet s = \"a##b\";\n";
179        assert!(!has_token_concat_macros(src));
180    }
181
182    #[test]
183    fn macro_detection_ignores_define_without_concat() {
184        let src = "#define MAX(a, b) ((a) > (b) ? (a) : (b))\n";
185        assert!(!has_token_concat_macros(src));
186    }
187
188    #[test]
189    fn macro_detection_handles_no_macros() {
190        let src = "int main() { return 0; }\n";
191        assert!(!has_token_concat_macros(src));
192    }
193}