1use 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
17pub struct ClassAnalyzer<'a> {
22 codebase: &'a Codebase,
23 analyzed_files: HashSet<Arc<str>>,
25 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 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 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 if let Some(parent_fqcn) = &cls.parent {
85 if let Some(parent) = self.codebase.classes.get(parent_fqcn.as_ref()) {
86 if parent.is_final {
87 let loc = issue_location(
88 cls.location.as_ref(),
89 fqcn,
90 cls.location
91 .as_ref()
92 .and_then(|l| self.sources.get(&l.file).copied()),
93 );
94 let mut issue = Issue::new(
95 IssueKind::FinalClassExtended {
96 parent: parent_fqcn.to_string(),
97 child: fqcn.to_string(),
98 },
99 loc,
100 );
101 if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources)
102 {
103 issue = issue.with_snippet(snippet);
104 }
105 issues.push(issue);
106 }
107 if let Some(msg) = parent.deprecated.clone() {
108 let loc = issue_location(
109 cls.location.as_ref(),
110 fqcn,
111 cls.location
112 .as_ref()
113 .and_then(|l| self.sources.get(&l.file).copied()),
114 );
115 let mut issue = Issue::new(
116 IssueKind::DeprecatedClass {
117 name: parent_fqcn.to_string(),
118 message: Some(msg).filter(|m| !m.is_empty()),
119 },
120 loc,
121 );
122 if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources)
123 {
124 issue = issue.with_snippet(snippet);
125 }
126 issues.push(issue);
127 }
128 }
129 }
130
131 if cls.is_abstract {
133 self.check_overrides(&cls, &mut issues);
135 continue;
136 }
137
138 self.check_abstract_methods_implemented(&cls, &mut issues);
140
141 self.check_interface_methods_implemented(&cls, &mut issues);
143
144 self.check_overrides(&cls, &mut issues);
146 }
147
148 self.check_circular_class_inheritance(&mut issues);
150 self.check_circular_interface_inheritance(&mut issues);
151
152 issues
153 }
154
155 fn check_abstract_methods_implemented(
160 &self,
161 cls: &mir_codebase::storage::ClassStorage,
162 issues: &mut Vec<Issue>,
163 ) {
164 let fqcn = &cls.fqcn;
165
166 for ancestor_fqcn in &cls.all_parents {
168 let abstract_methods: Vec<Arc<str>> = {
171 let Some(ancestor) = self.codebase.classes.get(ancestor_fqcn.as_ref()) else {
172 continue;
173 };
174 ancestor
175 .own_methods
176 .iter()
177 .filter(|(_, m)| m.is_abstract)
178 .map(|(_, m)| m.name.clone())
179 .collect()
180 };
181
182 for method_name in abstract_methods {
183 if self
185 .codebase
186 .get_method(fqcn.as_ref(), method_name.as_ref())
187 .map(|m| !m.is_abstract)
188 .unwrap_or(false)
189 {
190 continue; }
192
193 let loc = issue_location(
194 cls.location.as_ref(),
195 fqcn,
196 cls.location
197 .as_ref()
198 .and_then(|l| self.sources.get(&l.file).copied()),
199 );
200 let mut issue = Issue::new(
201 IssueKind::UnimplementedAbstractMethod {
202 class: fqcn.to_string(),
203 method: method_name.to_string(),
204 },
205 loc,
206 );
207 if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources) {
208 issue = issue.with_snippet(snippet);
209 }
210 issues.push(issue);
211 }
212 }
213 }
214
215 fn check_interface_methods_implemented(
220 &self,
221 cls: &mir_codebase::storage::ClassStorage,
222 issues: &mut Vec<Issue>,
223 ) {
224 let fqcn = &cls.fqcn;
225
226 let all_ifaces: Vec<Arc<str>> = cls
228 .all_parents
229 .iter()
230 .filter(|p| self.codebase.interfaces.contains_key(p.as_ref()))
231 .cloned()
232 .collect();
233
234 for iface_fqcn in &all_ifaces {
235 let method_names: Vec<Arc<str>> =
238 match self.codebase.interfaces.get(iface_fqcn.as_ref()) {
239 Some(iface) => iface.own_methods.values().map(|m| m.name.clone()).collect(),
240 None => continue,
241 };
242
243 for method_name in method_names {
244 let method_name_lower = method_name.to_lowercase();
248 let implemented = self
250 .codebase
251 .get_method(fqcn.as_ref(), &method_name_lower)
252 .map(|m| !m.is_abstract)
253 .unwrap_or(false);
254
255 if !implemented {
256 let loc = issue_location(
257 cls.location.as_ref(),
258 fqcn,
259 cls.location
260 .as_ref()
261 .and_then(|l| self.sources.get(&l.file).copied()),
262 );
263 let mut issue = Issue::new(
264 IssueKind::UnimplementedInterfaceMethod {
265 class: fqcn.to_string(),
266 interface: iface_fqcn.to_string(),
267 method: method_name.to_string(),
268 },
269 loc,
270 );
271 if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources) {
272 issue = issue.with_snippet(snippet);
273 }
274 issues.push(issue);
275 }
276 }
277 }
278 }
279
280 fn check_overrides(&self, cls: &mir_codebase::storage::ClassStorage, issues: &mut Vec<Issue>) {
285 let fqcn = &cls.fqcn;
286
287 for (method_name, own_method) in &cls.own_methods {
288 if method_name.as_ref() == "__construct" {
290 continue;
291 }
292
293 let parent_method = self.find_parent_method(cls, method_name.as_ref());
295
296 let parent = match parent_method {
297 Some(m) => m,
298 None => continue, };
300
301 let loc = issue_location(
302 own_method.location.as_ref(),
303 fqcn,
304 own_method
305 .location
306 .as_ref()
307 .and_then(|l| self.sources.get(&l.file).copied()),
308 );
309
310 if parent.is_final {
312 let mut issue = Issue::new(
313 IssueKind::FinalMethodOverridden {
314 class: fqcn.to_string(),
315 method: method_name.to_string(),
316 parent: parent.fqcn.to_string(),
317 },
318 loc.clone(),
319 );
320 if let Some(snippet) = extract_snippet(own_method.location.as_ref(), &self.sources)
321 {
322 issue = issue.with_snippet(snippet);
323 }
324 issues.push(issue);
325 }
326
327 if visibility_reduced(own_method.visibility, parent.visibility) {
329 let mut issue = Issue::new(
330 IssueKind::OverriddenMethodAccess {
331 class: fqcn.to_string(),
332 method: method_name.to_string(),
333 },
334 loc.clone(),
335 );
336 if let Some(snippet) = extract_snippet(own_method.location.as_ref(), &self.sources)
337 {
338 issue = issue.with_snippet(snippet);
339 }
340 issues.push(issue);
341 }
342
343 if let (Some(child_ret), Some(parent_ret)) =
350 (&own_method.return_type, &parent.return_type)
351 {
352 let parent_from_docblock = parent_ret.from_docblock;
353 let involves_named_objects = self.type_has_named_objects(child_ret)
354 || self.type_has_named_objects(parent_ret);
355 let involves_self_static = self.type_has_self_or_static(child_ret)
356 || self.type_has_self_or_static(parent_ret);
357
358 if !parent_from_docblock
359 && !involves_named_objects
360 && !involves_self_static
361 && !child_ret.is_subtype_of_simple(parent_ret)
362 && !parent_ret.is_mixed()
363 && !child_ret.is_mixed()
364 && !self.return_type_has_template(parent_ret)
365 {
366 issues.push(
367 Issue::new(
368 IssueKind::MethodSignatureMismatch {
369 class: fqcn.to_string(),
370 method: method_name.to_string(),
371 detail: format!(
372 "return type '{child_ret}' is not a subtype of parent '{parent_ret}'"
373 ),
374 },
375 loc.clone(),
376 )
377 .with_snippet(method_name.to_string()),
378 );
379 }
380 }
381
382 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 {child_required} argument(s) but parent requires {parent_required}"
402 ),
403 },
404 loc.clone(),
405 )
406 .with_snippet(method_name.to_string()),
407 );
408 }
409
410 let shared_len = parent.params.len().min(own_method.params.len());
421 for i in 0..shared_len {
422 let parent_param = &parent.params[i];
423 let child_param = &own_method.params[i];
424
425 let (parent_ty, child_ty) = match (&parent_param.ty, &child_param.ty) {
426 (Some(p), Some(c)) => (p, c),
427 _ => continue,
428 };
429
430 if parent_ty.is_mixed()
431 || child_ty.is_mixed()
432 || self.type_has_named_objects(parent_ty)
433 || self.type_has_named_objects(child_ty)
434 || self.type_has_self_or_static(parent_ty)
435 || self.type_has_self_or_static(child_ty)
436 || self.return_type_has_template(parent_ty)
437 || self.return_type_has_template(child_ty)
438 {
439 continue;
440 }
441
442 if !parent_ty.is_subtype_of_simple(child_ty) {
445 issues.push(
446 Issue::new(
447 IssueKind::MethodSignatureMismatch {
448 class: fqcn.to_string(),
449 method: method_name.to_string(),
450 detail: format!(
451 "parameter ${} type '{}' is narrower than parent type '{}'",
452 child_param.name, child_ty, parent_ty
453 ),
454 },
455 loc.clone(),
456 )
457 .with_snippet(method_name.to_string()),
458 );
459 break; }
461 }
462 }
463 }
464
465 fn return_type_has_template(&self, ty: &mir_types::Union) -> bool {
473 use mir_types::Atomic;
474 ty.types.iter().any(|atomic| match atomic {
475 Atomic::TTemplateParam { .. } => true,
476 Atomic::TClassString(Some(inner)) => !self.codebase.type_exists(inner.as_ref()),
477 Atomic::TNamedObject { fqcn, type_params } => {
478 (!fqcn.contains('\\') && !self.codebase.type_exists(fqcn.as_ref()))
480 || type_params.iter().any(|tp| self.return_type_has_template(tp))
482 }
483 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
484 self.return_type_has_template(key) || self.return_type_has_template(value)
485 }
486 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
487 self.return_type_has_template(value)
488 }
489 _ => false,
490 })
491 }
492
493 fn type_has_named_objects(&self, ty: &mir_types::Union) -> bool {
498 use mir_types::Atomic;
499 ty.types.iter().any(|a| match a {
500 Atomic::TNamedObject { .. } => true,
501 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
502 self.type_has_named_objects(key) || self.type_has_named_objects(value)
503 }
504 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
505 self.type_has_named_objects(value)
506 }
507 _ => false,
508 })
509 }
510
511 fn type_has_self_or_static(&self, ty: &mir_types::Union) -> bool {
514 use mir_types::Atomic;
515 ty.types
516 .iter()
517 .any(|a| matches!(a, Atomic::TSelf { .. } | Atomic::TStaticObject { .. }))
518 }
519
520 fn find_parent_method(
522 &self,
523 cls: &mir_codebase::storage::ClassStorage,
524 method_name: &str,
525 ) -> Option<Arc<MethodStorage>> {
526 for ancestor_fqcn in &cls.all_parents {
528 if let Some(ancestor_cls) = self.codebase.classes.get(ancestor_fqcn.as_ref()) {
529 if let Some(m) = ancestor_cls.own_methods.get(method_name) {
530 return Some(Arc::clone(m));
531 }
532 } else if let Some(iface) = self.codebase.interfaces.get(ancestor_fqcn.as_ref()) {
533 if let Some(m) = iface.own_methods.get(method_name) {
534 return Some(Arc::clone(m));
535 }
536 }
537 }
538 None
539 }
540
541 fn check_circular_class_inheritance(&self, issues: &mut Vec<Issue>) {
546 let mut globally_done: HashSet<String> = HashSet::new();
547
548 let mut class_keys: Vec<Arc<str>> = self
549 .codebase
550 .classes
551 .iter()
552 .map(|e| e.key().clone())
553 .collect();
554 class_keys.sort();
555
556 for start_fqcn in &class_keys {
557 if globally_done.contains(start_fqcn.as_ref()) {
558 continue;
559 }
560
561 let mut chain: Vec<Arc<str>> = Vec::new();
563 let mut chain_set: HashSet<String> = HashSet::new();
564 let mut current: Arc<str> = start_fqcn.clone();
565
566 loop {
567 if globally_done.contains(current.as_ref()) {
568 for node in &chain {
570 globally_done.insert(node.to_string());
571 }
572 break;
573 }
574 if !chain_set.insert(current.to_string()) {
575 let cycle_start = chain
577 .iter()
578 .position(|p| p.as_ref() == current.as_ref())
579 .unwrap_or(0);
580 let cycle_nodes = &chain[cycle_start..];
581
582 let offender = cycle_nodes
585 .iter()
586 .filter(|n| self.class_in_analyzed_files(n))
587 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
588
589 if let Some(offender) = offender {
590 let cls = self.codebase.classes.get(offender.as_ref());
591 let loc = issue_location(
592 cls.as_ref().and_then(|c| c.location.as_ref()),
593 offender,
594 cls.as_ref()
595 .and_then(|c| c.location.as_ref())
596 .and_then(|l| self.sources.get(&l.file).copied()),
597 );
598 let mut issue = Issue::new(
599 IssueKind::CircularInheritance {
600 class: offender.to_string(),
601 },
602 loc,
603 );
604 if let Some(snippet) = extract_snippet(
605 cls.as_ref().and_then(|c| c.location.as_ref()),
606 &self.sources,
607 ) {
608 issue = issue.with_snippet(snippet);
609 }
610 issues.push(issue);
611 }
612
613 for node in &chain {
614 globally_done.insert(node.to_string());
615 }
616 break;
617 }
618
619 chain.push(current.clone());
620
621 let parent = self
622 .codebase
623 .classes
624 .get(current.as_ref())
625 .and_then(|c| c.parent.clone());
626
627 match parent {
628 Some(p) => current = p,
629 None => {
630 for node in &chain {
631 globally_done.insert(node.to_string());
632 }
633 break;
634 }
635 }
636 }
637 }
638 }
639
640 fn check_circular_interface_inheritance(&self, issues: &mut Vec<Issue>) {
645 let mut globally_done: HashSet<String> = HashSet::new();
646
647 let mut iface_keys: Vec<Arc<str>> = self
648 .codebase
649 .interfaces
650 .iter()
651 .map(|e| e.key().clone())
652 .collect();
653 iface_keys.sort();
654
655 for start_fqcn in &iface_keys {
656 if globally_done.contains(start_fqcn.as_ref()) {
657 continue;
658 }
659 let mut in_stack: Vec<Arc<str>> = Vec::new();
660 let mut stack_set: HashSet<String> = HashSet::new();
661 self.dfs_interface_cycle(
662 start_fqcn.clone(),
663 &mut in_stack,
664 &mut stack_set,
665 &mut globally_done,
666 issues,
667 );
668 }
669 }
670
671 fn dfs_interface_cycle(
672 &self,
673 fqcn: Arc<str>,
674 in_stack: &mut Vec<Arc<str>>,
675 stack_set: &mut HashSet<String>,
676 globally_done: &mut HashSet<String>,
677 issues: &mut Vec<Issue>,
678 ) {
679 if globally_done.contains(fqcn.as_ref()) {
680 return;
681 }
682 if stack_set.contains(fqcn.as_ref()) {
683 let cycle_start = in_stack
685 .iter()
686 .position(|p| p.as_ref() == fqcn.as_ref())
687 .unwrap_or(0);
688 let cycle_nodes = &in_stack[cycle_start..];
689
690 let offender = cycle_nodes
691 .iter()
692 .filter(|n| self.iface_in_analyzed_files(n))
693 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
694
695 if let Some(offender) = offender {
696 let iface = self.codebase.interfaces.get(offender.as_ref());
697 let loc = issue_location(
698 iface.as_ref().and_then(|i| i.location.as_ref()),
699 offender,
700 iface
701 .as_ref()
702 .and_then(|i| i.location.as_ref())
703 .and_then(|l| self.sources.get(&l.file).copied()),
704 );
705 let mut issue = Issue::new(
706 IssueKind::CircularInheritance {
707 class: offender.to_string(),
708 },
709 loc,
710 );
711 if let Some(snippet) = extract_snippet(
712 iface.as_ref().and_then(|i| i.location.as_ref()),
713 &self.sources,
714 ) {
715 issue = issue.with_snippet(snippet);
716 }
717 issues.push(issue);
718 }
719 return;
720 }
721
722 stack_set.insert(fqcn.to_string());
723 in_stack.push(fqcn.clone());
724
725 let extends = self
726 .codebase
727 .interfaces
728 .get(fqcn.as_ref())
729 .map(|i| i.extends.clone())
730 .unwrap_or_default();
731
732 for parent in extends {
733 self.dfs_interface_cycle(parent, in_stack, stack_set, globally_done, issues);
734 }
735
736 in_stack.pop();
737 stack_set.remove(fqcn.as_ref());
738 globally_done.insert(fqcn.to_string());
739 }
740
741 fn class_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
742 if self.analyzed_files.is_empty() {
743 return true;
744 }
745 self.codebase
746 .classes
747 .get(fqcn.as_ref())
748 .map(|c| {
749 c.location
750 .as_ref()
751 .map(|loc| self.analyzed_files.contains(&loc.file))
752 .unwrap_or(false)
753 })
754 .unwrap_or(false)
755 }
756
757 fn iface_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
758 if self.analyzed_files.is_empty() {
759 return true;
760 }
761 self.codebase
762 .interfaces
763 .get(fqcn.as_ref())
764 .map(|i| {
765 i.location
766 .as_ref()
767 .map(|loc| self.analyzed_files.contains(&loc.file))
768 .unwrap_or(false)
769 })
770 .unwrap_or(false)
771 }
772}
773
774fn visibility_reduced(child_vis: Visibility, parent_vis: Visibility) -> bool {
776 matches!(
779 (parent_vis, child_vis),
780 (Visibility::Public, Visibility::Protected)
781 | (Visibility::Public, Visibility::Private)
782 | (Visibility::Protected, Visibility::Private)
783 )
784}
785
786fn issue_location(
790 storage_loc: Option<&mir_codebase::storage::Location>,
791 fqcn: &Arc<str>,
792 _source: Option<&str>,
793) -> Location {
794 match storage_loc {
795 Some(loc) => Location {
796 file: loc.file.clone(),
797 line: loc.line,
798 line_end: loc.line_end,
799 col_start: loc.col_start,
800 col_end: loc.col_end,
801 },
802 None => Location {
803 file: fqcn.clone(),
804 line: 1,
805 line_end: 1,
806 col_start: 0,
807 col_end: 0,
808 },
809 }
810}
811
812fn extract_snippet(
814 storage_loc: Option<&mir_codebase::storage::Location>,
815 sources: &HashMap<Arc<str>, &str>,
816) -> Option<String> {
817 let loc = storage_loc?;
818 let src = *sources.get(&loc.file)?;
819 let line_idx = loc.line.saturating_sub(1) as usize;
821 let line_text = src.lines().nth(line_idx)?;
822 Some(line_text.trim().to_string())
823}