Skip to main content

mir_analyzer/
dead_code.rs

1/// Dead-code detector (M18).
2///
3/// After Pass 2 has recorded all method/property/function references into the
4/// codebase, this analyzer walks every class and reports:
5///
6/// - `UnusedMethod`   — private method that is never called
7/// - `UnusedProperty` — private property that is never read
8/// - `UnusedFunction` — non-public free function that is never called
9///
10/// Magic methods (`__construct`, `__destruct`, `__toString`, etc.) and
11/// constructors are excluded because they are called implicitly.
12use mir_codebase::storage::Visibility;
13use mir_codebase::Codebase;
14use mir_issues::{Issue, IssueKind, Location, Severity};
15
16use crate::stubs::StubVfs;
17
18// Magic PHP methods that are invoked implicitly — never flag these as unused.
19const MAGIC_METHODS: &[&str] = &[
20    "__construct",
21    "__destruct",
22    "__call",
23    "__callstatic",
24    "__get",
25    "__set",
26    "__isset",
27    "__unset",
28    "__sleep",
29    "__wakeup",
30    "__serialize",
31    "__unserialize",
32    "__tostring",
33    "__invoke",
34    "__set_state",
35    "__clone",
36    "__debuginfo",
37];
38
39pub struct DeadCodeAnalyzer<'a> {
40    codebase: &'a Codebase,
41}
42
43impl<'a> DeadCodeAnalyzer<'a> {
44    pub fn new(codebase: &'a Codebase) -> Self {
45        Self { codebase }
46    }
47
48    pub fn analyze(&self) -> Vec<Issue> {
49        let mut issues = Vec::new();
50
51        // --- Private methods / properties on classes ---
52        for entry in self.codebase.classes.iter() {
53            let cls = entry.value();
54            let fqcn = cls.fqcn.as_ref();
55
56            for (method_name, method) in &cls.own_methods {
57                if method.visibility != Visibility::Private {
58                    continue;
59                }
60                let name = method_name.as_ref();
61                if MAGIC_METHODS.contains(&name) {
62                    continue;
63                }
64                if !self.codebase.is_method_referenced(fqcn, name) {
65                    let (file, line) = location_from_storage(&method.location);
66                    issues.push(Issue::new(
67                        IssueKind::UnusedMethod {
68                            class: fqcn.to_string(),
69                            method: name.to_string(),
70                        },
71                        Location {
72                            file,
73                            line,
74                            line_end: line,
75                            col_start: 0,
76                            col_end: 0,
77                        },
78                    ));
79                }
80            }
81
82            for (prop_name, prop) in &cls.own_properties {
83                if prop.visibility != Visibility::Private {
84                    continue;
85                }
86                let name = prop_name.as_ref();
87                if !self.codebase.is_property_referenced(fqcn, name) {
88                    let (file, line) = location_from_storage(&prop.location);
89                    issues.push(Issue::new(
90                        IssueKind::UnusedProperty {
91                            class: fqcn.to_string(),
92                            property: name.to_string(),
93                        },
94                        Location {
95                            file,
96                            line,
97                            line_end: line,
98                            col_start: 0,
99                            col_end: 0,
100                        },
101                    ));
102                }
103            }
104        }
105
106        // --- Non-referenced free functions ---
107        let stub_vfs = StubVfs::new();
108        for entry in self.codebase.functions.iter() {
109            let func = entry.value();
110            let fqn = func.fqn.as_ref();
111            // Skip PHP built-in and extension functions loaded from stubs —
112            // they are not user-defined dead code.
113            if let Some(loc) = &func.location {
114                if stub_vfs.is_stub_file(loc.file.as_ref()) {
115                    continue;
116                }
117            }
118            if !self.codebase.is_function_referenced(fqn) {
119                let (file, line) = location_from_storage(&func.location);
120                issues.push(Issue::new(
121                    IssueKind::UnusedFunction {
122                        name: func.short_name.to_string(),
123                    },
124                    Location {
125                        file,
126                        line,
127                        line_end: line,
128                        col_start: 0,
129                        col_end: 0,
130                    },
131                ));
132            }
133        }
134
135        // Downgrade all dead-code issues to Info
136        for issue in &mut issues {
137            issue.severity = Severity::Info;
138        }
139
140        issues
141    }
142}
143
144fn location_from_storage(
145    loc: &Option<mir_codebase::storage::Location>,
146) -> (std::sync::Arc<str>, u32) {
147    match loc {
148        Some(l) => (l.file.clone(), 1), // byte offset → line mapping not available here
149        None => (std::sync::Arc::from("<unknown>"), 1),
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::project::ProjectAnalyzer;
157
158    #[test]
159    fn builtin_functions_not_flagged_as_unused() {
160        // The dead-code pass must not produce UnusedFunction for PHP built-ins
161        // (strlen, array_map, etc.) even when they are never called in user code.
162        // This test bypasses the fixture runner's file-path filter to verify the
163        // fix directly on the DeadCodeAnalyzer output.
164        let analyzer = ProjectAnalyzer::new();
165        analyzer.load_stubs();
166        let issues = DeadCodeAnalyzer::new(analyzer.codebase()).analyze();
167        let builtin_false_positives: Vec<_> = issues
168            .iter()
169            .filter(|i| {
170                matches!(&i.kind, IssueKind::UnusedFunction { name } if
171                    matches!(name.as_str(), "strlen" | "array_map" | "json_encode" | "preg_match")
172                )
173            })
174            .collect();
175        assert!(
176            builtin_false_positives.is_empty(),
177            "Expected no UnusedFunction for PHP builtins, got: {:?}",
178            builtin_false_positives
179                .iter()
180                .map(|i| i.kind.message())
181                .collect::<Vec<_>>()
182        );
183    }
184}