1use std::collections::{HashMap, HashSet};
11use std::sync::Arc;
12
13use mir_codebase::storage::{Location as StorageLocation, Visibility};
14use mir_issues::{Issue, IssueKind, Location};
15
16use crate::db::{class_ancestors, MirDatabase};
17
18pub struct ClassAnalyzer<'a> {
23 db: &'a dyn MirDatabase,
24 analyzed_files: HashSet<Arc<str>>,
26 sources: HashMap<Arc<str>, &'a str>,
28}
29
30impl<'a> ClassAnalyzer<'a> {
31 pub fn new(db: &'a dyn MirDatabase) -> Self {
32 Self {
33 db,
34 analyzed_files: HashSet::new(),
35 sources: HashMap::new(),
36 }
37 }
38
39 pub fn with_files(
40 db: &'a dyn MirDatabase,
41 files: HashSet<Arc<str>>,
42 file_data: &'a [(Arc<str>, Arc<str>)],
43 ) -> Self {
44 let sources: HashMap<Arc<str>, &'a str> = file_data
45 .iter()
46 .map(|(f, s)| (f.clone(), s.as_ref()))
47 .collect();
48 Self {
49 db,
50 analyzed_files: files,
51 sources,
52 }
53 }
54
55 fn ancestors(&self, fqcn: &str) -> Vec<Arc<str>> {
58 self.db
59 .lookup_class_node(fqcn)
60 .map(|node| class_ancestors(self.db, node).0)
61 .unwrap_or_default()
62 }
63
64 pub fn analyze_all(&self) -> Vec<Issue> {
66 let mut issues = Vec::new();
67
68 let mut class_keys: Vec<Arc<str>> = self
69 .db
70 .active_class_node_fqcns()
71 .into_iter()
72 .filter(|fqcn| {
73 self.db
74 .lookup_class_node(fqcn.as_ref())
75 .map(|n| {
76 !n.is_interface(self.db) && !n.is_trait(self.db) && !n.is_enum(self.db)
77 })
78 .unwrap_or(false)
79 })
80 .collect();
81 class_keys.sort();
83
84 for fqcn in &class_keys {
85 let node = match self
86 .db
87 .lookup_class_node(fqcn.as_ref())
88 .filter(|n| n.active(self.db))
89 {
90 Some(n) => n,
91 None => continue,
92 };
93 let location = node.location(self.db);
94
95 if !self.analyzed_files.is_empty() {
97 let in_analyzed = location
98 .as_ref()
99 .map(|loc| self.analyzed_files.contains(&loc.file))
100 .unwrap_or(false);
101 if !in_analyzed {
102 continue;
103 }
104 }
105
106 if let Some(parent_fqcn) = node.parent(self.db) {
108 if let Some(parent) = self
109 .db
110 .lookup_class_node(parent_fqcn.as_ref())
111 .filter(|n| n.active(self.db))
112 {
113 if parent.is_final(self.db) {
114 let loc = issue_location(
115 location.as_ref(),
116 fqcn,
117 location
118 .as_ref()
119 .and_then(|l| self.sources.get(&l.file).copied()),
120 );
121 let mut issue = Issue::new(
122 IssueKind::FinalClassExtended {
123 parent: parent_fqcn.to_string(),
124 child: fqcn.to_string(),
125 },
126 loc,
127 );
128 if let Some(snippet) = extract_snippet(location.as_ref(), &self.sources) {
129 issue = issue.with_snippet(snippet);
130 }
131 issues.push(issue);
132 }
133 if let Some(msg) = parent.deprecated(self.db) {
134 let loc = issue_location(
135 location.as_ref(),
136 fqcn,
137 location
138 .as_ref()
139 .and_then(|l| self.sources.get(&l.file).copied()),
140 );
141 let mut issue = Issue::new(
142 IssueKind::DeprecatedClass {
143 name: parent_fqcn.to_string(),
144 message: Some(msg).filter(|m| !m.is_empty()),
145 },
146 loc,
147 );
148 if let Some(snippet) = extract_snippet(location.as_ref(), &self.sources) {
149 issue = issue.with_snippet(snippet);
150 }
151 issues.push(issue);
152 }
153 }
154 }
155
156 if node.is_abstract(self.db) {
158 self.check_overrides(fqcn, location.as_ref(), &mut issues);
160 continue;
161 }
162
163 self.check_abstract_methods_implemented(fqcn, location.as_ref(), &mut issues);
165
166 self.check_interface_methods_implemented(fqcn, location.as_ref(), &mut issues);
168
169 self.check_overrides(fqcn, location.as_ref(), &mut issues);
171 }
172
173 self.check_circular_class_inheritance(&mut issues);
175 self.check_circular_interface_inheritance(&mut issues);
176
177 issues
178 }
179
180 fn check_abstract_methods_implemented(
185 &self,
186 fqcn: &Arc<str>,
187 cls_location: Option<&StorageLocation>,
188 issues: &mut Vec<Issue>,
189 ) {
190 let ancestors = self.ancestors(fqcn);
192 for ancestor_fqcn in &ancestors {
193 let abstract_methods: Vec<Arc<str>> = self
198 .db
199 .class_own_methods(ancestor_fqcn.as_ref())
200 .into_iter()
201 .filter(|m| m.active(self.db) && m.is_abstract(self.db))
202 .map(|m| m.name(self.db))
203 .collect();
204
205 for method_name in abstract_methods {
206 if crate::db::method_is_concretely_implemented(
208 self.db,
209 fqcn.as_ref(),
210 method_name.as_ref(),
211 ) {
212 continue; }
214
215 let loc = issue_location(
216 cls_location,
217 fqcn,
218 cls_location.and_then(|l| self.sources.get(&l.file).copied()),
219 );
220 let mut issue = Issue::new(
221 IssueKind::UnimplementedAbstractMethod {
222 class: fqcn.to_string(),
223 method: method_name.to_string(),
224 },
225 loc,
226 );
227 if let Some(snippet) = extract_snippet(cls_location, &self.sources) {
228 issue = issue.with_snippet(snippet);
229 }
230 issues.push(issue);
231 }
232 }
233 }
234
235 fn check_interface_methods_implemented(
240 &self,
241 fqcn: &Arc<str>,
242 cls_location: Option<&StorageLocation>,
243 issues: &mut Vec<Issue>,
244 ) {
245 let all_ifaces: Vec<Arc<str>> = self
247 .ancestors(fqcn)
248 .into_iter()
249 .filter(|p| {
250 crate::db::class_kind_via_db(self.db, p.as_ref()).is_some_and(|k| k.is_interface)
251 })
252 .collect();
253
254 for iface_fqcn in &all_ifaces {
255 let method_nodes = self.db.class_own_methods(iface_fqcn.as_ref());
259 if method_nodes.is_empty() {
260 continue;
263 }
264 let method_names: Vec<Arc<str>> = method_nodes
265 .into_iter()
266 .filter(|m| m.active(self.db))
267 .map(|m| m.name(self.db))
268 .collect();
269
270 for method_name in method_names {
271 let method_name_lower = method_name.to_lowercase();
275 let implemented = crate::db::method_is_concretely_implemented(
277 self.db,
278 fqcn.as_ref(),
279 &method_name_lower,
280 );
281
282 if !implemented {
283 let loc = issue_location(
284 cls_location,
285 fqcn,
286 cls_location.and_then(|l| self.sources.get(&l.file).copied()),
287 );
288 let mut issue = Issue::new(
289 IssueKind::UnimplementedInterfaceMethod {
290 class: fqcn.to_string(),
291 interface: iface_fqcn.to_string(),
292 method: method_name.to_string(),
293 },
294 loc,
295 );
296 if let Some(snippet) = extract_snippet(cls_location, &self.sources) {
297 issue = issue.with_snippet(snippet);
298 }
299 issues.push(issue);
300 }
301 }
302 }
303 }
304
305 fn check_overrides(
310 &self,
311 fqcn: &Arc<str>,
312 _cls_location: Option<&StorageLocation>,
313 issues: &mut Vec<Issue>,
314 ) {
315 let own_methods = self.db.class_own_methods(fqcn.as_ref());
316 for own in own_methods {
317 if !own.active(self.db) {
318 continue;
319 }
320 let method_name: Arc<str> = own.name(self.db);
321
322 if method_name.as_ref() == "__construct" {
324 continue;
325 }
326
327 let method_name_lower: Arc<str> = if method_name.chars().all(|c| !c.is_uppercase()) {
329 method_name.clone()
330 } else {
331 Arc::from(method_name.to_lowercase().as_str())
332 };
333 let parent_method = self.find_parent_method(fqcn, method_name_lower.as_ref());
334
335 let parent = match parent_method {
336 Some(m) => m,
337 None => continue, };
339
340 let own_location = own.location(self.db);
341 let loc = issue_location(
342 own_location.as_ref(),
343 fqcn,
344 own_location
345 .as_ref()
346 .and_then(|l| self.sources.get(&l.file).copied()),
347 );
348
349 if parent.is_final(self.db) {
351 let mut issue = Issue::new(
352 IssueKind::FinalMethodOverridden {
353 class: fqcn.to_string(),
354 method: method_name_lower.to_string(),
355 parent: parent.fqcn(self.db).to_string(),
356 },
357 loc.clone(),
358 );
359 if let Some(snippet) = extract_snippet(own_location.as_ref(), &self.sources) {
360 issue = issue.with_snippet(snippet);
361 }
362 issues.push(issue);
363 }
364
365 if visibility_reduced(own.visibility(self.db), parent.visibility(self.db)) {
367 let mut issue = Issue::new(
368 IssueKind::OverriddenMethodAccess {
369 class: fqcn.to_string(),
370 method: method_name_lower.to_string(),
371 },
372 loc.clone(),
373 );
374 if let Some(snippet) = extract_snippet(own_location.as_ref(), &self.sources) {
375 issue = issue.with_snippet(snippet);
376 }
377 issues.push(issue);
378 }
379
380 let parent_return_type = parent.return_type(self.db);
387 let own_return_type = own.return_type(self.db);
388 if let (Some(child_ret), Some(parent_ret)) =
389 (own_return_type.as_ref(), parent_return_type.as_ref())
390 {
391 let parent_from_docblock = parent_ret.from_docblock;
392 let involves_named_objects = Self::type_has_named_objects(child_ret)
393 || Self::type_has_named_objects(parent_ret);
394 let involves_self_static = self.type_has_self_or_static(child_ret)
395 || self.type_has_self_or_static(parent_ret);
396
397 if !parent_from_docblock
398 && !parent_ret.is_mixed()
399 && !child_ret.is_mixed()
400 && !self.return_type_has_template(parent_ret)
401 {
402 let child_file = own_location.as_ref().map(|l| l.file.as_ref()).unwrap_or("");
403
404 let compatible = if (involves_named_objects || involves_self_static)
405 && self.type_has_only_object_atoms(child_ret)
406 && self.type_has_only_object_atoms(parent_ret)
407 {
408 crate::stmt::named_object_return_compatible(
409 child_ret, parent_ret, self.db, child_file,
410 )
411 } else if involves_named_objects || involves_self_static {
412 true } else {
414 child_ret.is_subtype_of_simple(parent_ret)
415 };
416
417 if !compatible {
418 issues.push(
419 Issue::new(
420 IssueKind::MethodSignatureMismatch {
421 class: fqcn.to_string(),
422 method: method_name_lower.to_string(),
423 detail: format!(
424 "return type '{child_ret}' is not a subtype of parent '{parent_ret}'"
425 ),
426 },
427 loc.clone(),
428 )
429 .with_snippet(method_name_lower.to_string()),
430 );
431 }
432 }
433 }
434
435 let parent_params = parent.params(self.db);
437 let own_params = own.params(self.db);
438 let parent_required = parent_params
439 .iter()
440 .filter(|p| !p.is_optional && !p.is_variadic)
441 .count();
442 let child_required = own_params
443 .iter()
444 .filter(|p| !p.is_optional && !p.is_variadic)
445 .count();
446
447 if child_required > parent_required {
448 issues.push(
449 Issue::new(
450 IssueKind::MethodSignatureMismatch {
451 class: fqcn.to_string(),
452 method: method_name_lower.to_string(),
453 detail: format!(
454 "overriding method requires {child_required} argument(s) but parent requires {parent_required}"
455 ),
456 },
457 loc.clone(),
458 )
459 .with_snippet(method_name_lower.to_string()),
460 );
461 }
462
463 let shared_len = parent_params.len().min(own_params.len());
474 for i in 0..shared_len {
475 let parent_param = &parent_params[i];
476 let child_param = &own_params[i];
477
478 let (parent_ty, child_ty) = match (&parent_param.ty, &child_param.ty) {
479 (Some(p), Some(c)) => (p, c),
480 _ => continue,
481 };
482
483 if parent_ty.is_mixed()
484 || child_ty.is_mixed()
485 || Self::type_has_named_objects(parent_ty)
486 || Self::type_has_named_objects(child_ty)
487 || self.type_has_self_or_static(parent_ty)
488 || self.type_has_self_or_static(child_ty)
489 || self.return_type_has_template(parent_ty)
490 || self.return_type_has_template(child_ty)
491 {
492 continue;
493 }
494
495 if !parent_ty.is_subtype_of_simple(child_ty) {
498 issues.push(
499 Issue::new(
500 IssueKind::MethodSignatureMismatch {
501 class: fqcn.to_string(),
502 method: method_name_lower.to_string(),
503 detail: format!(
504 "parameter ${} type '{}' is narrower than parent type '{}'",
505 child_param.name, child_ty, parent_ty
506 ),
507 },
508 loc.clone(),
509 )
510 .with_snippet(method_name_lower.to_string()),
511 );
512 break; }
514 }
515 }
516 }
517
518 fn return_type_has_template(&self, ty: &mir_types::Union) -> bool {
526 use mir_types::Atomic;
527 ty.types.iter().any(|atomic| match atomic {
528 Atomic::TTemplateParam { .. } => true,
529 Atomic::TClassString(Some(inner)) => {
530 !crate::db::type_exists_via_db(self.db, inner.as_ref())
531 }
532 Atomic::TNamedObject { fqcn, type_params } => {
533 (!fqcn.contains('\\') && !crate::db::type_exists_via_db(self.db, fqcn.as_ref()))
535 || type_params.iter().any(|tp| self.return_type_has_template(tp))
537 }
538 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
539 self.return_type_has_template(key) || self.return_type_has_template(value)
540 }
541 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
542 self.return_type_has_template(value)
543 }
544 _ => false,
545 })
546 }
547
548 fn type_has_named_objects(ty: &mir_types::Union) -> bool {
553 use mir_types::Atomic;
554 ty.types.iter().any(|a| match a {
555 Atomic::TNamedObject { .. } => true,
556 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
557 Self::type_has_named_objects(key) || Self::type_has_named_objects(value)
558 }
559 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
560 Self::type_has_named_objects(value)
561 }
562 _ => false,
563 })
564 }
565
566 fn type_has_self_or_static(&self, ty: &mir_types::Union) -> bool {
569 use mir_types::Atomic;
570 ty.types
571 .iter()
572 .any(|a| matches!(a, Atomic::TSelf { .. } | Atomic::TStaticObject { .. }))
573 }
574
575 fn type_has_only_object_atoms(&self, ty: &mir_types::Union) -> bool {
580 use mir_types::Atomic;
581 ty.types.iter().all(|a| {
582 matches!(
583 a,
584 Atomic::TNamedObject { .. }
585 | Atomic::TSelf { .. }
586 | Atomic::TStaticObject { .. }
587 | Atomic::TParent { .. }
588 | Atomic::TNull
589 | Atomic::TVoid
590 | Atomic::TNever
591 | Atomic::TClassString(_)
592 )
593 })
594 }
595
596 fn find_parent_method(
599 &self,
600 fqcn: &Arc<str>,
601 method_name_lower: &str,
602 ) -> Option<crate::db::MethodNode> {
603 let ancestors = self.ancestors(fqcn);
604 for ancestor_fqcn in &ancestors {
605 if let Some(node) = self
606 .db
607 .lookup_method_node(ancestor_fqcn.as_ref(), method_name_lower)
608 .filter(|n| n.active(self.db))
609 {
610 return Some(node);
611 }
612 }
613 None
614 }
615
616 fn check_circular_class_inheritance(&self, issues: &mut Vec<Issue>) {
621 let mut globally_done: HashSet<String> = HashSet::new();
622
623 let mut class_keys: Vec<Arc<str>> = self
624 .db
625 .active_class_node_fqcns()
626 .into_iter()
627 .filter(|fqcn| {
628 self.db
629 .lookup_class_node(fqcn.as_ref())
630 .map(|n| {
631 !n.is_interface(self.db) && !n.is_trait(self.db) && !n.is_enum(self.db)
632 })
633 .unwrap_or(false)
634 })
635 .collect();
636 class_keys.sort();
637
638 for start_fqcn in &class_keys {
639 if globally_done.contains(start_fqcn.as_ref()) {
640 continue;
641 }
642
643 let mut chain: Vec<Arc<str>> = Vec::new();
645 let mut chain_set: HashSet<String> = HashSet::new();
646 let mut current: Arc<str> = start_fqcn.clone();
647
648 loop {
649 if globally_done.contains(current.as_ref()) {
650 for node in &chain {
652 globally_done.insert(node.to_string());
653 }
654 break;
655 }
656 if !chain_set.insert(current.to_string()) {
657 let cycle_start = chain
659 .iter()
660 .position(|p| p.as_ref() == current.as_ref())
661 .unwrap_or(0);
662 let cycle_nodes = &chain[cycle_start..];
663
664 let offender = cycle_nodes
667 .iter()
668 .filter(|n| self.class_in_analyzed_files(n))
669 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
670
671 if let Some(offender) = offender {
672 let location = self
673 .db
674 .lookup_class_node(offender.as_ref())
675 .filter(|n| n.active(self.db))
676 .and_then(|n| n.location(self.db));
677 let loc = issue_location(
678 location.as_ref(),
679 offender,
680 location
681 .as_ref()
682 .and_then(|l| self.sources.get(&l.file).copied()),
683 );
684 let mut issue = Issue::new(
685 IssueKind::CircularInheritance {
686 class: offender.to_string(),
687 },
688 loc,
689 );
690 if let Some(snippet) = extract_snippet(location.as_ref(), &self.sources) {
691 issue = issue.with_snippet(snippet);
692 }
693 issues.push(issue);
694 }
695
696 for node in &chain {
697 globally_done.insert(node.to_string());
698 }
699 break;
700 }
701
702 chain.push(current.clone());
703
704 let parent = self
705 .db
706 .lookup_class_node(current.as_ref())
707 .filter(|n| n.active(self.db))
708 .and_then(|n| n.parent(self.db));
709
710 match parent {
711 Some(p) => current = p,
712 None => {
713 for node in &chain {
714 globally_done.insert(node.to_string());
715 }
716 break;
717 }
718 }
719 }
720 }
721 }
722
723 fn check_circular_interface_inheritance(&self, issues: &mut Vec<Issue>) {
728 let mut globally_done: HashSet<String> = HashSet::new();
729
730 let mut iface_keys: Vec<Arc<str>> = self
731 .db
732 .active_class_node_fqcns()
733 .into_iter()
734 .filter(|fqcn| {
735 self.db
736 .lookup_class_node(fqcn.as_ref())
737 .map(|n| n.is_interface(self.db))
738 .unwrap_or(false)
739 })
740 .collect();
741 iface_keys.sort();
742
743 for start_fqcn in &iface_keys {
744 if globally_done.contains(start_fqcn.as_ref()) {
745 continue;
746 }
747 let mut in_stack: Vec<Arc<str>> = Vec::new();
748 let mut stack_set: HashSet<String> = HashSet::new();
749 self.dfs_interface_cycle(
750 start_fqcn.clone(),
751 &mut in_stack,
752 &mut stack_set,
753 &mut globally_done,
754 issues,
755 );
756 }
757 }
758
759 fn dfs_interface_cycle(
760 &self,
761 fqcn: Arc<str>,
762 in_stack: &mut Vec<Arc<str>>,
763 stack_set: &mut HashSet<String>,
764 globally_done: &mut HashSet<String>,
765 issues: &mut Vec<Issue>,
766 ) {
767 if globally_done.contains(fqcn.as_ref()) {
768 return;
769 }
770 if stack_set.contains(fqcn.as_ref()) {
771 let cycle_start = in_stack
773 .iter()
774 .position(|p| p.as_ref() == fqcn.as_ref())
775 .unwrap_or(0);
776 let cycle_nodes = &in_stack[cycle_start..];
777
778 let offender = cycle_nodes
779 .iter()
780 .filter(|n| self.iface_in_analyzed_files(n))
781 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
782
783 if let Some(offender) = offender {
784 let location = self
785 .db
786 .lookup_class_node(offender.as_ref())
787 .filter(|n| n.active(self.db))
788 .and_then(|n| n.location(self.db));
789 let loc = issue_location(
790 location.as_ref(),
791 offender,
792 location
793 .as_ref()
794 .and_then(|l| self.sources.get(&l.file).copied()),
795 );
796 let mut issue = Issue::new(
797 IssueKind::CircularInheritance {
798 class: offender.to_string(),
799 },
800 loc,
801 );
802 if let Some(snippet) = extract_snippet(location.as_ref(), &self.sources) {
803 issue = issue.with_snippet(snippet);
804 }
805 issues.push(issue);
806 }
807 return;
808 }
809
810 stack_set.insert(fqcn.to_string());
811 in_stack.push(fqcn.clone());
812
813 let extends: Vec<Arc<str>> = self
814 .db
815 .lookup_class_node(fqcn.as_ref())
816 .filter(|n| n.active(self.db))
817 .map(|n| n.extends(self.db).to_vec())
818 .unwrap_or_default();
819
820 for parent in extends {
821 self.dfs_interface_cycle(parent, in_stack, stack_set, globally_done, issues);
822 }
823
824 in_stack.pop();
825 stack_set.remove(fqcn.as_ref());
826 globally_done.insert(fqcn.to_string());
827 }
828
829 fn class_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
830 if self.analyzed_files.is_empty() {
831 return true;
832 }
833 self.db
834 .lookup_class_node(fqcn.as_ref())
835 .filter(|n| n.active(self.db))
836 .and_then(|n| n.location(self.db))
837 .map(|loc| self.analyzed_files.contains(&loc.file))
838 .unwrap_or(false)
839 }
840
841 fn iface_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
842 self.class_in_analyzed_files(fqcn)
845 }
846}
847
848fn visibility_reduced(child_vis: Visibility, parent_vis: Visibility) -> bool {
850 matches!(
853 (parent_vis, child_vis),
854 (Visibility::Public, Visibility::Protected)
855 | (Visibility::Public, Visibility::Private)
856 | (Visibility::Protected, Visibility::Private)
857 )
858}
859
860fn issue_location(
864 storage_loc: Option<&mir_codebase::storage::Location>,
865 fqcn: &Arc<str>,
866 _source: Option<&str>,
867) -> Location {
868 match storage_loc {
869 Some(loc) => Location {
870 file: loc.file.clone(),
871 line: loc.line,
872 line_end: loc.line_end,
873 col_start: loc.col_start,
874 col_end: loc.col_end,
875 },
876 None => Location {
877 file: fqcn.clone(),
878 line: 1,
879 line_end: 1,
880 col_start: 0,
881 col_end: 0,
882 },
883 }
884}
885
886fn extract_snippet(
888 storage_loc: Option<&mir_codebase::storage::Location>,
889 sources: &HashMap<Arc<str>, &str>,
890) -> Option<String> {
891 let loc = storage_loc?;
892 let src = *sources.get(&loc.file)?;
893 let line_idx = loc.line.saturating_sub(1) as usize;
895 let line_text = src.lines().nth(line_idx)?;
896 Some(line_text.trim().to_string())
897}