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 self.codebase.ensure_finalized(fqcn);
67 let cls = match self.codebase.classes.get(fqcn.as_ref()) {
68 Some(c) => c,
69 None => continue,
70 };
71
72 if !self.analyzed_files.is_empty() {
74 let in_analyzed = cls
75 .location
76 .as_ref()
77 .map(|loc| self.analyzed_files.contains(&loc.file))
78 .unwrap_or(false);
79 if !in_analyzed {
80 continue;
81 }
82 }
83
84 if let Some(parent_fqcn) = &cls.parent {
86 if let Some(parent) = self.codebase.classes.get(parent_fqcn.as_ref()) {
87 if parent.is_final {
88 let loc = issue_location(
89 cls.location.as_ref(),
90 fqcn,
91 cls.location
92 .as_ref()
93 .and_then(|l| self.sources.get(&l.file).copied()),
94 );
95 let mut issue = Issue::new(
96 IssueKind::FinalClassExtended {
97 parent: parent_fqcn.to_string(),
98 child: fqcn.to_string(),
99 },
100 loc,
101 );
102 if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources)
103 {
104 issue = issue.with_snippet(snippet);
105 }
106 issues.push(issue);
107 }
108 if let Some(msg) = parent.deprecated.clone() {
109 let loc = issue_location(
110 cls.location.as_ref(),
111 fqcn,
112 cls.location
113 .as_ref()
114 .and_then(|l| self.sources.get(&l.file).copied()),
115 );
116 let mut issue = Issue::new(
117 IssueKind::DeprecatedClass {
118 name: parent_fqcn.to_string(),
119 message: Some(msg).filter(|m| !m.is_empty()),
120 },
121 loc,
122 );
123 if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources)
124 {
125 issue = issue.with_snippet(snippet);
126 }
127 issues.push(issue);
128 }
129 }
130 }
131
132 if cls.is_abstract {
134 self.check_overrides(&cls, &mut issues);
136 continue;
137 }
138
139 self.check_abstract_methods_implemented(&cls, &mut issues);
141
142 self.check_interface_methods_implemented(&cls, &mut issues);
144
145 self.check_overrides(&cls, &mut issues);
147 }
148
149 self.check_circular_class_inheritance(&mut issues);
151 self.check_circular_interface_inheritance(&mut issues);
152
153 issues
154 }
155
156 fn check_abstract_methods_implemented(
161 &self,
162 cls: &mir_codebase::storage::ClassStorage,
163 issues: &mut Vec<Issue>,
164 ) {
165 let fqcn = &cls.fqcn;
166
167 for ancestor_fqcn in &cls.all_parents {
169 let abstract_methods: Vec<Arc<str>> = {
172 let Some(ancestor) = self.codebase.classes.get(ancestor_fqcn.as_ref()) else {
173 continue;
174 };
175 ancestor
176 .own_methods
177 .iter()
178 .filter(|(_, m)| m.is_abstract)
179 .map(|(_, m)| m.name.clone())
180 .collect()
181 };
182
183 for method_name in abstract_methods {
184 if self
186 .codebase
187 .get_method(fqcn.as_ref(), method_name.as_ref())
188 .map(|m| !m.is_abstract)
189 .unwrap_or(false)
190 {
191 continue; }
193
194 let loc = issue_location(
195 cls.location.as_ref(),
196 fqcn,
197 cls.location
198 .as_ref()
199 .and_then(|l| self.sources.get(&l.file).copied()),
200 );
201 let mut issue = Issue::new(
202 IssueKind::UnimplementedAbstractMethod {
203 class: fqcn.to_string(),
204 method: method_name.to_string(),
205 },
206 loc,
207 );
208 if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources) {
209 issue = issue.with_snippet(snippet);
210 }
211 issues.push(issue);
212 }
213 }
214 }
215
216 fn check_interface_methods_implemented(
221 &self,
222 cls: &mir_codebase::storage::ClassStorage,
223 issues: &mut Vec<Issue>,
224 ) {
225 let fqcn = &cls.fqcn;
226
227 let all_ifaces: Vec<Arc<str>> = cls
229 .all_parents
230 .iter()
231 .filter(|p| self.codebase.interfaces.contains_key(p.as_ref()))
232 .cloned()
233 .collect();
234
235 for iface_fqcn in &all_ifaces {
236 let method_names: Vec<Arc<str>> =
239 match self.codebase.interfaces.get(iface_fqcn.as_ref()) {
240 Some(iface) => iface.own_methods.values().map(|m| m.name.clone()).collect(),
241 None => continue,
242 };
243
244 for method_name in method_names {
245 let method_name_lower = method_name.to_lowercase();
249 let implemented = self
251 .codebase
252 .get_method(fqcn.as_ref(), &method_name_lower)
253 .map(|m| !m.is_abstract)
254 .unwrap_or(false);
255
256 if !implemented {
257 let loc = issue_location(
258 cls.location.as_ref(),
259 fqcn,
260 cls.location
261 .as_ref()
262 .and_then(|l| self.sources.get(&l.file).copied()),
263 );
264 let mut issue = Issue::new(
265 IssueKind::UnimplementedInterfaceMethod {
266 class: fqcn.to_string(),
267 interface: iface_fqcn.to_string(),
268 method: method_name.to_string(),
269 },
270 loc,
271 );
272 if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources) {
273 issue = issue.with_snippet(snippet);
274 }
275 issues.push(issue);
276 }
277 }
278 }
279 }
280
281 fn check_overrides(&self, cls: &mir_codebase::storage::ClassStorage, issues: &mut Vec<Issue>) {
286 let fqcn = &cls.fqcn;
287
288 for (method_name, own_method) in &cls.own_methods {
289 if method_name.as_ref() == "__construct" {
291 continue;
292 }
293
294 let parent_method = self.find_parent_method(cls, method_name.as_ref());
296
297 let parent = match parent_method {
298 Some(m) => m,
299 None => continue, };
301
302 let loc = issue_location(
303 own_method.location.as_ref(),
304 fqcn,
305 own_method
306 .location
307 .as_ref()
308 .and_then(|l| self.sources.get(&l.file).copied()),
309 );
310
311 if parent.is_final {
313 let mut issue = Issue::new(
314 IssueKind::FinalMethodOverridden {
315 class: fqcn.to_string(),
316 method: method_name.to_string(),
317 parent: parent.fqcn.to_string(),
318 },
319 loc.clone(),
320 );
321 if let Some(snippet) = extract_snippet(own_method.location.as_ref(), &self.sources)
322 {
323 issue = issue.with_snippet(snippet);
324 }
325 issues.push(issue);
326 }
327
328 if visibility_reduced(own_method.visibility, parent.visibility) {
330 let mut issue = Issue::new(
331 IssueKind::OverriddenMethodAccess {
332 class: fqcn.to_string(),
333 method: method_name.to_string(),
334 },
335 loc.clone(),
336 );
337 if let Some(snippet) = extract_snippet(own_method.location.as_ref(), &self.sources)
338 {
339 issue = issue.with_snippet(snippet);
340 }
341 issues.push(issue);
342 }
343
344 if let (Some(child_ret), Some(parent_ret)) =
351 (&own_method.return_type, &parent.return_type)
352 {
353 let parent_from_docblock = parent_ret.from_docblock;
354 let involves_named_objects = self.type_has_named_objects(child_ret)
355 || self.type_has_named_objects(parent_ret);
356 let involves_self_static = self.type_has_self_or_static(child_ret)
357 || self.type_has_self_or_static(parent_ret);
358
359 if !parent_from_docblock
360 && !parent_ret.is_mixed()
361 && !child_ret.is_mixed()
362 && !self.return_type_has_template(parent_ret)
363 {
364 let child_file = own_method
365 .location
366 .as_ref()
367 .map(|l| l.file.as_ref())
368 .unwrap_or("");
369
370 let compatible = if (involves_named_objects || involves_self_static)
371 && self.type_has_only_object_atoms(child_ret)
372 && self.type_has_only_object_atoms(parent_ret)
373 {
374 crate::stmt::named_object_return_compatible(
375 child_ret,
376 parent_ret,
377 self.codebase,
378 child_file,
379 )
380 } else if involves_named_objects || involves_self_static {
381 true } else {
383 child_ret.is_subtype_of_simple(parent_ret)
384 };
385
386 if !compatible {
387 issues.push(
388 Issue::new(
389 IssueKind::MethodSignatureMismatch {
390 class: fqcn.to_string(),
391 method: method_name.to_string(),
392 detail: format!(
393 "return type '{child_ret}' is not a subtype of parent '{parent_ret}'"
394 ),
395 },
396 loc.clone(),
397 )
398 .with_snippet(method_name.to_string()),
399 );
400 }
401 }
402 }
403
404 let parent_required = parent
406 .params
407 .iter()
408 .filter(|p| !p.is_optional && !p.is_variadic)
409 .count();
410 let child_required = own_method
411 .params
412 .iter()
413 .filter(|p| !p.is_optional && !p.is_variadic)
414 .count();
415
416 if child_required > parent_required {
417 issues.push(
418 Issue::new(
419 IssueKind::MethodSignatureMismatch {
420 class: fqcn.to_string(),
421 method: method_name.to_string(),
422 detail: format!(
423 "overriding method requires {child_required} argument(s) but parent requires {parent_required}"
424 ),
425 },
426 loc.clone(),
427 )
428 .with_snippet(method_name.to_string()),
429 );
430 }
431
432 let shared_len = parent.params.len().min(own_method.params.len());
443 for i in 0..shared_len {
444 let parent_param = &parent.params[i];
445 let child_param = &own_method.params[i];
446
447 let (parent_ty, child_ty) = match (&parent_param.ty, &child_param.ty) {
448 (Some(p), Some(c)) => (p, c),
449 _ => continue,
450 };
451
452 if parent_ty.is_mixed()
453 || child_ty.is_mixed()
454 || self.type_has_named_objects(parent_ty)
455 || self.type_has_named_objects(child_ty)
456 || self.type_has_self_or_static(parent_ty)
457 || self.type_has_self_or_static(child_ty)
458 || self.return_type_has_template(parent_ty)
459 || self.return_type_has_template(child_ty)
460 {
461 continue;
462 }
463
464 if !parent_ty.is_subtype_of_simple(child_ty) {
467 issues.push(
468 Issue::new(
469 IssueKind::MethodSignatureMismatch {
470 class: fqcn.to_string(),
471 method: method_name.to_string(),
472 detail: format!(
473 "parameter ${} type '{}' is narrower than parent type '{}'",
474 child_param.name, child_ty, parent_ty
475 ),
476 },
477 loc.clone(),
478 )
479 .with_snippet(method_name.to_string()),
480 );
481 break; }
483 }
484 }
485 }
486
487 fn return_type_has_template(&self, ty: &mir_types::Union) -> bool {
495 use mir_types::Atomic;
496 ty.types.iter().any(|atomic| match atomic {
497 Atomic::TTemplateParam { .. } => true,
498 Atomic::TClassString(Some(inner)) => !self.codebase.type_exists(inner.as_ref()),
499 Atomic::TNamedObject { fqcn, type_params } => {
500 (!fqcn.contains('\\') && !self.codebase.type_exists(fqcn.as_ref()))
502 || type_params.iter().any(|tp| self.return_type_has_template(tp))
504 }
505 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
506 self.return_type_has_template(key) || self.return_type_has_template(value)
507 }
508 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
509 self.return_type_has_template(value)
510 }
511 _ => false,
512 })
513 }
514
515 fn type_has_named_objects(&self, ty: &mir_types::Union) -> bool {
520 use mir_types::Atomic;
521 ty.types.iter().any(|a| match a {
522 Atomic::TNamedObject { .. } => true,
523 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
524 self.type_has_named_objects(key) || self.type_has_named_objects(value)
525 }
526 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
527 self.type_has_named_objects(value)
528 }
529 _ => false,
530 })
531 }
532
533 fn type_has_self_or_static(&self, ty: &mir_types::Union) -> bool {
536 use mir_types::Atomic;
537 ty.types
538 .iter()
539 .any(|a| matches!(a, Atomic::TSelf { .. } | Atomic::TStaticObject { .. }))
540 }
541
542 fn type_has_only_object_atoms(&self, ty: &mir_types::Union) -> bool {
547 use mir_types::Atomic;
548 ty.types.iter().all(|a| {
549 matches!(
550 a,
551 Atomic::TNamedObject { .. }
552 | Atomic::TSelf { .. }
553 | Atomic::TStaticObject { .. }
554 | Atomic::TParent { .. }
555 | Atomic::TNull
556 | Atomic::TVoid
557 | Atomic::TNever
558 | Atomic::TClassString(_)
559 )
560 })
561 }
562
563 fn find_parent_method(
565 &self,
566 cls: &mir_codebase::storage::ClassStorage,
567 method_name: &str,
568 ) -> Option<Arc<MethodStorage>> {
569 for ancestor_fqcn in &cls.all_parents {
571 if let Some(ancestor_cls) = self.codebase.classes.get(ancestor_fqcn.as_ref()) {
572 if let Some(m) = ancestor_cls.own_methods.get(method_name) {
573 return Some(Arc::clone(m));
574 }
575 } else if let Some(iface) = self.codebase.interfaces.get(ancestor_fqcn.as_ref()) {
576 if let Some(m) = iface.own_methods.get(method_name) {
577 return Some(Arc::clone(m));
578 }
579 }
580 }
581 None
582 }
583
584 fn check_circular_class_inheritance(&self, issues: &mut Vec<Issue>) {
589 let mut globally_done: HashSet<String> = HashSet::new();
590
591 let mut class_keys: Vec<Arc<str>> = self
592 .codebase
593 .classes
594 .iter()
595 .map(|e| e.key().clone())
596 .collect();
597 class_keys.sort();
598
599 for start_fqcn in &class_keys {
600 if globally_done.contains(start_fqcn.as_ref()) {
601 continue;
602 }
603
604 let mut chain: Vec<Arc<str>> = Vec::new();
606 let mut chain_set: HashSet<String> = HashSet::new();
607 let mut current: Arc<str> = start_fqcn.clone();
608
609 loop {
610 if globally_done.contains(current.as_ref()) {
611 for node in &chain {
613 globally_done.insert(node.to_string());
614 }
615 break;
616 }
617 if !chain_set.insert(current.to_string()) {
618 let cycle_start = chain
620 .iter()
621 .position(|p| p.as_ref() == current.as_ref())
622 .unwrap_or(0);
623 let cycle_nodes = &chain[cycle_start..];
624
625 let offender = cycle_nodes
628 .iter()
629 .filter(|n| self.class_in_analyzed_files(n))
630 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
631
632 if let Some(offender) = offender {
633 let cls = self.codebase.classes.get(offender.as_ref());
634 let loc = issue_location(
635 cls.as_ref().and_then(|c| c.location.as_ref()),
636 offender,
637 cls.as_ref()
638 .and_then(|c| c.location.as_ref())
639 .and_then(|l| self.sources.get(&l.file).copied()),
640 );
641 let mut issue = Issue::new(
642 IssueKind::CircularInheritance {
643 class: offender.to_string(),
644 },
645 loc,
646 );
647 if let Some(snippet) = extract_snippet(
648 cls.as_ref().and_then(|c| c.location.as_ref()),
649 &self.sources,
650 ) {
651 issue = issue.with_snippet(snippet);
652 }
653 issues.push(issue);
654 }
655
656 for node in &chain {
657 globally_done.insert(node.to_string());
658 }
659 break;
660 }
661
662 chain.push(current.clone());
663
664 let parent = self
665 .codebase
666 .classes
667 .get(current.as_ref())
668 .and_then(|c| c.parent.clone());
669
670 match parent {
671 Some(p) => current = p,
672 None => {
673 for node in &chain {
674 globally_done.insert(node.to_string());
675 }
676 break;
677 }
678 }
679 }
680 }
681 }
682
683 fn check_circular_interface_inheritance(&self, issues: &mut Vec<Issue>) {
688 let mut globally_done: HashSet<String> = HashSet::new();
689
690 let mut iface_keys: Vec<Arc<str>> = self
691 .codebase
692 .interfaces
693 .iter()
694 .map(|e| e.key().clone())
695 .collect();
696 iface_keys.sort();
697
698 for start_fqcn in &iface_keys {
699 if globally_done.contains(start_fqcn.as_ref()) {
700 continue;
701 }
702 let mut in_stack: Vec<Arc<str>> = Vec::new();
703 let mut stack_set: HashSet<String> = HashSet::new();
704 self.dfs_interface_cycle(
705 start_fqcn.clone(),
706 &mut in_stack,
707 &mut stack_set,
708 &mut globally_done,
709 issues,
710 );
711 }
712 }
713
714 fn dfs_interface_cycle(
715 &self,
716 fqcn: Arc<str>,
717 in_stack: &mut Vec<Arc<str>>,
718 stack_set: &mut HashSet<String>,
719 globally_done: &mut HashSet<String>,
720 issues: &mut Vec<Issue>,
721 ) {
722 if globally_done.contains(fqcn.as_ref()) {
723 return;
724 }
725 if stack_set.contains(fqcn.as_ref()) {
726 let cycle_start = in_stack
728 .iter()
729 .position(|p| p.as_ref() == fqcn.as_ref())
730 .unwrap_or(0);
731 let cycle_nodes = &in_stack[cycle_start..];
732
733 let offender = cycle_nodes
734 .iter()
735 .filter(|n| self.iface_in_analyzed_files(n))
736 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
737
738 if let Some(offender) = offender {
739 let iface = self.codebase.interfaces.get(offender.as_ref());
740 let loc = issue_location(
741 iface.as_ref().and_then(|i| i.location.as_ref()),
742 offender,
743 iface
744 .as_ref()
745 .and_then(|i| i.location.as_ref())
746 .and_then(|l| self.sources.get(&l.file).copied()),
747 );
748 let mut issue = Issue::new(
749 IssueKind::CircularInheritance {
750 class: offender.to_string(),
751 },
752 loc,
753 );
754 if let Some(snippet) = extract_snippet(
755 iface.as_ref().and_then(|i| i.location.as_ref()),
756 &self.sources,
757 ) {
758 issue = issue.with_snippet(snippet);
759 }
760 issues.push(issue);
761 }
762 return;
763 }
764
765 stack_set.insert(fqcn.to_string());
766 in_stack.push(fqcn.clone());
767
768 let extends = self
769 .codebase
770 .interfaces
771 .get(fqcn.as_ref())
772 .map(|i| i.extends.clone())
773 .unwrap_or_default();
774
775 for parent in extends {
776 self.dfs_interface_cycle(parent, in_stack, stack_set, globally_done, issues);
777 }
778
779 in_stack.pop();
780 stack_set.remove(fqcn.as_ref());
781 globally_done.insert(fqcn.to_string());
782 }
783
784 fn class_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
785 if self.analyzed_files.is_empty() {
786 return true;
787 }
788 self.codebase
789 .classes
790 .get(fqcn.as_ref())
791 .map(|c| {
792 c.location
793 .as_ref()
794 .map(|loc| self.analyzed_files.contains(&loc.file))
795 .unwrap_or(false)
796 })
797 .unwrap_or(false)
798 }
799
800 fn iface_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
801 if self.analyzed_files.is_empty() {
802 return true;
803 }
804 self.codebase
805 .interfaces
806 .get(fqcn.as_ref())
807 .map(|i| {
808 i.location
809 .as_ref()
810 .map(|loc| self.analyzed_files.contains(&loc.file))
811 .unwrap_or(false)
812 })
813 .unwrap_or(false)
814 }
815}
816
817fn visibility_reduced(child_vis: Visibility, parent_vis: Visibility) -> bool {
819 matches!(
822 (parent_vis, child_vis),
823 (Visibility::Public, Visibility::Protected)
824 | (Visibility::Public, Visibility::Private)
825 | (Visibility::Protected, Visibility::Private)
826 )
827}
828
829fn issue_location(
833 storage_loc: Option<&mir_codebase::storage::Location>,
834 fqcn: &Arc<str>,
835 _source: Option<&str>,
836) -> Location {
837 match storage_loc {
838 Some(loc) => Location {
839 file: loc.file.clone(),
840 line: loc.line,
841 line_end: loc.line_end,
842 col_start: loc.col_start,
843 col_end: loc.col_end,
844 },
845 None => Location {
846 file: fqcn.clone(),
847 line: 1,
848 line_end: 1,
849 col_start: 0,
850 col_end: 0,
851 },
852 }
853}
854
855fn extract_snippet(
857 storage_loc: Option<&mir_codebase::storage::Location>,
858 sources: &HashMap<Arc<str>, &str>,
859) -> Option<String> {
860 let loc = storage_loc?;
861 let src = *sources.get(&loc.file)?;
862 let line_idx = loc.line.saturating_sub(1) as usize;
864 let line_text = src.lines().nth(line_idx)?;
865 Some(line_text.trim().to_string())
866}