Skip to main content

mir_analyzer/
class.rs

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