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