Skip to main content

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}