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