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