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 '{}' is not a subtype of parent '{}'",
373 child_ret, parent_ret
374 ),
375 },
376 loc.clone(),
377 )
378 .with_snippet(method_name.to_string()),
379 );
380 }
381 }
382
383 let parent_required = parent
385 .params
386 .iter()
387 .filter(|p| !p.is_optional && !p.is_variadic)
388 .count();
389 let child_required = own_method
390 .params
391 .iter()
392 .filter(|p| !p.is_optional && !p.is_variadic)
393 .count();
394
395 if child_required > parent_required {
396 issues.push(
397 Issue::new(
398 IssueKind::MethodSignatureMismatch {
399 class: fqcn.to_string(),
400 method: method_name.to_string(),
401 detail: format!(
402 "overriding method requires {} argument(s) but parent requires {}",
403 child_required, parent_required
404 ),
405 },
406 loc.clone(),
407 )
408 .with_snippet(method_name.to_string()),
409 );
410 }
411
412 let shared_len = parent.params.len().min(own_method.params.len());
423 for i in 0..shared_len {
424 let parent_param = &parent.params[i];
425 let child_param = &own_method.params[i];
426
427 let (parent_ty, child_ty) = match (&parent_param.ty, &child_param.ty) {
428 (Some(p), Some(c)) => (p, c),
429 _ => continue,
430 };
431
432 if parent_ty.is_mixed()
433 || child_ty.is_mixed()
434 || self.type_has_named_objects(parent_ty)
435 || self.type_has_named_objects(child_ty)
436 || self.type_has_self_or_static(parent_ty)
437 || self.type_has_self_or_static(child_ty)
438 || self.return_type_has_template(parent_ty)
439 || self.return_type_has_template(child_ty)
440 {
441 continue;
442 }
443
444 if !parent_ty.is_subtype_of_simple(child_ty) {
447 issues.push(
448 Issue::new(
449 IssueKind::MethodSignatureMismatch {
450 class: fqcn.to_string(),
451 method: method_name.to_string(),
452 detail: format!(
453 "parameter ${} type '{}' is narrower than parent type '{}'",
454 child_param.name, child_ty, parent_ty
455 ),
456 },
457 loc.clone(),
458 )
459 .with_snippet(method_name.to_string()),
460 );
461 break; }
463 }
464 }
465 }
466
467 fn return_type_has_template(&self, ty: &mir_types::Union) -> bool {
475 use mir_types::Atomic;
476 ty.types.iter().any(|atomic| match atomic {
477 Atomic::TTemplateParam { .. } => true,
478 Atomic::TClassString(Some(inner)) => !self.codebase.type_exists(inner.as_ref()),
479 Atomic::TNamedObject { fqcn, type_params } => {
480 (!fqcn.contains('\\') && !self.codebase.type_exists(fqcn.as_ref()))
482 || type_params.iter().any(|tp| self.return_type_has_template(tp))
484 }
485 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
486 self.return_type_has_template(key) || self.return_type_has_template(value)
487 }
488 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
489 self.return_type_has_template(value)
490 }
491 _ => false,
492 })
493 }
494
495 fn type_has_named_objects(&self, ty: &mir_types::Union) -> bool {
500 use mir_types::Atomic;
501 ty.types.iter().any(|a| match a {
502 Atomic::TNamedObject { .. } => true,
503 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
504 self.type_has_named_objects(key) || self.type_has_named_objects(value)
505 }
506 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
507 self.type_has_named_objects(value)
508 }
509 _ => false,
510 })
511 }
512
513 fn type_has_self_or_static(&self, ty: &mir_types::Union) -> bool {
516 use mir_types::Atomic;
517 ty.types
518 .iter()
519 .any(|a| matches!(a, Atomic::TSelf { .. } | Atomic::TStaticObject { .. }))
520 }
521
522 fn find_parent_method(
524 &self,
525 cls: &mir_codebase::storage::ClassStorage,
526 method_name: &str,
527 ) -> Option<Arc<MethodStorage>> {
528 for ancestor_fqcn in &cls.all_parents {
530 if let Some(ancestor_cls) = self.codebase.classes.get(ancestor_fqcn.as_ref()) {
531 if let Some(m) = ancestor_cls.own_methods.get(method_name) {
532 return Some(Arc::clone(m));
533 }
534 } else if let Some(iface) = self.codebase.interfaces.get(ancestor_fqcn.as_ref()) {
535 if let Some(m) = iface.own_methods.get(method_name) {
536 return Some(Arc::clone(m));
537 }
538 }
539 }
540 None
541 }
542
543 fn check_circular_class_inheritance(&self, issues: &mut Vec<Issue>) {
548 let mut globally_done: HashSet<String> = HashSet::new();
549
550 let mut class_keys: Vec<Arc<str>> = self
551 .codebase
552 .classes
553 .iter()
554 .map(|e| e.key().clone())
555 .collect();
556 class_keys.sort();
557
558 for start_fqcn in &class_keys {
559 if globally_done.contains(start_fqcn.as_ref()) {
560 continue;
561 }
562
563 let mut chain: Vec<Arc<str>> = Vec::new();
565 let mut chain_set: HashSet<String> = HashSet::new();
566 let mut current: Arc<str> = start_fqcn.clone();
567
568 loop {
569 if globally_done.contains(current.as_ref()) {
570 for node in &chain {
572 globally_done.insert(node.to_string());
573 }
574 break;
575 }
576 if !chain_set.insert(current.to_string()) {
577 let cycle_start = chain
579 .iter()
580 .position(|p| p.as_ref() == current.as_ref())
581 .unwrap_or(0);
582 let cycle_nodes = &chain[cycle_start..];
583
584 let offender = cycle_nodes
587 .iter()
588 .filter(|n| self.class_in_analyzed_files(n))
589 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
590
591 if let Some(offender) = offender {
592 let cls = self.codebase.classes.get(offender.as_ref());
593 let loc = issue_location(
594 cls.as_ref().and_then(|c| c.location.as_ref()),
595 offender,
596 cls.as_ref()
597 .and_then(|c| c.location.as_ref())
598 .and_then(|l| self.sources.get(&l.file).copied()),
599 );
600 let mut issue = Issue::new(
601 IssueKind::CircularInheritance {
602 class: offender.to_string(),
603 },
604 loc,
605 );
606 if let Some(snippet) = extract_snippet(
607 cls.as_ref().and_then(|c| c.location.as_ref()),
608 &self.sources,
609 ) {
610 issue = issue.with_snippet(snippet);
611 }
612 issues.push(issue);
613 }
614
615 for node in &chain {
616 globally_done.insert(node.to_string());
617 }
618 break;
619 }
620
621 chain.push(current.clone());
622
623 let parent = self
624 .codebase
625 .classes
626 .get(current.as_ref())
627 .and_then(|c| c.parent.clone());
628
629 match parent {
630 Some(p) => current = p,
631 None => {
632 for node in &chain {
633 globally_done.insert(node.to_string());
634 }
635 break;
636 }
637 }
638 }
639 }
640 }
641
642 fn check_circular_interface_inheritance(&self, issues: &mut Vec<Issue>) {
647 let mut globally_done: HashSet<String> = HashSet::new();
648
649 let mut iface_keys: Vec<Arc<str>> = self
650 .codebase
651 .interfaces
652 .iter()
653 .map(|e| e.key().clone())
654 .collect();
655 iface_keys.sort();
656
657 for start_fqcn in &iface_keys {
658 if globally_done.contains(start_fqcn.as_ref()) {
659 continue;
660 }
661 let mut in_stack: Vec<Arc<str>> = Vec::new();
662 let mut stack_set: HashSet<String> = HashSet::new();
663 self.dfs_interface_cycle(
664 start_fqcn.clone(),
665 &mut in_stack,
666 &mut stack_set,
667 &mut globally_done,
668 issues,
669 );
670 }
671 }
672
673 fn dfs_interface_cycle(
674 &self,
675 fqcn: Arc<str>,
676 in_stack: &mut Vec<Arc<str>>,
677 stack_set: &mut HashSet<String>,
678 globally_done: &mut HashSet<String>,
679 issues: &mut Vec<Issue>,
680 ) {
681 if globally_done.contains(fqcn.as_ref()) {
682 return;
683 }
684 if stack_set.contains(fqcn.as_ref()) {
685 let cycle_start = in_stack
687 .iter()
688 .position(|p| p.as_ref() == fqcn.as_ref())
689 .unwrap_or(0);
690 let cycle_nodes = &in_stack[cycle_start..];
691
692 let offender = cycle_nodes
693 .iter()
694 .filter(|n| self.iface_in_analyzed_files(n))
695 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
696
697 if let Some(offender) = offender {
698 let iface = self.codebase.interfaces.get(offender.as_ref());
699 let loc = issue_location(
700 iface.as_ref().and_then(|i| i.location.as_ref()),
701 offender,
702 iface
703 .as_ref()
704 .and_then(|i| i.location.as_ref())
705 .and_then(|l| self.sources.get(&l.file).copied()),
706 );
707 let mut issue = Issue::new(
708 IssueKind::CircularInheritance {
709 class: offender.to_string(),
710 },
711 loc,
712 );
713 if let Some(snippet) = extract_snippet(
714 iface.as_ref().and_then(|i| i.location.as_ref()),
715 &self.sources,
716 ) {
717 issue = issue.with_snippet(snippet);
718 }
719 issues.push(issue);
720 }
721 return;
722 }
723
724 stack_set.insert(fqcn.to_string());
725 in_stack.push(fqcn.clone());
726
727 let extends = self
728 .codebase
729 .interfaces
730 .get(fqcn.as_ref())
731 .map(|i| i.extends.clone())
732 .unwrap_or_default();
733
734 for parent in extends {
735 self.dfs_interface_cycle(parent, in_stack, stack_set, globally_done, issues);
736 }
737
738 in_stack.pop();
739 stack_set.remove(fqcn.as_ref());
740 globally_done.insert(fqcn.to_string());
741 }
742
743 fn class_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
744 if self.analyzed_files.is_empty() {
745 return true;
746 }
747 self.codebase
748 .classes
749 .get(fqcn.as_ref())
750 .map(|c| {
751 c.location
752 .as_ref()
753 .map(|loc| self.analyzed_files.contains(&loc.file))
754 .unwrap_or(false)
755 })
756 .unwrap_or(false)
757 }
758
759 fn iface_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
760 if self.analyzed_files.is_empty() {
761 return true;
762 }
763 self.codebase
764 .interfaces
765 .get(fqcn.as_ref())
766 .map(|i| {
767 i.location
768 .as_ref()
769 .map(|loc| self.analyzed_files.contains(&loc.file))
770 .unwrap_or(false)
771 })
772 .unwrap_or(false)
773 }
774}
775
776fn visibility_reduced(child_vis: Visibility, parent_vis: Visibility) -> bool {
778 matches!(
781 (parent_vis, child_vis),
782 (Visibility::Public, Visibility::Protected)
783 | (Visibility::Public, Visibility::Private)
784 | (Visibility::Protected, Visibility::Private)
785 )
786}
787
788fn issue_location(
792 storage_loc: Option<&mir_codebase::storage::Location>,
793 fqcn: &Arc<str>,
794 source: Option<&str>,
795) -> Location {
796 match storage_loc {
797 Some(loc) => {
798 let col_end = if let Some(src) = source {
800 if loc.end > loc.start {
801 let end_offset = (loc.end as usize).min(src.len());
802 let line_start = src[..end_offset].rfind('\n').map(|p| p + 1).unwrap_or(0);
804 let col_end = src[line_start..end_offset].chars().count() as u16;
806
807 let col_start_offset = (loc.start as usize).min(src.len());
809 let col_start_line = src[..col_start_offset]
810 .rfind('\n')
811 .map(|p| p + 1)
812 .unwrap_or(0);
813 let col_start = src[col_start_line..col_start_offset].chars().count() as u16;
814
815 col_end.max(col_start + 1)
816 } else {
817 let col_start_offset = (loc.start as usize).min(src.len());
819 let col_start_line = src[..col_start_offset]
820 .rfind('\n')
821 .map(|p| p + 1)
822 .unwrap_or(0);
823 src[col_start_line..col_start_offset].chars().count() as u16 + 1
824 }
825 } else {
826 loc.col + 1
827 };
828
829 let col_start = if let Some(src) = source {
831 let col_start_offset = (loc.start as usize).min(src.len());
832 let col_start_line = src[..col_start_offset]
833 .rfind('\n')
834 .map(|p| p + 1)
835 .unwrap_or(0);
836 src[col_start_line..col_start_offset].chars().count() as u16
837 } else {
838 loc.col
839 };
840
841 Location {
842 file: loc.file.clone(),
843 line: loc.line,
844 col_start,
845 col_end,
846 }
847 }
848 None => Location {
849 file: fqcn.clone(),
850 line: 1,
851 col_start: 0,
852 col_end: 0,
853 },
854 }
855}
856
857fn extract_snippet(
859 storage_loc: Option<&mir_codebase::storage::Location>,
860 sources: &HashMap<Arc<str>, &str>,
861) -> Option<String> {
862 let loc = storage_loc?;
863 let src = *sources.get(&loc.file)?;
864 let start = loc.start as usize;
865 let end = loc.end as usize;
866 if start >= src.len() {
867 return None;
868 }
869 let end = end.min(src.len());
870 let span_text = &src[start..end];
871 let first_line = span_text.lines().next().unwrap_or(span_text);
873 Some(first_line.trim().to_string())
874}