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::{Location as StorageLocation, Visibility};
14use mir_issues::{Issue, IssueKind, Location};
15
16use crate::db::{class_ancestors, MirDatabase};
17
18// ---------------------------------------------------------------------------
19// ClassAnalyzer
20// ---------------------------------------------------------------------------
21
22pub struct ClassAnalyzer<'a> {
23    db: &'a dyn MirDatabase,
24    /// Only report issues for classes defined in these files (empty = all files).
25    analyzed_files: HashSet<Arc<str>>,
26    /// Source text keyed by file path, used to extract snippets for class-level issues.
27    sources: HashMap<Arc<str>, &'a str>,
28}
29
30impl<'a> ClassAnalyzer<'a> {
31    pub fn new(db: &'a dyn MirDatabase) -> Self {
32        Self {
33            db,
34            analyzed_files: HashSet::new(),
35            sources: HashMap::new(),
36        }
37    }
38
39    pub fn with_files(
40        db: &'a dyn MirDatabase,
41        files: HashSet<Arc<str>>,
42        file_data: &'a [(Arc<str>, Arc<str>)],
43    ) -> Self {
44        let sources: HashMap<Arc<str>, &'a str> = file_data
45            .iter()
46            .map(|(f, s)| (f.clone(), s.as_ref()))
47            .collect();
48        Self {
49            db,
50            analyzed_files: files,
51            sources,
52        }
53    }
54
55    /// Ancestor chain for `fqcn` from the salsa db, or empty if the class
56    /// isn't registered.
57    fn ancestors(&self, fqcn: &str) -> Vec<Arc<str>> {
58        self.db
59            .lookup_class_node(fqcn)
60            .map(|node| class_ancestors(self.db, node).0)
61            .unwrap_or_default()
62    }
63
64    /// Run all class-level checks and return every discovered issue.
65    pub fn analyze_all(&self) -> Vec<Issue> {
66        let mut issues = Vec::new();
67
68        let mut class_keys: Vec<Arc<str>> = self
69            .db
70            .active_class_node_fqcns()
71            .into_iter()
72            .filter(|fqcn| {
73                self.db
74                    .lookup_class_node(fqcn.as_ref())
75                    .map(|n| {
76                        !n.is_interface(self.db) && !n.is_trait(self.db) && !n.is_enum(self.db)
77                    })
78                    .unwrap_or(false)
79            })
80            .collect();
81        // Sort for deterministic issue order across runs.
82        class_keys.sort();
83
84        for fqcn in &class_keys {
85            let node = match self
86                .db
87                .lookup_class_node(fqcn.as_ref())
88                .filter(|n| n.active(self.db))
89            {
90                Some(n) => n,
91                None => continue,
92            };
93            let location = node.location(self.db);
94
95            // Skip classes from vendor / stub files — only check user-analyzed files
96            if !self.analyzed_files.is_empty() {
97                let in_analyzed = location
98                    .as_ref()
99                    .map(|loc| self.analyzed_files.contains(&loc.file))
100                    .unwrap_or(false);
101                if !in_analyzed {
102                    continue;
103                }
104            }
105
106            // ---- 1. Final-class extension check / deprecated parent check ------
107            if let Some(parent_fqcn) = node.parent(self.db) {
108                if let Some(parent) = self
109                    .db
110                    .lookup_class_node(parent_fqcn.as_ref())
111                    .filter(|n| n.active(self.db))
112                {
113                    if parent.is_final(self.db) {
114                        let loc = issue_location(
115                            location.as_ref(),
116                            fqcn,
117                            location
118                                .as_ref()
119                                .and_then(|l| self.sources.get(&l.file).copied()),
120                        );
121                        let mut issue = Issue::new(
122                            IssueKind::FinalClassExtended {
123                                parent: parent_fqcn.to_string(),
124                                child: fqcn.to_string(),
125                            },
126                            loc,
127                        );
128                        if let Some(snippet) = extract_snippet(location.as_ref(), &self.sources) {
129                            issue = issue.with_snippet(snippet);
130                        }
131                        issues.push(issue);
132                    }
133                    if let Some(msg) = parent.deprecated(self.db) {
134                        let loc = issue_location(
135                            location.as_ref(),
136                            fqcn,
137                            location
138                                .as_ref()
139                                .and_then(|l| self.sources.get(&l.file).copied()),
140                        );
141                        let mut issue = Issue::new(
142                            IssueKind::DeprecatedClass {
143                                name: parent_fqcn.to_string(),
144                                message: Some(msg).filter(|m| !m.is_empty()),
145                            },
146                            loc,
147                        );
148                        if let Some(snippet) = extract_snippet(location.as_ref(), &self.sources) {
149                            issue = issue.with_snippet(snippet);
150                        }
151                        issues.push(issue);
152                    }
153                }
154            }
155
156            // Skip abstract classes for "must implement" checks
157            if node.is_abstract(self.db) {
158                // Still check override compatibility for abstract classes
159                self.check_overrides(fqcn, location.as_ref(), &mut issues);
160                continue;
161            }
162
163            // ---- 2. Abstract parent methods must be implemented ----------------
164            self.check_abstract_methods_implemented(fqcn, location.as_ref(), &mut issues);
165
166            // ---- 3. Interface methods must be implemented ----------------------
167            self.check_interface_methods_implemented(fqcn, location.as_ref(), &mut issues);
168
169            // ---- 4. Method override compatibility ------------------------------
170            self.check_overrides(fqcn, location.as_ref(), &mut issues);
171        }
172
173        // ---- 5. Circular inheritance detection --------------------------------
174        self.check_circular_class_inheritance(&mut issues);
175        self.check_circular_interface_inheritance(&mut issues);
176
177        issues
178    }
179
180    // -----------------------------------------------------------------------
181    // Check: all abstract methods from ancestor chain are implemented
182    // -----------------------------------------------------------------------
183
184    fn check_abstract_methods_implemented(
185        &self,
186        fqcn: &Arc<str>,
187        cls_location: Option<&StorageLocation>,
188        issues: &mut Vec<Issue>,
189    ) {
190        // Walk every ancestor class and collect abstract methods
191        let ancestors = self.ancestors(fqcn);
192        for ancestor_fqcn in &ancestors {
193            // Read abstract method names from the salsa db.  PR52 wired
194            // pruning into `ingest_codebase`, so `method_nodes` no longer
195            // accumulates stale stub entries when a user file shadows a
196            // bundled-stub class with a different method set.
197            let abstract_methods: Vec<Arc<str>> = self
198                .db
199                .class_own_methods(ancestor_fqcn.as_ref())
200                .into_iter()
201                .filter(|m| m.active(self.db) && m.is_abstract(self.db))
202                .map(|m| m.name(self.db))
203                .collect();
204
205            for method_name in abstract_methods {
206                // Check if the concrete class (or any closer ancestor) provides it
207                if crate::db::method_is_concretely_implemented(
208                    self.db,
209                    fqcn.as_ref(),
210                    method_name.as_ref(),
211                ) {
212                    continue; // implemented
213                }
214
215                let loc = issue_location(
216                    cls_location,
217                    fqcn,
218                    cls_location.and_then(|l| self.sources.get(&l.file).copied()),
219                );
220                let mut issue = Issue::new(
221                    IssueKind::UnimplementedAbstractMethod {
222                        class: fqcn.to_string(),
223                        method: method_name.to_string(),
224                    },
225                    loc,
226                );
227                if let Some(snippet) = extract_snippet(cls_location, &self.sources) {
228                    issue = issue.with_snippet(snippet);
229                }
230                issues.push(issue);
231            }
232        }
233    }
234
235    // -----------------------------------------------------------------------
236    // Check: all interface methods are implemented
237    // -----------------------------------------------------------------------
238
239    fn check_interface_methods_implemented(
240        &self,
241        fqcn: &Arc<str>,
242        cls_location: Option<&StorageLocation>,
243        issues: &mut Vec<Issue>,
244    ) {
245        // Collect all interfaces (direct + from ancestors)
246        let all_ifaces: Vec<Arc<str>> = self
247            .ancestors(fqcn)
248            .into_iter()
249            .filter(|p| {
250                crate::db::class_kind_via_db(self.db, p.as_ref()).is_some_and(|k| k.is_interface)
251            })
252            .collect();
253
254        for iface_fqcn in &all_ifaces {
255            // Read method names from the salsa db.  PR52 wired pruning into
256            // `ingest_codebase`, so `method_nodes` no longer accumulates stale
257            // stub entries when a user file shadows a bundled-stub interface.
258            let method_nodes = self.db.class_own_methods(iface_fqcn.as_ref());
259            if method_nodes.is_empty() {
260                // Skip interfaces with no registered methods (unregistered or
261                // empty marker interfaces).
262                continue;
263            }
264            let method_names: Vec<Arc<str>> = method_nodes
265                .into_iter()
266                .filter(|m| m.active(self.db))
267                .map(|m| m.name(self.db))
268                .collect();
269
270            for method_name in method_names {
271                // PHP method names are case-insensitive; normalize before lookup so that
272                // a hand-written stub key like "jsonSerialize" matches the collector's
273                // lowercased key "jsonserialize" stored in own_methods.
274                let method_name_lower = method_name.to_lowercase();
275                // Check if the class provides a concrete implementation
276                let implemented = crate::db::method_is_concretely_implemented(
277                    self.db,
278                    fqcn.as_ref(),
279                    &method_name_lower,
280                );
281
282                if !implemented {
283                    let loc = issue_location(
284                        cls_location,
285                        fqcn,
286                        cls_location.and_then(|l| self.sources.get(&l.file).copied()),
287                    );
288                    let mut issue = Issue::new(
289                        IssueKind::UnimplementedInterfaceMethod {
290                            class: fqcn.to_string(),
291                            interface: iface_fqcn.to_string(),
292                            method: method_name.to_string(),
293                        },
294                        loc,
295                    );
296                    if let Some(snippet) = extract_snippet(cls_location, &self.sources) {
297                        issue = issue.with_snippet(snippet);
298                    }
299                    issues.push(issue);
300                }
301            }
302        }
303    }
304
305    // -----------------------------------------------------------------------
306    // Check: override compatibility
307    // -----------------------------------------------------------------------
308
309    fn check_overrides(
310        &self,
311        fqcn: &Arc<str>,
312        _cls_location: Option<&StorageLocation>,
313        issues: &mut Vec<Issue>,
314    ) {
315        let own_methods = self.db.class_own_methods(fqcn.as_ref());
316        for own in own_methods {
317            if !own.active(self.db) {
318                continue;
319            }
320            let method_name: Arc<str> = own.name(self.db);
321
322            // PHP does not enforce constructor signature compatibility
323            if method_name.as_ref() == "__construct" {
324                continue;
325            }
326
327            // Find parent definition (if any) — search ancestor chain
328            let method_name_lower: Arc<str> = if method_name.chars().all(|c| !c.is_uppercase()) {
329                method_name.clone()
330            } else {
331                Arc::from(method_name.to_lowercase().as_str())
332            };
333            let parent_method = self.find_parent_method(fqcn, method_name_lower.as_ref());
334
335            let parent = match parent_method {
336                Some(m) => m,
337                None => continue, // not an override
338            };
339
340            let own_location = own.location(self.db);
341            let loc = issue_location(
342                own_location.as_ref(),
343                fqcn,
344                own_location
345                    .as_ref()
346                    .and_then(|l| self.sources.get(&l.file).copied()),
347            );
348
349            // ---- a. Cannot override a final method -------------------------
350            if parent.is_final(self.db) {
351                let mut issue = Issue::new(
352                    IssueKind::FinalMethodOverridden {
353                        class: fqcn.to_string(),
354                        method: method_name_lower.to_string(),
355                        parent: parent.fqcn(self.db).to_string(),
356                    },
357                    loc.clone(),
358                );
359                if let Some(snippet) = extract_snippet(own_location.as_ref(), &self.sources) {
360                    issue = issue.with_snippet(snippet);
361                }
362                issues.push(issue);
363            }
364
365            // ---- b. Visibility must not be reduced -------------------------
366            if visibility_reduced(own.visibility(self.db), parent.visibility(self.db)) {
367                let mut issue = Issue::new(
368                    IssueKind::OverriddenMethodAccess {
369                        class: fqcn.to_string(),
370                        method: method_name_lower.to_string(),
371                    },
372                    loc.clone(),
373                );
374                if let Some(snippet) = extract_snippet(own_location.as_ref(), &self.sources) {
375                    issue = issue.with_snippet(snippet);
376                }
377                issues.push(issue);
378            }
379
380            // ---- c. Return type must be covariant --------------------------
381            // Only check when both sides have an explicit return type.
382            // Skip when:
383            //   - Parent type is from a docblock (PHP doesn't enforce docblock override compat)
384            //   - Either type is mixed
385            //   - Parent type contains a template param
386            let parent_return_type = parent.return_type(self.db);
387            let own_return_type = own.return_type(self.db);
388            if let (Some(child_ret), Some(parent_ret)) =
389                (own_return_type.as_ref(), parent_return_type.as_ref())
390            {
391                let parent_from_docblock = parent_ret.from_docblock;
392                let involves_named_objects = Self::type_has_named_objects(child_ret)
393                    || Self::type_has_named_objects(parent_ret);
394                let involves_self_static = self.type_has_self_or_static(child_ret)
395                    || self.type_has_self_or_static(parent_ret);
396
397                if !parent_from_docblock
398                    && !parent_ret.is_mixed()
399                    && !child_ret.is_mixed()
400                    && !self.return_type_has_template(parent_ret)
401                {
402                    let child_file = own_location.as_ref().map(|l| l.file.as_ref()).unwrap_or("");
403
404                    let compatible = if (involves_named_objects || involves_self_static)
405                        && self.type_has_only_object_atoms(child_ret)
406                        && self.type_has_only_object_atoms(parent_ret)
407                    {
408                        crate::stmt::named_object_return_compatible(
409                            child_ret, parent_ret, self.db, child_file,
410                        )
411                    } else if involves_named_objects || involves_self_static {
412                        true // mixed scalar+object union — skip (G5 gap)
413                    } else {
414                        child_ret.is_subtype_of_simple(parent_ret)
415                    };
416
417                    if !compatible {
418                        issues.push(
419                            Issue::new(
420                                IssueKind::MethodSignatureMismatch {
421                                    class: fqcn.to_string(),
422                                    method: method_name_lower.to_string(),
423                                    detail: format!(
424                                        "return type '{child_ret}' is not a subtype of parent '{parent_ret}'"
425                                    ),
426                                },
427                                loc.clone(),
428                            )
429                            .with_snippet(method_name_lower.to_string()),
430                        );
431                    }
432                }
433            }
434
435            // ---- d. Required param count must not increase -----------------
436            let parent_params = parent.params(self.db);
437            let own_params = own.params(self.db);
438            let parent_required = parent_params
439                .iter()
440                .filter(|p| !p.is_optional && !p.is_variadic)
441                .count();
442            let child_required = own_params
443                .iter()
444                .filter(|p| !p.is_optional && !p.is_variadic)
445                .count();
446
447            if child_required > parent_required {
448                issues.push(
449                    Issue::new(
450                        IssueKind::MethodSignatureMismatch {
451                            class: fqcn.to_string(),
452                            method: method_name_lower.to_string(),
453                            detail: format!(
454                                "overriding method requires {child_required} argument(s) but parent requires {parent_required}"
455                            ),
456                        },
457                        loc.clone(),
458                    )
459                    .with_snippet(method_name_lower.to_string()),
460                );
461            }
462
463            // ---- e. Param types must not be narrowed (contravariance) --------
464            // For each positional param present in both parent and child:
465            //   parent_param_type must be a subtype of child_param_type.
466            //   (Child may widen; it must not narrow.)
467            // Skip when:
468            //   - Either side has no type hint
469            //   - Either type is mixed
470            //   - Either type contains a named object (needs codebase for inheritance check)
471            //   - Either type contains TSelf/TStaticObject
472            //   - Either type contains a template param
473            let shared_len = parent_params.len().min(own_params.len());
474            for i in 0..shared_len {
475                let parent_param = &parent_params[i];
476                let child_param = &own_params[i];
477
478                let (parent_ty, child_ty) = match (&parent_param.ty, &child_param.ty) {
479                    (Some(p), Some(c)) => (p, c),
480                    _ => continue,
481                };
482
483                if parent_ty.is_mixed()
484                    || child_ty.is_mixed()
485                    || Self::type_has_named_objects(parent_ty)
486                    || Self::type_has_named_objects(child_ty)
487                    || self.type_has_self_or_static(parent_ty)
488                    || self.type_has_self_or_static(child_ty)
489                    || self.return_type_has_template(parent_ty)
490                    || self.return_type_has_template(child_ty)
491                {
492                    continue;
493                }
494
495                // Contravariance: parent_ty must be subtype of child_ty.
496                // If not, child has narrowed the param type.
497                if !parent_ty.is_subtype_of_simple(child_ty) {
498                    issues.push(
499                        Issue::new(
500                            IssueKind::MethodSignatureMismatch {
501                                class: fqcn.to_string(),
502                                method: method_name_lower.to_string(),
503                                detail: format!(
504                                    "parameter ${} type '{}' is narrower than parent type '{}'",
505                                    child_param.name, child_ty, parent_ty
506                                ),
507                            },
508                            loc.clone(),
509                        )
510                        .with_snippet(method_name_lower.to_string()),
511                    );
512                    break; // one issue per method is enough
513                }
514            }
515        }
516    }
517
518    // -----------------------------------------------------------------------
519    // Helpers
520    // -----------------------------------------------------------------------
521
522    /// Returns true if the type contains template params or class-strings with unknown types.
523    /// Used to suppress MethodSignatureMismatch on generic parent return types.
524    /// Checks recursively into array key/value types.
525    fn return_type_has_template(&self, ty: &mir_types::Union) -> bool {
526        use mir_types::Atomic;
527        ty.types.iter().any(|atomic| match atomic {
528            Atomic::TTemplateParam { .. } => true,
529            Atomic::TClassString(Some(inner)) => {
530                !crate::db::type_exists_via_db(self.db, inner.as_ref())
531            }
532            Atomic::TNamedObject { fqcn, type_params } => {
533                // Bare name with no namespace separator is likely a template param
534                (!fqcn.contains('\\') && !crate::db::type_exists_via_db(self.db, fqcn.as_ref()))
535                    // Also check if any type params are templates
536                    || type_params.iter().any(|tp| self.return_type_has_template(tp))
537            }
538            Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
539                self.return_type_has_template(key) || self.return_type_has_template(value)
540            }
541            Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
542                self.return_type_has_template(value)
543            }
544            _ => false,
545        })
546    }
547
548    /// Returns true if the type contains any named-object atomics (TNamedObject)
549    /// at any level (including inside array key/value types).
550    /// Named-object subtyping requires codebase inheritance lookup, so we skip
551    /// the simple structural check for these.
552    fn type_has_named_objects(ty: &mir_types::Union) -> bool {
553        use mir_types::Atomic;
554        ty.types.iter().any(|a| match a {
555            Atomic::TNamedObject { .. } => true,
556            Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
557                Self::type_has_named_objects(key) || Self::type_has_named_objects(value)
558            }
559            Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
560                Self::type_has_named_objects(value)
561            }
562            _ => false,
563        })
564    }
565
566    /// Returns true if the type contains TSelf or TStaticObject (late-static types).
567    /// These are always considered compatible with their bound class type.
568    fn type_has_self_or_static(&self, ty: &mir_types::Union) -> bool {
569        use mir_types::Atomic;
570        ty.types
571            .iter()
572            .any(|a| matches!(a, Atomic::TSelf { .. } | Atomic::TStaticObject { .. }))
573    }
574
575    /// Returns true if every atom in the union is handled by `named_object_return_compatible`:
576    /// object types (named/self/static/parent), null, void, never, and class-string variants.
577    /// Unions that also contain scalar atoms (int, string, …) are not fully handled there
578    /// and must fall back to the skip path (G5 gap).
579    fn type_has_only_object_atoms(&self, ty: &mir_types::Union) -> bool {
580        use mir_types::Atomic;
581        ty.types.iter().all(|a| {
582            matches!(
583                a,
584                Atomic::TNamedObject { .. }
585                    | Atomic::TSelf { .. }
586                    | Atomic::TStaticObject { .. }
587                    | Atomic::TParent { .. }
588                    | Atomic::TNull
589                    | Atomic::TVoid
590                    | Atomic::TNever
591                    | Atomic::TClassString(_)
592            )
593        })
594    }
595
596    /// Find a method with the given name in the closest ancestor (not the class itself).
597    /// Returns the parent's `MethodNode` (db-tracked).
598    fn find_parent_method(
599        &self,
600        fqcn: &Arc<str>,
601        method_name_lower: &str,
602    ) -> Option<crate::db::MethodNode> {
603        let ancestors = self.ancestors(fqcn);
604        for ancestor_fqcn in &ancestors {
605            if let Some(node) = self
606                .db
607                .lookup_method_node(ancestor_fqcn.as_ref(), method_name_lower)
608                .filter(|n| n.active(self.db))
609            {
610                return Some(node);
611            }
612        }
613        None
614    }
615
616    // -----------------------------------------------------------------------
617    // Check: circular class inheritance (class A extends B extends A)
618    // -----------------------------------------------------------------------
619
620    fn check_circular_class_inheritance(&self, issues: &mut Vec<Issue>) {
621        let mut globally_done: HashSet<String> = HashSet::new();
622
623        let mut class_keys: Vec<Arc<str>> = self
624            .db
625            .active_class_node_fqcns()
626            .into_iter()
627            .filter(|fqcn| {
628                self.db
629                    .lookup_class_node(fqcn.as_ref())
630                    .map(|n| {
631                        !n.is_interface(self.db) && !n.is_trait(self.db) && !n.is_enum(self.db)
632                    })
633                    .unwrap_or(false)
634            })
635            .collect();
636        class_keys.sort();
637
638        for start_fqcn in &class_keys {
639            if globally_done.contains(start_fqcn.as_ref()) {
640                continue;
641            }
642
643            // Walk the parent chain, tracking order for cycle reporting.
644            let mut chain: Vec<Arc<str>> = Vec::new();
645            let mut chain_set: HashSet<String> = HashSet::new();
646            let mut current: Arc<str> = start_fqcn.clone();
647
648            loop {
649                if globally_done.contains(current.as_ref()) {
650                    // Known safe — stop here.
651                    for node in &chain {
652                        globally_done.insert(node.to_string());
653                    }
654                    break;
655                }
656                if !chain_set.insert(current.to_string()) {
657                    // current is already in chain → cycle detected.
658                    let cycle_start = chain
659                        .iter()
660                        .position(|p| p.as_ref() == current.as_ref())
661                        .unwrap_or(0);
662                    let cycle_nodes = &chain[cycle_start..];
663
664                    // Report on the lexicographically last class in the cycle
665                    // that belongs to an analyzed file (or any if filter is empty).
666                    let offender = cycle_nodes
667                        .iter()
668                        .filter(|n| self.class_in_analyzed_files(n))
669                        .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
670
671                    if let Some(offender) = offender {
672                        let location = self
673                            .db
674                            .lookup_class_node(offender.as_ref())
675                            .filter(|n| n.active(self.db))
676                            .and_then(|n| n.location(self.db));
677                        let loc = issue_location(
678                            location.as_ref(),
679                            offender,
680                            location
681                                .as_ref()
682                                .and_then(|l| self.sources.get(&l.file).copied()),
683                        );
684                        let mut issue = Issue::new(
685                            IssueKind::CircularInheritance {
686                                class: offender.to_string(),
687                            },
688                            loc,
689                        );
690                        if let Some(snippet) = extract_snippet(location.as_ref(), &self.sources) {
691                            issue = issue.with_snippet(snippet);
692                        }
693                        issues.push(issue);
694                    }
695
696                    for node in &chain {
697                        globally_done.insert(node.to_string());
698                    }
699                    break;
700                }
701
702                chain.push(current.clone());
703
704                let parent = self
705                    .db
706                    .lookup_class_node(current.as_ref())
707                    .filter(|n| n.active(self.db))
708                    .and_then(|n| n.parent(self.db));
709
710                match parent {
711                    Some(p) => current = p,
712                    None => {
713                        for node in &chain {
714                            globally_done.insert(node.to_string());
715                        }
716                        break;
717                    }
718                }
719            }
720        }
721    }
722
723    // -----------------------------------------------------------------------
724    // Check: circular interface inheritance (interface I1 extends I2 extends I1)
725    // -----------------------------------------------------------------------
726
727    fn check_circular_interface_inheritance(&self, issues: &mut Vec<Issue>) {
728        let mut globally_done: HashSet<String> = HashSet::new();
729
730        let mut iface_keys: Vec<Arc<str>> = self
731            .db
732            .active_class_node_fqcns()
733            .into_iter()
734            .filter(|fqcn| {
735                self.db
736                    .lookup_class_node(fqcn.as_ref())
737                    .map(|n| n.is_interface(self.db))
738                    .unwrap_or(false)
739            })
740            .collect();
741        iface_keys.sort();
742
743        for start_fqcn in &iface_keys {
744            if globally_done.contains(start_fqcn.as_ref()) {
745                continue;
746            }
747            let mut in_stack: Vec<Arc<str>> = Vec::new();
748            let mut stack_set: HashSet<String> = HashSet::new();
749            self.dfs_interface_cycle(
750                start_fqcn.clone(),
751                &mut in_stack,
752                &mut stack_set,
753                &mut globally_done,
754                issues,
755            );
756        }
757    }
758
759    fn dfs_interface_cycle(
760        &self,
761        fqcn: Arc<str>,
762        in_stack: &mut Vec<Arc<str>>,
763        stack_set: &mut HashSet<String>,
764        globally_done: &mut HashSet<String>,
765        issues: &mut Vec<Issue>,
766    ) {
767        if globally_done.contains(fqcn.as_ref()) {
768            return;
769        }
770        if stack_set.contains(fqcn.as_ref()) {
771            // Cycle: find cycle nodes from in_stack.
772            let cycle_start = in_stack
773                .iter()
774                .position(|p| p.as_ref() == fqcn.as_ref())
775                .unwrap_or(0);
776            let cycle_nodes = &in_stack[cycle_start..];
777
778            let offender = cycle_nodes
779                .iter()
780                .filter(|n| self.iface_in_analyzed_files(n))
781                .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
782
783            if let Some(offender) = offender {
784                let location = self
785                    .db
786                    .lookup_class_node(offender.as_ref())
787                    .filter(|n| n.active(self.db))
788                    .and_then(|n| n.location(self.db));
789                let loc = issue_location(
790                    location.as_ref(),
791                    offender,
792                    location
793                        .as_ref()
794                        .and_then(|l| self.sources.get(&l.file).copied()),
795                );
796                let mut issue = Issue::new(
797                    IssueKind::CircularInheritance {
798                        class: offender.to_string(),
799                    },
800                    loc,
801                );
802                if let Some(snippet) = extract_snippet(location.as_ref(), &self.sources) {
803                    issue = issue.with_snippet(snippet);
804                }
805                issues.push(issue);
806            }
807            return;
808        }
809
810        stack_set.insert(fqcn.to_string());
811        in_stack.push(fqcn.clone());
812
813        let extends: Vec<Arc<str>> = self
814            .db
815            .lookup_class_node(fqcn.as_ref())
816            .filter(|n| n.active(self.db))
817            .map(|n| n.extends(self.db).to_vec())
818            .unwrap_or_default();
819
820        for parent in extends {
821            self.dfs_interface_cycle(parent, in_stack, stack_set, globally_done, issues);
822        }
823
824        in_stack.pop();
825        stack_set.remove(fqcn.as_ref());
826        globally_done.insert(fqcn.to_string());
827    }
828
829    fn class_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
830        if self.analyzed_files.is_empty() {
831            return true;
832        }
833        self.db
834            .lookup_class_node(fqcn.as_ref())
835            .filter(|n| n.active(self.db))
836            .and_then(|n| n.location(self.db))
837            .map(|loc| self.analyzed_files.contains(&loc.file))
838            .unwrap_or(false)
839    }
840
841    fn iface_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
842        // Same lookup path as `class_in_analyzed_files` — interface and class
843        // nodes share `ClassNode` storage, distinguished by `is_interface`.
844        self.class_in_analyzed_files(fqcn)
845    }
846}
847
848/// Returns true if `child_vis` is strictly less visible than `parent_vis`.
849fn visibility_reduced(child_vis: Visibility, parent_vis: Visibility) -> bool {
850    // Public > Protected > Private (in terms of access)
851    // Reducing means going from more visible to less visible.
852    matches!(
853        (parent_vis, child_vis),
854        (Visibility::Public, Visibility::Protected)
855            | (Visibility::Public, Visibility::Private)
856            | (Visibility::Protected, Visibility::Private)
857    )
858}
859
860/// Build an issue location from the stored codebase Location.
861/// Falls back to a dummy location using the FQCN as the file path when no
862/// Location is stored.
863fn issue_location(
864    storage_loc: Option<&mir_codebase::storage::Location>,
865    fqcn: &Arc<str>,
866    _source: Option<&str>,
867) -> Location {
868    match storage_loc {
869        Some(loc) => Location {
870            file: loc.file.clone(),
871            line: loc.line,
872            line_end: loc.line_end,
873            col_start: loc.col_start,
874            col_end: loc.col_end,
875        },
876        None => Location {
877            file: fqcn.clone(),
878            line: 1,
879            line_end: 1,
880            col_start: 0,
881            col_end: 0,
882        },
883    }
884}
885
886/// Extract the first line of source text covered by `storage_loc` as a snippet.
887fn extract_snippet(
888    storage_loc: Option<&mir_codebase::storage::Location>,
889    sources: &HashMap<Arc<str>, &str>,
890) -> Option<String> {
891    let loc = storage_loc?;
892    let src = *sources.get(&loc.file)?;
893    // Walk to the 1-based start line (loc.line is already 1-based).
894    let line_idx = loc.line.saturating_sub(1) as usize;
895    let line_text = src.lines().nth(line_idx)?;
896    Some(line_text.trim().to_string())
897}