Skip to main content

mir_analyzer/
class.rs

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