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 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 if cls.is_abstract {
132 self.check_overrides(&cls, &mut issues);
134 continue;
135 }
136
137 self.check_abstract_methods_implemented(&cls, &mut issues);
139
140 self.check_interface_methods_implemented(&cls, &mut issues);
142
143 self.check_overrides(&cls, &mut issues);
145 }
146
147 self.check_circular_class_inheritance(&mut issues);
149 self.check_circular_interface_inheritance(&mut issues);
150
151 issues
152 }
153
154 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 for ancestor_fqcn in &cls.all_parents {
167 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 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; }
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 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 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 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 let method_name_lower = method_name.to_lowercase();
247 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 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 if method_name.as_ref() == "__construct" {
289 continue;
290 }
291
292 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, };
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 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 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 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 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 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 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; }
462 }
463 }
464 }
465
466 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 (!fqcn.contains('\\') && !self.codebase.type_exists(fqcn.as_ref()))
481 || 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 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 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 fn find_parent_method(
523 &self,
524 cls: &mir_codebase::storage::ClassStorage,
525 method_name: &str,
526 ) -> Option<Arc<MethodStorage>> {
527 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 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 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 for node in &chain {
571 globally_done.insert(node.to_string());
572 }
573 break;
574 }
575 if !chain_set.insert(current.to_string()) {
576 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 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 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 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
775fn visibility_reduced(child_vis: Visibility, parent_vis: Visibility) -> bool {
777 matches!(
780 (parent_vis, child_vis),
781 (Visibility::Public, Visibility::Protected)
782 | (Visibility::Public, Visibility::Private)
783 | (Visibility::Protected, Visibility::Private)
784 )
785}
786
787fn 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 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 let line_start = src[..end_offset].rfind('\n').map(|p| p + 1).unwrap_or(0);
803 let col_end = src[line_start..end_offset].chars().count() as u16;
805
806 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 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 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
856fn 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 let first_line = span_text.lines().next().unwrap_or(span_text);
872 Some(first_line.trim().to_string())
873}