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::db::MirDatabase;
17use crate::stubs::StubVfs;
18
19// Magic PHP methods that are invoked implicitly — never flag these as unused.
20const MAGIC_METHODS: &[&str] = &[
21    "__construct",
22    "__destruct",
23    "__call",
24    "__callstatic",
25    "__get",
26    "__set",
27    "__isset",
28    "__unset",
29    "__sleep",
30    "__wakeup",
31    "__serialize",
32    "__unserialize",
33    "__tostring",
34    "__invoke",
35    "__set_state",
36    "__clone",
37    "__debuginfo",
38];
39
40pub struct DeadCodeAnalyzer<'a> {
41    codebase: &'a Codebase,
42    db: &'a dyn MirDatabase,
43}
44
45impl<'a> DeadCodeAnalyzer<'a> {
46    pub fn new(codebase: &'a Codebase, db: &'a dyn MirDatabase) -> Self {
47        Self { codebase, db }
48    }
49
50    pub fn analyze(&self) -> Vec<Issue> {
51        let mut issues = Vec::new();
52
53        // --- Private methods / properties on classes ---
54        // Walk only class-kind nodes (not interfaces/traits/enums) to match
55        // the previous `Codebase::classes` iteration semantics.
56        for fqcn in self.db.active_class_node_fqcns() {
57            let Some(class_node) = self.db.lookup_class_node(fqcn.as_ref()) else {
58                continue;
59            };
60            if class_node.is_interface(self.db)
61                || class_node.is_trait(self.db)
62                || class_node.is_enum(self.db)
63            {
64                continue;
65            }
66            let fqcn_str = fqcn.as_ref();
67
68            for method in self.db.class_own_methods(fqcn_str) {
69                if !method.active(self.db) {
70                    continue;
71                }
72                if method.visibility(self.db) != Visibility::Private {
73                    continue;
74                }
75                let name = method.name(self.db);
76                let name_lower = name.to_lowercase();
77                if MAGIC_METHODS.contains(&name_lower.as_str()) {
78                    continue;
79                }
80                if !self.codebase.is_method_referenced(fqcn_str, name.as_ref()) {
81                    let (file, line) = location_from_storage(&method.location(self.db));
82                    issues.push(Issue::new(
83                        IssueKind::UnusedMethod {
84                            class: fqcn_str.to_string(),
85                            method: name.to_string(),
86                        },
87                        Location {
88                            file,
89                            line,
90                            line_end: line,
91                            col_start: 0,
92                            col_end: 0,
93                        },
94                    ));
95                }
96            }
97
98            for prop in self.db.class_own_properties(fqcn_str) {
99                if !prop.active(self.db) {
100                    continue;
101                }
102                if prop.visibility(self.db) != Visibility::Private {
103                    continue;
104                }
105                let name = prop.name(self.db);
106                if !self
107                    .codebase
108                    .is_property_referenced(fqcn_str, name.as_ref())
109                {
110                    let (file, line) = location_from_storage(&prop.location(self.db));
111                    issues.push(Issue::new(
112                        IssueKind::UnusedProperty {
113                            class: fqcn_str.to_string(),
114                            property: name.to_string(),
115                        },
116                        Location {
117                            file,
118                            line,
119                            line_end: line,
120                            col_start: 0,
121                            col_end: 0,
122                        },
123                    ));
124                }
125            }
126        }
127
128        // --- Non-referenced free functions ---
129        let stub_vfs = StubVfs::new();
130        for fqn in self.db.active_function_node_fqns() {
131            let Some(node) = self.db.lookup_function_node(fqn.as_ref()) else {
132                continue;
133            };
134            if !node.active(self.db) {
135                continue;
136            }
137            let location = node.location(self.db);
138            // Skip PHP built-in and extension functions loaded from stubs —
139            // they are not user-defined dead code.
140            if let Some(loc) = &location {
141                if stub_vfs.is_stub_file(loc.file.as_ref()) {
142                    continue;
143                }
144            }
145            if !self.codebase.is_function_referenced(fqn.as_ref()) {
146                let (file, line) = location_from_storage(&location);
147                issues.push(Issue::new(
148                    IssueKind::UnusedFunction {
149                        name: node.short_name(self.db).to_string(),
150                    },
151                    Location {
152                        file,
153                        line,
154                        line_end: line,
155                        col_start: 0,
156                        col_end: 0,
157                    },
158                ));
159            }
160        }
161
162        // Downgrade all dead-code issues to Info
163        for issue in &mut issues {
164            issue.severity = Severity::Info;
165        }
166
167        issues
168    }
169}
170
171fn location_from_storage(
172    loc: &Option<mir_codebase::storage::Location>,
173) -> (std::sync::Arc<str>, u32) {
174    match loc {
175        Some(l) => (l.file.clone(), 1), // byte offset → line mapping not available here
176        None => (std::sync::Arc::from("<unknown>"), 1),
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::project::ProjectAnalyzer;
184
185    #[test]
186    fn builtin_functions_not_flagged_as_unused() {
187        // The dead-code pass must not produce UnusedFunction for PHP built-ins
188        // (strlen, array_map, etc.) even when they are never called in user code.
189        // This test bypasses the fixture runner's file-path filter to verify the
190        // fix directly on the DeadCodeAnalyzer output.
191        let analyzer = ProjectAnalyzer::new();
192        analyzer.load_stubs();
193        let salsa = analyzer.salsa_db_for_test();
194        let salsa = salsa.lock().unwrap();
195        let issues = DeadCodeAnalyzer::new(analyzer.codebase(), &salsa.0).analyze();
196        let builtin_false_positives: Vec<_> = issues
197            .iter()
198            .filter(|i| {
199                matches!(&i.kind, IssueKind::UnusedFunction { name } if
200                    matches!(name.as_str(), "strlen" | "array_map" | "json_encode" | "preg_match")
201                )
202            })
203            .collect();
204        assert!(
205            builtin_false_positives.is_empty(),
206            "Expected no UnusedFunction for PHP builtins, got: {:?}",
207            builtin_false_positives
208                .iter()
209                .map(|i| i.kind.message())
210                .collect::<Vec<_>>()
211        );
212    }
213}