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