mir_analyzer/class.rs
1/// Class analyzer — validates class definitions after codebase finalization.
2///
3/// Checks performed (all codebase-level, no AST required):
4/// - Concrete class implements all abstract parent methods
5/// - Concrete class implements all interface methods
6/// - Overriding method does not reduce visibility
7/// - Overriding method return type is covariant with parent
8/// - Overriding method does not override a final method
9/// - Class does not extend a final class
10use std::collections::HashSet;
11use std::sync::Arc;
12
13use mir_codebase::storage::{MethodStorage, Visibility};
14use mir_codebase::Codebase;
15use mir_issues::{Issue, IssueKind, Location};
16
17// ---------------------------------------------------------------------------
18// ClassAnalyzer
19// ---------------------------------------------------------------------------
20
21pub struct ClassAnalyzer<'a> {
22 codebase: &'a Codebase,
23 /// Only report issues for classes defined in these files (empty = all files).
24 analyzed_files: HashSet<Arc<str>>,
25}
26
27impl<'a> ClassAnalyzer<'a> {
28 pub fn new(codebase: &'a Codebase) -> Self {
29 Self {
30 codebase,
31 analyzed_files: HashSet::new(),
32 }
33 }
34
35 pub fn with_files(codebase: &'a Codebase, files: HashSet<Arc<str>>) -> Self {
36 Self {
37 codebase,
38 analyzed_files: files,
39 }
40 }
41
42 /// Run all class-level checks and return every discovered issue.
43 pub fn analyze_all(&self) -> Vec<Issue> {
44 let mut issues = Vec::new();
45
46 let class_keys: Vec<Arc<str>> = self
47 .codebase
48 .classes
49 .iter()
50 .map(|e| e.key().clone())
51 .collect();
52
53 for fqcn in &class_keys {
54 let cls = match self.codebase.classes.get(fqcn.as_ref()) {
55 Some(c) => c,
56 None => continue,
57 };
58
59 // Skip classes from vendor / stub files — only check user-analyzed files
60 if !self.analyzed_files.is_empty() {
61 let in_analyzed = cls
62 .location
63 .as_ref()
64 .map(|loc| self.analyzed_files.contains(&loc.file))
65 .unwrap_or(false);
66 if !in_analyzed {
67 continue;
68 }
69 }
70
71 let loc = dummy_location(fqcn);
72
73 // ---- 1. Final-class extension check --------------------------------
74 if let Some(parent_fqcn) = &cls.parent {
75 if let Some(parent) = self.codebase.classes.get(parent_fqcn.as_ref()) {
76 if parent.is_final {
77 issues.push(Issue::new(
78 IssueKind::FinalClassExtended {
79 parent: parent_fqcn.to_string(),
80 child: fqcn.to_string(),
81 },
82 loc.clone(),
83 ));
84 }
85 }
86 }
87
88 // Skip abstract classes for "must implement" checks
89 if cls.is_abstract {
90 // Still check override compatibility for abstract classes
91 self.check_overrides(&cls, &mut issues);
92 continue;
93 }
94
95 // ---- 2. Abstract parent methods must be implemented ----------------
96 self.check_abstract_methods_implemented(&cls, &mut issues);
97
98 // ---- 3. Interface methods must be implemented ----------------------
99 self.check_interface_methods_implemented(&cls, &mut issues);
100
101 // ---- 4. Method override compatibility ------------------------------
102 self.check_overrides(&cls, &mut issues);
103 }
104
105 issues
106 }
107
108 // -----------------------------------------------------------------------
109 // Check: all abstract methods from ancestor chain are implemented
110 // -----------------------------------------------------------------------
111
112 fn check_abstract_methods_implemented(
113 &self,
114 cls: &mir_codebase::storage::ClassStorage,
115 issues: &mut Vec<Issue>,
116 ) {
117 let fqcn = &cls.fqcn;
118
119 // Walk every ancestor class and collect abstract methods
120 for ancestor_fqcn in &cls.all_parents {
121 let ancestor = match self.codebase.classes.get(ancestor_fqcn.as_ref()) {
122 Some(a) => a,
123 None => continue,
124 };
125
126 for (method_name, method) in &ancestor.own_methods {
127 if !method.is_abstract {
128 continue;
129 }
130
131 // Check if the concrete class (or any closer ancestor) provides it
132 if cls
133 .get_method(method_name.as_ref())
134 .map(|m| !m.is_abstract)
135 .unwrap_or(false)
136 {
137 continue; // implemented
138 }
139
140 issues.push(Issue::new(
141 IssueKind::UnimplementedAbstractMethod {
142 class: fqcn.to_string(),
143 method: method_name.to_string(),
144 },
145 dummy_location(fqcn),
146 ));
147 }
148 }
149 }
150
151 // -----------------------------------------------------------------------
152 // Check: all interface methods are implemented
153 // -----------------------------------------------------------------------
154
155 fn check_interface_methods_implemented(
156 &self,
157 cls: &mir_codebase::storage::ClassStorage,
158 issues: &mut Vec<Issue>,
159 ) {
160 let fqcn = &cls.fqcn;
161
162 // Collect all interfaces (direct + from ancestors)
163 let all_ifaces: Vec<Arc<str>> = cls
164 .all_parents
165 .iter()
166 .filter(|p| self.codebase.interfaces.contains_key(p.as_ref()))
167 .cloned()
168 .collect();
169
170 for iface_fqcn in &all_ifaces {
171 let iface = match self.codebase.interfaces.get(iface_fqcn.as_ref()) {
172 Some(i) => i,
173 None => continue,
174 };
175
176 for (method_name, _method) in &iface.own_methods {
177 // Check if the class provides a concrete implementation
178 let implemented = cls
179 .get_method(method_name.as_ref())
180 .map(|m| !m.is_abstract)
181 .unwrap_or(false);
182
183 if !implemented {
184 issues.push(Issue::new(
185 IssueKind::UnimplementedInterfaceMethod {
186 class: fqcn.to_string(),
187 interface: iface_fqcn.to_string(),
188 method: method_name.to_string(),
189 },
190 dummy_location(fqcn),
191 ));
192 }
193 }
194 }
195 }
196
197 // -----------------------------------------------------------------------
198 // Check: override compatibility
199 // -----------------------------------------------------------------------
200
201 fn check_overrides(&self, cls: &mir_codebase::storage::ClassStorage, issues: &mut Vec<Issue>) {
202 let fqcn = &cls.fqcn;
203 // Use the actual source file if available, otherwise fall back to fqcn.
204 let class_file: Arc<str> = cls
205 .location
206 .as_ref()
207 .map(|l| l.file.clone())
208 .unwrap_or_else(|| fqcn.clone());
209
210 for (method_name, own_method) in &cls.own_methods {
211 // PHP does not enforce constructor signature compatibility
212 if method_name.as_ref() == "__construct" {
213 continue;
214 }
215
216 // Find parent definition (if any) — search ancestor chain
217 let parent_method = self.find_parent_method(cls, method_name.as_ref());
218
219 let parent = match parent_method {
220 Some(m) => m,
221 None => continue, // not an override
222 };
223
224 let loc = Location {
225 file: class_file.clone(),
226 line: 1,
227 col_start: 0,
228 col_end: 0,
229 };
230
231 // ---- a. Cannot override a final method -------------------------
232 if parent.is_final {
233 issues.push(Issue::new(
234 IssueKind::FinalMethodOverridden {
235 class: fqcn.to_string(),
236 method: method_name.to_string(),
237 parent: parent.fqcn.to_string(),
238 },
239 loc.clone(),
240 ));
241 }
242
243 // ---- b. Visibility must not be reduced -------------------------
244 if visibility_reduced(own_method.visibility, parent.visibility) {
245 issues.push(Issue::new(
246 IssueKind::OverriddenMethodAccess {
247 class: fqcn.to_string(),
248 method: method_name.to_string(),
249 },
250 loc.clone(),
251 ));
252 }
253
254 // ---- c. Return type must be covariant --------------------------
255 // Only check when both sides have an explicit return type.
256 // Skip when:
257 // - Parent type is from a docblock (PHP doesn't enforce docblock override compat)
258 // - Either type contains a named object (needs codebase for inheritance check)
259 // - Either type contains TSelf/TStaticObject (always compatible with self)
260 if let (Some(child_ret), Some(parent_ret)) =
261 (&own_method.return_type, &parent.return_type)
262 {
263 let parent_from_docblock = parent_ret.from_docblock;
264 let involves_named_objects = self.type_has_named_objects(child_ret)
265 || self.type_has_named_objects(parent_ret);
266 let involves_self_static = self.type_has_self_or_static(child_ret)
267 || self.type_has_self_or_static(parent_ret);
268
269 if !parent_from_docblock
270 && !involves_named_objects
271 && !involves_self_static
272 && !child_ret.is_subtype_of_simple(parent_ret)
273 && !parent_ret.is_mixed()
274 && !child_ret.is_mixed()
275 && !self.return_type_has_template(parent_ret)
276 {
277 issues.push(
278 Issue::new(
279 IssueKind::MethodSignatureMismatch {
280 class: fqcn.to_string(),
281 method: method_name.to_string(),
282 detail: format!(
283 "return type '{}' is not a subtype of parent '{}'",
284 child_ret, parent_ret
285 ),
286 },
287 loc.clone(),
288 )
289 .with_snippet(method_name.to_string()),
290 );
291 }
292 }
293
294 // ---- d. Required param count must not increase -----------------
295 let parent_required = parent
296 .params
297 .iter()
298 .filter(|p| !p.is_optional && !p.is_variadic)
299 .count();
300 let child_required = own_method
301 .params
302 .iter()
303 .filter(|p| !p.is_optional && !p.is_variadic)
304 .count();
305
306 if child_required > parent_required {
307 issues.push(
308 Issue::new(
309 IssueKind::MethodSignatureMismatch {
310 class: fqcn.to_string(),
311 method: method_name.to_string(),
312 detail: format!(
313 "overriding method requires {} argument(s) but parent requires {}",
314 child_required, parent_required
315 ),
316 },
317 loc.clone(),
318 )
319 .with_snippet(method_name.to_string()),
320 );
321 }
322
323 // ---- e. Param types must not be narrowed (contravariance) --------
324 // For each positional param present in both parent and child:
325 // parent_param_type must be a subtype of child_param_type.
326 // (Child may widen; it must not narrow.)
327 // Skip when:
328 // - Either side has no type hint
329 // - Either type is mixed
330 // - Either type contains a named object (needs codebase for inheritance check)
331 // - Either type contains TSelf/TStaticObject
332 // - Either type contains a template param
333 let shared_len = parent.params.len().min(own_method.params.len());
334 for i in 0..shared_len {
335 let parent_param = &parent.params[i];
336 let child_param = &own_method.params[i];
337
338 let (parent_ty, child_ty) = match (&parent_param.ty, &child_param.ty) {
339 (Some(p), Some(c)) => (p, c),
340 _ => continue,
341 };
342
343 if parent_ty.is_mixed()
344 || child_ty.is_mixed()
345 || self.type_has_named_objects(parent_ty)
346 || self.type_has_named_objects(child_ty)
347 || self.type_has_self_or_static(parent_ty)
348 || self.type_has_self_or_static(child_ty)
349 || self.return_type_has_template(parent_ty)
350 || self.return_type_has_template(child_ty)
351 {
352 continue;
353 }
354
355 // Contravariance: parent_ty must be subtype of child_ty.
356 // If not, child has narrowed the param type.
357 if !parent_ty.is_subtype_of_simple(child_ty) {
358 issues.push(
359 Issue::new(
360 IssueKind::MethodSignatureMismatch {
361 class: fqcn.to_string(),
362 method: method_name.to_string(),
363 detail: format!(
364 "parameter ${} type '{}' is narrower than parent type '{}'",
365 child_param.name, child_ty, parent_ty
366 ),
367 },
368 loc.clone(),
369 )
370 .with_snippet(method_name.to_string()),
371 );
372 break; // one issue per method is enough
373 }
374 }
375 }
376 }
377
378 // -----------------------------------------------------------------------
379 // Helpers
380 // -----------------------------------------------------------------------
381
382 /// Returns true if the type contains template params or class-strings with unknown types.
383 /// Used to suppress MethodSignatureMismatch on generic parent return types.
384 /// Checks recursively into array key/value types.
385 fn return_type_has_template(&self, ty: &mir_types::Union) -> bool {
386 use mir_types::Atomic;
387 ty.types.iter().any(|atomic| match atomic {
388 Atomic::TTemplateParam { .. } => true,
389 Atomic::TClassString(Some(inner)) => !self.codebase.type_exists(inner.as_ref()),
390 Atomic::TNamedObject { fqcn, type_params } => {
391 // Bare name with no namespace separator is likely a template param
392 (!fqcn.contains('\\') && !self.codebase.type_exists(fqcn.as_ref()))
393 // Also check if any type params are templates
394 || type_params.iter().any(|tp| self.return_type_has_template(tp))
395 }
396 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
397 self.return_type_has_template(key) || self.return_type_has_template(value)
398 }
399 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
400 self.return_type_has_template(value)
401 }
402 _ => false,
403 })
404 }
405
406 /// Returns true if the type contains any named-object atomics (TNamedObject)
407 /// at any level (including inside array key/value types).
408 /// Named-object subtyping requires codebase inheritance lookup, so we skip
409 /// the simple structural check for these.
410 fn type_has_named_objects(&self, ty: &mir_types::Union) -> bool {
411 use mir_types::Atomic;
412 ty.types.iter().any(|a| match a {
413 Atomic::TNamedObject { .. } => true,
414 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
415 self.type_has_named_objects(key) || self.type_has_named_objects(value)
416 }
417 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
418 self.type_has_named_objects(value)
419 }
420 _ => false,
421 })
422 }
423
424 /// Returns true if the type contains TSelf or TStaticObject (late-static types).
425 /// These are always considered compatible with their bound class type.
426 fn type_has_self_or_static(&self, ty: &mir_types::Union) -> bool {
427 use mir_types::Atomic;
428 ty.types
429 .iter()
430 .any(|a| matches!(a, Atomic::TSelf { .. } | Atomic::TStaticObject { .. }))
431 }
432
433 /// Find a method with the given name in the closest ancestor (not the class itself).
434 fn find_parent_method(
435 &self,
436 cls: &mir_codebase::storage::ClassStorage,
437 method_name: &str,
438 ) -> Option<MethodStorage> {
439 // Walk all_parents in order (closest ancestor first)
440 for ancestor_fqcn in &cls.all_parents {
441 if let Some(ancestor_cls) = self.codebase.classes.get(ancestor_fqcn.as_ref()) {
442 if let Some(m) = ancestor_cls.own_methods.get(method_name) {
443 return Some(m.clone());
444 }
445 } else if let Some(iface) = self.codebase.interfaces.get(ancestor_fqcn.as_ref()) {
446 if let Some(m) = iface.own_methods.get(method_name) {
447 return Some(m.clone());
448 }
449 }
450 }
451 None
452 }
453}
454
455// ---------------------------------------------------------------------------
456// Helpers
457// ---------------------------------------------------------------------------
458
459/// Returns true if `child_vis` is strictly less visible than `parent_vis`.
460fn visibility_reduced(child_vis: Visibility, parent_vis: Visibility) -> bool {
461 // Public > Protected > Private (in terms of access)
462 // Reducing means going from more visible to less visible.
463 matches!(
464 (parent_vis, child_vis),
465 (Visibility::Public, Visibility::Protected)
466 | (Visibility::Public, Visibility::Private)
467 | (Visibility::Protected, Visibility::Private)
468 )
469}
470
471/// Create a placeholder location (class-level issues don't have a precise span yet).
472fn dummy_location(fqcn: &Arc<str>) -> Location {
473 Location {
474 file: fqcn.clone(),
475 line: 1,
476 col_start: 0,
477 col_end: 0,
478 }
479}