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 '{}' is not a subtype of parent '{}'",
373                                    child_ret, parent_ret
374                                ),
375                            },
376                            loc.clone(),
377                        )
378                        .with_snippet(method_name.to_string()),
379                    );
380                }
381            }
382
383            // ---- d. Required param count must not increase -----------------
384            let parent_required = parent
385                .params
386                .iter()
387                .filter(|p| !p.is_optional && !p.is_variadic)
388                .count();
389            let child_required = own_method
390                .params
391                .iter()
392                .filter(|p| !p.is_optional && !p.is_variadic)
393                .count();
394
395            if child_required > parent_required {
396                issues.push(
397                    Issue::new(
398                        IssueKind::MethodSignatureMismatch {
399                            class: fqcn.to_string(),
400                            method: method_name.to_string(),
401                            detail: format!(
402                                "overriding method requires {} argument(s) but parent requires {}",
403                                child_required, parent_required
404                            ),
405                        },
406                        loc.clone(),
407                    )
408                    .with_snippet(method_name.to_string()),
409                );
410            }
411
412            // ---- e. Param types must not be narrowed (contravariance) --------
413            // For each positional param present in both parent and child:
414            //   parent_param_type must be a subtype of child_param_type.
415            //   (Child may widen; it must not narrow.)
416            // Skip when:
417            //   - Either side has no type hint
418            //   - Either type is mixed
419            //   - Either type contains a named object (needs codebase for inheritance check)
420            //   - Either type contains TSelf/TStaticObject
421            //   - Either type contains a template param
422            let shared_len = parent.params.len().min(own_method.params.len());
423            for i in 0..shared_len {
424                let parent_param = &parent.params[i];
425                let child_param = &own_method.params[i];
426
427                let (parent_ty, child_ty) = match (&parent_param.ty, &child_param.ty) {
428                    (Some(p), Some(c)) => (p, c),
429                    _ => continue,
430                };
431
432                if parent_ty.is_mixed()
433                    || child_ty.is_mixed()
434                    || self.type_has_named_objects(parent_ty)
435                    || self.type_has_named_objects(child_ty)
436                    || self.type_has_self_or_static(parent_ty)
437                    || self.type_has_self_or_static(child_ty)
438                    || self.return_type_has_template(parent_ty)
439                    || self.return_type_has_template(child_ty)
440                {
441                    continue;
442                }
443
444                // Contravariance: parent_ty must be subtype of child_ty.
445                // If not, child has narrowed the param type.
446                if !parent_ty.is_subtype_of_simple(child_ty) {
447                    issues.push(
448                        Issue::new(
449                            IssueKind::MethodSignatureMismatch {
450                                class: fqcn.to_string(),
451                                method: method_name.to_string(),
452                                detail: format!(
453                                    "parameter ${} type '{}' is narrower than parent type '{}'",
454                                    child_param.name, child_ty, parent_ty
455                                ),
456                            },
457                            loc.clone(),
458                        )
459                        .with_snippet(method_name.to_string()),
460                    );
461                    break; // one issue per method is enough
462                }
463            }
464        }
465    }
466
467    // -----------------------------------------------------------------------
468    // Helpers
469    // -----------------------------------------------------------------------
470
471    /// Returns true if the type contains template params or class-strings with unknown types.
472    /// Used to suppress MethodSignatureMismatch on generic parent return types.
473    /// Checks recursively into array key/value types.
474    fn return_type_has_template(&self, ty: &mir_types::Union) -> bool {
475        use mir_types::Atomic;
476        ty.types.iter().any(|atomic| match atomic {
477            Atomic::TTemplateParam { .. } => true,
478            Atomic::TClassString(Some(inner)) => !self.codebase.type_exists(inner.as_ref()),
479            Atomic::TNamedObject { fqcn, type_params } => {
480                // Bare name with no namespace separator is likely a template param
481                (!fqcn.contains('\\') && !self.codebase.type_exists(fqcn.as_ref()))
482                    // Also check if any type params are templates
483                    || type_params.iter().any(|tp| self.return_type_has_template(tp))
484            }
485            Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
486                self.return_type_has_template(key) || self.return_type_has_template(value)
487            }
488            Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
489                self.return_type_has_template(value)
490            }
491            _ => false,
492        })
493    }
494
495    /// Returns true if the type contains any named-object atomics (TNamedObject)
496    /// at any level (including inside array key/value types).
497    /// Named-object subtyping requires codebase inheritance lookup, so we skip
498    /// the simple structural check for these.
499    fn type_has_named_objects(&self, ty: &mir_types::Union) -> bool {
500        use mir_types::Atomic;
501        ty.types.iter().any(|a| match a {
502            Atomic::TNamedObject { .. } => true,
503            Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
504                self.type_has_named_objects(key) || self.type_has_named_objects(value)
505            }
506            Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
507                self.type_has_named_objects(value)
508            }
509            _ => false,
510        })
511    }
512
513    /// Returns true if the type contains TSelf or TStaticObject (late-static types).
514    /// These are always considered compatible with their bound class type.
515    fn type_has_self_or_static(&self, ty: &mir_types::Union) -> bool {
516        use mir_types::Atomic;
517        ty.types
518            .iter()
519            .any(|a| matches!(a, Atomic::TSelf { .. } | Atomic::TStaticObject { .. }))
520    }
521
522    /// Find a method with the given name in the closest ancestor (not the class itself).
523    fn find_parent_method(
524        &self,
525        cls: &mir_codebase::storage::ClassStorage,
526        method_name: &str,
527    ) -> Option<Arc<MethodStorage>> {
528        // Walk all_parents in order (closest ancestor first)
529        for ancestor_fqcn in &cls.all_parents {
530            if let Some(ancestor_cls) = self.codebase.classes.get(ancestor_fqcn.as_ref()) {
531                if let Some(m) = ancestor_cls.own_methods.get(method_name) {
532                    return Some(Arc::clone(m));
533                }
534            } else if let Some(iface) = self.codebase.interfaces.get(ancestor_fqcn.as_ref()) {
535                if let Some(m) = iface.own_methods.get(method_name) {
536                    return Some(Arc::clone(m));
537                }
538            }
539        }
540        None
541    }
542
543    // -----------------------------------------------------------------------
544    // Check: circular class inheritance (class A extends B extends A)
545    // -----------------------------------------------------------------------
546
547    fn check_circular_class_inheritance(&self, issues: &mut Vec<Issue>) {
548        let mut globally_done: HashSet<String> = HashSet::new();
549
550        let mut class_keys: Vec<Arc<str>> = self
551            .codebase
552            .classes
553            .iter()
554            .map(|e| e.key().clone())
555            .collect();
556        class_keys.sort();
557
558        for start_fqcn in &class_keys {
559            if globally_done.contains(start_fqcn.as_ref()) {
560                continue;
561            }
562
563            // Walk the parent chain, tracking order for cycle reporting.
564            let mut chain: Vec<Arc<str>> = Vec::new();
565            let mut chain_set: HashSet<String> = HashSet::new();
566            let mut current: Arc<str> = start_fqcn.clone();
567
568            loop {
569                if globally_done.contains(current.as_ref()) {
570                    // Known safe — stop here.
571                    for node in &chain {
572                        globally_done.insert(node.to_string());
573                    }
574                    break;
575                }
576                if !chain_set.insert(current.to_string()) {
577                    // current is already in chain → cycle detected.
578                    let cycle_start = chain
579                        .iter()
580                        .position(|p| p.as_ref() == current.as_ref())
581                        .unwrap_or(0);
582                    let cycle_nodes = &chain[cycle_start..];
583
584                    // Report on the lexicographically last class in the cycle
585                    // that belongs to an analyzed file (or any if filter is empty).
586                    let offender = cycle_nodes
587                        .iter()
588                        .filter(|n| self.class_in_analyzed_files(n))
589                        .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
590
591                    if let Some(offender) = offender {
592                        let cls = self.codebase.classes.get(offender.as_ref());
593                        let loc = issue_location(
594                            cls.as_ref().and_then(|c| c.location.as_ref()),
595                            offender,
596                            cls.as_ref()
597                                .and_then(|c| c.location.as_ref())
598                                .and_then(|l| self.sources.get(&l.file).copied()),
599                        );
600                        let mut issue = Issue::new(
601                            IssueKind::CircularInheritance {
602                                class: offender.to_string(),
603                            },
604                            loc,
605                        );
606                        if let Some(snippet) = extract_snippet(
607                            cls.as_ref().and_then(|c| c.location.as_ref()),
608                            &self.sources,
609                        ) {
610                            issue = issue.with_snippet(snippet);
611                        }
612                        issues.push(issue);
613                    }
614
615                    for node in &chain {
616                        globally_done.insert(node.to_string());
617                    }
618                    break;
619                }
620
621                chain.push(current.clone());
622
623                let parent = self
624                    .codebase
625                    .classes
626                    .get(current.as_ref())
627                    .and_then(|c| c.parent.clone());
628
629                match parent {
630                    Some(p) => current = p,
631                    None => {
632                        for node in &chain {
633                            globally_done.insert(node.to_string());
634                        }
635                        break;
636                    }
637                }
638            }
639        }
640    }
641
642    // -----------------------------------------------------------------------
643    // Check: circular interface inheritance (interface I1 extends I2 extends I1)
644    // -----------------------------------------------------------------------
645
646    fn check_circular_interface_inheritance(&self, issues: &mut Vec<Issue>) {
647        let mut globally_done: HashSet<String> = HashSet::new();
648
649        let mut iface_keys: Vec<Arc<str>> = self
650            .codebase
651            .interfaces
652            .iter()
653            .map(|e| e.key().clone())
654            .collect();
655        iface_keys.sort();
656
657        for start_fqcn in &iface_keys {
658            if globally_done.contains(start_fqcn.as_ref()) {
659                continue;
660            }
661            let mut in_stack: Vec<Arc<str>> = Vec::new();
662            let mut stack_set: HashSet<String> = HashSet::new();
663            self.dfs_interface_cycle(
664                start_fqcn.clone(),
665                &mut in_stack,
666                &mut stack_set,
667                &mut globally_done,
668                issues,
669            );
670        }
671    }
672
673    fn dfs_interface_cycle(
674        &self,
675        fqcn: Arc<str>,
676        in_stack: &mut Vec<Arc<str>>,
677        stack_set: &mut HashSet<String>,
678        globally_done: &mut HashSet<String>,
679        issues: &mut Vec<Issue>,
680    ) {
681        if globally_done.contains(fqcn.as_ref()) {
682            return;
683        }
684        if stack_set.contains(fqcn.as_ref()) {
685            // Cycle: find cycle nodes from in_stack.
686            let cycle_start = in_stack
687                .iter()
688                .position(|p| p.as_ref() == fqcn.as_ref())
689                .unwrap_or(0);
690            let cycle_nodes = &in_stack[cycle_start..];
691
692            let offender = cycle_nodes
693                .iter()
694                .filter(|n| self.iface_in_analyzed_files(n))
695                .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
696
697            if let Some(offender) = offender {
698                let iface = self.codebase.interfaces.get(offender.as_ref());
699                let loc = issue_location(
700                    iface.as_ref().and_then(|i| i.location.as_ref()),
701                    offender,
702                    iface
703                        .as_ref()
704                        .and_then(|i| i.location.as_ref())
705                        .and_then(|l| self.sources.get(&l.file).copied()),
706                );
707                let mut issue = Issue::new(
708                    IssueKind::CircularInheritance {
709                        class: offender.to_string(),
710                    },
711                    loc,
712                );
713                if let Some(snippet) = extract_snippet(
714                    iface.as_ref().and_then(|i| i.location.as_ref()),
715                    &self.sources,
716                ) {
717                    issue = issue.with_snippet(snippet);
718                }
719                issues.push(issue);
720            }
721            return;
722        }
723
724        stack_set.insert(fqcn.to_string());
725        in_stack.push(fqcn.clone());
726
727        let extends = self
728            .codebase
729            .interfaces
730            .get(fqcn.as_ref())
731            .map(|i| i.extends.clone())
732            .unwrap_or_default();
733
734        for parent in extends {
735            self.dfs_interface_cycle(parent, in_stack, stack_set, globally_done, issues);
736        }
737
738        in_stack.pop();
739        stack_set.remove(fqcn.as_ref());
740        globally_done.insert(fqcn.to_string());
741    }
742
743    fn class_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
744        if self.analyzed_files.is_empty() {
745            return true;
746        }
747        self.codebase
748            .classes
749            .get(fqcn.as_ref())
750            .map(|c| {
751                c.location
752                    .as_ref()
753                    .map(|loc| self.analyzed_files.contains(&loc.file))
754                    .unwrap_or(false)
755            })
756            .unwrap_or(false)
757    }
758
759    fn iface_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
760        if self.analyzed_files.is_empty() {
761            return true;
762        }
763        self.codebase
764            .interfaces
765            .get(fqcn.as_ref())
766            .map(|i| {
767                i.location
768                    .as_ref()
769                    .map(|loc| self.analyzed_files.contains(&loc.file))
770                    .unwrap_or(false)
771            })
772            .unwrap_or(false)
773    }
774}
775
776/// Returns true if `child_vis` is strictly less visible than `parent_vis`.
777fn visibility_reduced(child_vis: Visibility, parent_vis: Visibility) -> bool {
778    // Public > Protected > Private (in terms of access)
779    // Reducing means going from more visible to less visible.
780    matches!(
781        (parent_vis, child_vis),
782        (Visibility::Public, Visibility::Protected)
783            | (Visibility::Public, Visibility::Private)
784            | (Visibility::Protected, Visibility::Private)
785    )
786}
787
788/// Build an issue location from the stored codebase Location (which carries line/col as
789/// Unicode char-count columns). Falls back to a dummy location using the FQCN as the file
790/// path when no Location is stored.
791fn issue_location(
792    storage_loc: Option<&mir_codebase::storage::Location>,
793    fqcn: &Arc<str>,
794    source: Option<&str>,
795) -> Location {
796    match storage_loc {
797        Some(loc) => {
798            // Calculate col_end from the end byte offset if source is available.
799            let col_end = if let Some(src) = source {
800                if loc.end > loc.start {
801                    let end_offset = (loc.end as usize).min(src.len());
802                    // Find the line start containing the end offset.
803                    let line_start = src[..end_offset].rfind('\n').map(|p| p + 1).unwrap_or(0);
804                    // Count Unicode chars from line start to end offset.
805                    let col_end = src[line_start..end_offset].chars().count() as u16;
806
807                    // Count Unicode chars from line start to start offset.
808                    let col_start_offset = (loc.start as usize).min(src.len());
809                    let col_start_line = src[..col_start_offset]
810                        .rfind('\n')
811                        .map(|p| p + 1)
812                        .unwrap_or(0);
813                    let col_start = src[col_start_line..col_start_offset].chars().count() as u16;
814
815                    col_end.max(col_start + 1)
816                } else {
817                    // Single-char span: end = start + 1.
818                    let col_start_offset = (loc.start as usize).min(src.len());
819                    let col_start_line = src[..col_start_offset]
820                        .rfind('\n')
821                        .map(|p| p + 1)
822                        .unwrap_or(0);
823                    src[col_start_line..col_start_offset].chars().count() as u16 + 1
824                }
825            } else {
826                loc.col + 1
827            };
828
829            // col_start: use loc.col (already a char-count) or recompute from source.
830            let col_start = if let Some(src) = source {
831                let col_start_offset = (loc.start as usize).min(src.len());
832                let col_start_line = src[..col_start_offset]
833                    .rfind('\n')
834                    .map(|p| p + 1)
835                    .unwrap_or(0);
836                src[col_start_line..col_start_offset].chars().count() as u16
837            } else {
838                loc.col
839            };
840
841            Location {
842                file: loc.file.clone(),
843                line: loc.line,
844                col_start,
845                col_end,
846            }
847        }
848        None => Location {
849            file: fqcn.clone(),
850            line: 1,
851            col_start: 0,
852            col_end: 0,
853        },
854    }
855}
856
857/// Extract the first line of source text covered by `storage_loc` as a snippet.
858fn extract_snippet(
859    storage_loc: Option<&mir_codebase::storage::Location>,
860    sources: &HashMap<Arc<str>, &str>,
861) -> Option<String> {
862    let loc = storage_loc?;
863    let src = *sources.get(&loc.file)?;
864    let start = loc.start as usize;
865    let end = loc.end as usize;
866    if start >= src.len() {
867        return None;
868    }
869    let end = end.min(src.len());
870    let span_text = &src[start..end];
871    // Take only the first line to keep the snippet concise.
872    let first_line = span_text.lines().next().unwrap_or(span_text);
873    Some(first_line.trim().to_string())
874}