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