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