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 && !parent_ret.is_mixed()
360 && !child_ret.is_mixed()
361 && !self.return_type_has_template(parent_ret)
362 {
363 let child_file = own_method
364 .location
365 .as_ref()
366 .map(|l| l.file.as_ref())
367 .unwrap_or("");
368
369 let compatible = if (involves_named_objects || involves_self_static)
370 && self.type_has_only_object_atoms(child_ret)
371 && self.type_has_only_object_atoms(parent_ret)
372 {
373 crate::stmt::named_object_return_compatible(
374 child_ret,
375 parent_ret,
376 self.codebase,
377 child_file,
378 )
379 } else if involves_named_objects || involves_self_static {
380 true } else {
382 child_ret.is_subtype_of_simple(parent_ret)
383 };
384
385 if !compatible {
386 issues.push(
387 Issue::new(
388 IssueKind::MethodSignatureMismatch {
389 class: fqcn.to_string(),
390 method: method_name.to_string(),
391 detail: format!(
392 "return type '{child_ret}' is not a subtype of parent '{parent_ret}'"
393 ),
394 },
395 loc.clone(),
396 )
397 .with_snippet(method_name.to_string()),
398 );
399 }
400 }
401 }
402
403 let parent_required = parent
405 .params
406 .iter()
407 .filter(|p| !p.is_optional && !p.is_variadic)
408 .count();
409 let child_required = own_method
410 .params
411 .iter()
412 .filter(|p| !p.is_optional && !p.is_variadic)
413 .count();
414
415 if child_required > parent_required {
416 issues.push(
417 Issue::new(
418 IssueKind::MethodSignatureMismatch {
419 class: fqcn.to_string(),
420 method: method_name.to_string(),
421 detail: format!(
422 "overriding method requires {child_required} argument(s) but parent requires {parent_required}"
423 ),
424 },
425 loc.clone(),
426 )
427 .with_snippet(method_name.to_string()),
428 );
429 }
430
431 let shared_len = parent.params.len().min(own_method.params.len());
442 for i in 0..shared_len {
443 let parent_param = &parent.params[i];
444 let child_param = &own_method.params[i];
445
446 let (parent_ty, child_ty) = match (&parent_param.ty, &child_param.ty) {
447 (Some(p), Some(c)) => (p, c),
448 _ => continue,
449 };
450
451 if parent_ty.is_mixed()
452 || child_ty.is_mixed()
453 || self.type_has_named_objects(parent_ty)
454 || self.type_has_named_objects(child_ty)
455 || self.type_has_self_or_static(parent_ty)
456 || self.type_has_self_or_static(child_ty)
457 || self.return_type_has_template(parent_ty)
458 || self.return_type_has_template(child_ty)
459 {
460 continue;
461 }
462
463 if !parent_ty.is_subtype_of_simple(child_ty) {
466 issues.push(
467 Issue::new(
468 IssueKind::MethodSignatureMismatch {
469 class: fqcn.to_string(),
470 method: method_name.to_string(),
471 detail: format!(
472 "parameter ${} type '{}' is narrower than parent type '{}'",
473 child_param.name, child_ty, parent_ty
474 ),
475 },
476 loc.clone(),
477 )
478 .with_snippet(method_name.to_string()),
479 );
480 break; }
482 }
483 }
484 }
485
486 fn return_type_has_template(&self, ty: &mir_types::Union) -> bool {
494 use mir_types::Atomic;
495 ty.types.iter().any(|atomic| match atomic {
496 Atomic::TTemplateParam { .. } => true,
497 Atomic::TClassString(Some(inner)) => !self.codebase.type_exists(inner.as_ref()),
498 Atomic::TNamedObject { fqcn, type_params } => {
499 (!fqcn.contains('\\') && !self.codebase.type_exists(fqcn.as_ref()))
501 || type_params.iter().any(|tp| self.return_type_has_template(tp))
503 }
504 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
505 self.return_type_has_template(key) || self.return_type_has_template(value)
506 }
507 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
508 self.return_type_has_template(value)
509 }
510 _ => false,
511 })
512 }
513
514 fn type_has_named_objects(&self, ty: &mir_types::Union) -> bool {
519 use mir_types::Atomic;
520 ty.types.iter().any(|a| match a {
521 Atomic::TNamedObject { .. } => true,
522 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
523 self.type_has_named_objects(key) || self.type_has_named_objects(value)
524 }
525 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
526 self.type_has_named_objects(value)
527 }
528 _ => false,
529 })
530 }
531
532 fn type_has_self_or_static(&self, ty: &mir_types::Union) -> bool {
535 use mir_types::Atomic;
536 ty.types
537 .iter()
538 .any(|a| matches!(a, Atomic::TSelf { .. } | Atomic::TStaticObject { .. }))
539 }
540
541 fn type_has_only_object_atoms(&self, ty: &mir_types::Union) -> bool {
546 use mir_types::Atomic;
547 ty.types.iter().all(|a| {
548 matches!(
549 a,
550 Atomic::TNamedObject { .. }
551 | Atomic::TSelf { .. }
552 | Atomic::TStaticObject { .. }
553 | Atomic::TParent { .. }
554 | Atomic::TNull
555 | Atomic::TVoid
556 | Atomic::TNever
557 | Atomic::TClassString(_)
558 )
559 })
560 }
561
562 fn find_parent_method(
564 &self,
565 cls: &mir_codebase::storage::ClassStorage,
566 method_name: &str,
567 ) -> Option<Arc<MethodStorage>> {
568 for ancestor_fqcn in &cls.all_parents {
570 if let Some(ancestor_cls) = self.codebase.classes.get(ancestor_fqcn.as_ref()) {
571 if let Some(m) = ancestor_cls.own_methods.get(method_name) {
572 return Some(Arc::clone(m));
573 }
574 } else if let Some(iface) = self.codebase.interfaces.get(ancestor_fqcn.as_ref()) {
575 if let Some(m) = iface.own_methods.get(method_name) {
576 return Some(Arc::clone(m));
577 }
578 }
579 }
580 None
581 }
582
583 fn check_circular_class_inheritance(&self, issues: &mut Vec<Issue>) {
588 let mut globally_done: HashSet<String> = HashSet::new();
589
590 let mut class_keys: Vec<Arc<str>> = self
591 .codebase
592 .classes
593 .iter()
594 .map(|e| e.key().clone())
595 .collect();
596 class_keys.sort();
597
598 for start_fqcn in &class_keys {
599 if globally_done.contains(start_fqcn.as_ref()) {
600 continue;
601 }
602
603 let mut chain: Vec<Arc<str>> = Vec::new();
605 let mut chain_set: HashSet<String> = HashSet::new();
606 let mut current: Arc<str> = start_fqcn.clone();
607
608 loop {
609 if globally_done.contains(current.as_ref()) {
610 for node in &chain {
612 globally_done.insert(node.to_string());
613 }
614 break;
615 }
616 if !chain_set.insert(current.to_string()) {
617 let cycle_start = chain
619 .iter()
620 .position(|p| p.as_ref() == current.as_ref())
621 .unwrap_or(0);
622 let cycle_nodes = &chain[cycle_start..];
623
624 let offender = cycle_nodes
627 .iter()
628 .filter(|n| self.class_in_analyzed_files(n))
629 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
630
631 if let Some(offender) = offender {
632 let cls = self.codebase.classes.get(offender.as_ref());
633 let loc = issue_location(
634 cls.as_ref().and_then(|c| c.location.as_ref()),
635 offender,
636 cls.as_ref()
637 .and_then(|c| c.location.as_ref())
638 .and_then(|l| self.sources.get(&l.file).copied()),
639 );
640 let mut issue = Issue::new(
641 IssueKind::CircularInheritance {
642 class: offender.to_string(),
643 },
644 loc,
645 );
646 if let Some(snippet) = extract_snippet(
647 cls.as_ref().and_then(|c| c.location.as_ref()),
648 &self.sources,
649 ) {
650 issue = issue.with_snippet(snippet);
651 }
652 issues.push(issue);
653 }
654
655 for node in &chain {
656 globally_done.insert(node.to_string());
657 }
658 break;
659 }
660
661 chain.push(current.clone());
662
663 let parent = self
664 .codebase
665 .classes
666 .get(current.as_ref())
667 .and_then(|c| c.parent.clone());
668
669 match parent {
670 Some(p) => current = p,
671 None => {
672 for node in &chain {
673 globally_done.insert(node.to_string());
674 }
675 break;
676 }
677 }
678 }
679 }
680 }
681
682 fn check_circular_interface_inheritance(&self, issues: &mut Vec<Issue>) {
687 let mut globally_done: HashSet<String> = HashSet::new();
688
689 let mut iface_keys: Vec<Arc<str>> = self
690 .codebase
691 .interfaces
692 .iter()
693 .map(|e| e.key().clone())
694 .collect();
695 iface_keys.sort();
696
697 for start_fqcn in &iface_keys {
698 if globally_done.contains(start_fqcn.as_ref()) {
699 continue;
700 }
701 let mut in_stack: Vec<Arc<str>> = Vec::new();
702 let mut stack_set: HashSet<String> = HashSet::new();
703 self.dfs_interface_cycle(
704 start_fqcn.clone(),
705 &mut in_stack,
706 &mut stack_set,
707 &mut globally_done,
708 issues,
709 );
710 }
711 }
712
713 fn dfs_interface_cycle(
714 &self,
715 fqcn: Arc<str>,
716 in_stack: &mut Vec<Arc<str>>,
717 stack_set: &mut HashSet<String>,
718 globally_done: &mut HashSet<String>,
719 issues: &mut Vec<Issue>,
720 ) {
721 if globally_done.contains(fqcn.as_ref()) {
722 return;
723 }
724 if stack_set.contains(fqcn.as_ref()) {
725 let cycle_start = in_stack
727 .iter()
728 .position(|p| p.as_ref() == fqcn.as_ref())
729 .unwrap_or(0);
730 let cycle_nodes = &in_stack[cycle_start..];
731
732 let offender = cycle_nodes
733 .iter()
734 .filter(|n| self.iface_in_analyzed_files(n))
735 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
736
737 if let Some(offender) = offender {
738 let iface = self.codebase.interfaces.get(offender.as_ref());
739 let loc = issue_location(
740 iface.as_ref().and_then(|i| i.location.as_ref()),
741 offender,
742 iface
743 .as_ref()
744 .and_then(|i| i.location.as_ref())
745 .and_then(|l| self.sources.get(&l.file).copied()),
746 );
747 let mut issue = Issue::new(
748 IssueKind::CircularInheritance {
749 class: offender.to_string(),
750 },
751 loc,
752 );
753 if let Some(snippet) = extract_snippet(
754 iface.as_ref().and_then(|i| i.location.as_ref()),
755 &self.sources,
756 ) {
757 issue = issue.with_snippet(snippet);
758 }
759 issues.push(issue);
760 }
761 return;
762 }
763
764 stack_set.insert(fqcn.to_string());
765 in_stack.push(fqcn.clone());
766
767 let extends = self
768 .codebase
769 .interfaces
770 .get(fqcn.as_ref())
771 .map(|i| i.extends.clone())
772 .unwrap_or_default();
773
774 for parent in extends {
775 self.dfs_interface_cycle(parent, in_stack, stack_set, globally_done, issues);
776 }
777
778 in_stack.pop();
779 stack_set.remove(fqcn.as_ref());
780 globally_done.insert(fqcn.to_string());
781 }
782
783 fn class_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
784 if self.analyzed_files.is_empty() {
785 return true;
786 }
787 self.codebase
788 .classes
789 .get(fqcn.as_ref())
790 .map(|c| {
791 c.location
792 .as_ref()
793 .map(|loc| self.analyzed_files.contains(&loc.file))
794 .unwrap_or(false)
795 })
796 .unwrap_or(false)
797 }
798
799 fn iface_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
800 if self.analyzed_files.is_empty() {
801 return true;
802 }
803 self.codebase
804 .interfaces
805 .get(fqcn.as_ref())
806 .map(|i| {
807 i.location
808 .as_ref()
809 .map(|loc| self.analyzed_files.contains(&loc.file))
810 .unwrap_or(false)
811 })
812 .unwrap_or(false)
813 }
814}
815
816fn visibility_reduced(child_vis: Visibility, parent_vis: Visibility) -> bool {
818 matches!(
821 (parent_vis, child_vis),
822 (Visibility::Public, Visibility::Protected)
823 | (Visibility::Public, Visibility::Private)
824 | (Visibility::Protected, Visibility::Private)
825 )
826}
827
828fn issue_location(
832 storage_loc: Option<&mir_codebase::storage::Location>,
833 fqcn: &Arc<str>,
834 _source: Option<&str>,
835) -> Location {
836 match storage_loc {
837 Some(loc) => Location {
838 file: loc.file.clone(),
839 line: loc.line,
840 line_end: loc.line_end,
841 col_start: loc.col_start,
842 col_end: loc.col_end,
843 },
844 None => Location {
845 file: fqcn.clone(),
846 line: 1,
847 line_end: 1,
848 col_start: 0,
849 col_end: 0,
850 },
851 }
852}
853
854fn extract_snippet(
856 storage_loc: Option<&mir_codebase::storage::Location>,
857 sources: &HashMap<Arc<str>, &str>,
858) -> Option<String> {
859 let loc = storage_loc?;
860 let src = *sources.get(&loc.file)?;
861 let line_idx = loc.line.saturating_sub(1) as usize;
863 let line_text = src.lines().nth(line_idx)?;
864 Some(line_text.trim().to_string())
865}