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 }
108 }
109
110 if cls.is_abstract {
112 self.check_overrides(&cls, &mut issues);
114 continue;
115 }
116
117 self.check_abstract_methods_implemented(&cls, &mut issues);
119
120 self.check_interface_methods_implemented(&cls, &mut issues);
122
123 self.check_overrides(&cls, &mut issues);
125 }
126
127 self.check_circular_class_inheritance(&mut issues);
129 self.check_circular_interface_inheritance(&mut issues);
130
131 issues
132 }
133
134 fn check_abstract_methods_implemented(
139 &self,
140 cls: &mir_codebase::storage::ClassStorage,
141 issues: &mut Vec<Issue>,
142 ) {
143 let fqcn = &cls.fqcn;
144
145 for ancestor_fqcn in &cls.all_parents {
147 let abstract_methods: Vec<Arc<str>> = {
150 let Some(ancestor) = self.codebase.classes.get(ancestor_fqcn.as_ref()) else {
151 continue;
152 };
153 ancestor
154 .own_methods
155 .iter()
156 .filter(|(_, m)| m.is_abstract)
157 .map(|(name, _)| name.clone())
158 .collect()
159 };
160
161 for method_name in abstract_methods {
162 if self
164 .codebase
165 .get_method(fqcn.as_ref(), method_name.as_ref())
166 .map(|m| !m.is_abstract)
167 .unwrap_or(false)
168 {
169 continue; }
171
172 let loc = issue_location(
173 cls.location.as_ref(),
174 fqcn,
175 cls.location
176 .as_ref()
177 .and_then(|l| self.sources.get(&l.file).copied()),
178 );
179 let mut issue = Issue::new(
180 IssueKind::UnimplementedAbstractMethod {
181 class: fqcn.to_string(),
182 method: method_name.to_string(),
183 },
184 loc,
185 );
186 if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources) {
187 issue = issue.with_snippet(snippet);
188 }
189 issues.push(issue);
190 }
191 }
192 }
193
194 fn check_interface_methods_implemented(
199 &self,
200 cls: &mir_codebase::storage::ClassStorage,
201 issues: &mut Vec<Issue>,
202 ) {
203 let fqcn = &cls.fqcn;
204
205 let all_ifaces: Vec<Arc<str>> = cls
207 .all_parents
208 .iter()
209 .filter(|p| self.codebase.interfaces.contains_key(p.as_ref()))
210 .cloned()
211 .collect();
212
213 for iface_fqcn in &all_ifaces {
214 let method_names: Vec<Arc<str>> =
217 match self.codebase.interfaces.get(iface_fqcn.as_ref()) {
218 Some(iface) => iface.own_methods.keys().cloned().collect(),
219 None => continue,
220 };
221
222 for method_name in method_names {
223 let method_name_lower = method_name.to_lowercase();
227 let implemented = self
229 .codebase
230 .get_method(fqcn.as_ref(), &method_name_lower)
231 .map(|m| !m.is_abstract)
232 .unwrap_or(false);
233
234 if !implemented {
235 let loc = issue_location(
236 cls.location.as_ref(),
237 fqcn,
238 cls.location
239 .as_ref()
240 .and_then(|l| self.sources.get(&l.file).copied()),
241 );
242 let mut issue = Issue::new(
243 IssueKind::UnimplementedInterfaceMethod {
244 class: fqcn.to_string(),
245 interface: iface_fqcn.to_string(),
246 method: method_name.to_string(),
247 },
248 loc,
249 );
250 if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources) {
251 issue = issue.with_snippet(snippet);
252 }
253 issues.push(issue);
254 }
255 }
256 }
257 }
258
259 fn check_overrides(&self, cls: &mir_codebase::storage::ClassStorage, issues: &mut Vec<Issue>) {
264 let fqcn = &cls.fqcn;
265
266 for (method_name, own_method) in &cls.own_methods {
267 if method_name.as_ref() == "__construct" {
269 continue;
270 }
271
272 let parent_method = self.find_parent_method(cls, method_name.as_ref());
274
275 let parent = match parent_method {
276 Some(m) => m,
277 None => continue, };
279
280 let loc = issue_location(
281 own_method.location.as_ref(),
282 fqcn,
283 own_method
284 .location
285 .as_ref()
286 .and_then(|l| self.sources.get(&l.file).copied()),
287 );
288
289 if parent.is_final {
291 let mut issue = Issue::new(
292 IssueKind::FinalMethodOverridden {
293 class: fqcn.to_string(),
294 method: method_name.to_string(),
295 parent: parent.fqcn.to_string(),
296 },
297 loc.clone(),
298 );
299 if let Some(snippet) = extract_snippet(own_method.location.as_ref(), &self.sources)
300 {
301 issue = issue.with_snippet(snippet);
302 }
303 issues.push(issue);
304 }
305
306 if visibility_reduced(own_method.visibility, parent.visibility) {
308 let mut issue = Issue::new(
309 IssueKind::OverriddenMethodAccess {
310 class: fqcn.to_string(),
311 method: method_name.to_string(),
312 },
313 loc.clone(),
314 );
315 if let Some(snippet) = extract_snippet(own_method.location.as_ref(), &self.sources)
316 {
317 issue = issue.with_snippet(snippet);
318 }
319 issues.push(issue);
320 }
321
322 if let (Some(child_ret), Some(parent_ret)) =
329 (&own_method.return_type, &parent.return_type)
330 {
331 let parent_from_docblock = parent_ret.from_docblock;
332 let involves_named_objects = self.type_has_named_objects(child_ret)
333 || self.type_has_named_objects(parent_ret);
334 let involves_self_static = self.type_has_self_or_static(child_ret)
335 || self.type_has_self_or_static(parent_ret);
336
337 if !parent_from_docblock
338 && !involves_named_objects
339 && !involves_self_static
340 && !child_ret.is_subtype_of_simple(parent_ret)
341 && !parent_ret.is_mixed()
342 && !child_ret.is_mixed()
343 && !self.return_type_has_template(parent_ret)
344 {
345 issues.push(
346 Issue::new(
347 IssueKind::MethodSignatureMismatch {
348 class: fqcn.to_string(),
349 method: method_name.to_string(),
350 detail: format!(
351 "return type '{}' is not a subtype of parent '{}'",
352 child_ret, parent_ret
353 ),
354 },
355 loc.clone(),
356 )
357 .with_snippet(method_name.to_string()),
358 );
359 }
360 }
361
362 let parent_required = parent
364 .params
365 .iter()
366 .filter(|p| !p.is_optional && !p.is_variadic)
367 .count();
368 let child_required = own_method
369 .params
370 .iter()
371 .filter(|p| !p.is_optional && !p.is_variadic)
372 .count();
373
374 if child_required > parent_required {
375 issues.push(
376 Issue::new(
377 IssueKind::MethodSignatureMismatch {
378 class: fqcn.to_string(),
379 method: method_name.to_string(),
380 detail: format!(
381 "overriding method requires {} argument(s) but parent requires {}",
382 child_required, parent_required
383 ),
384 },
385 loc.clone(),
386 )
387 .with_snippet(method_name.to_string()),
388 );
389 }
390
391 let shared_len = parent.params.len().min(own_method.params.len());
402 for i in 0..shared_len {
403 let parent_param = &parent.params[i];
404 let child_param = &own_method.params[i];
405
406 let (parent_ty, child_ty) = match (&parent_param.ty, &child_param.ty) {
407 (Some(p), Some(c)) => (p, c),
408 _ => continue,
409 };
410
411 if parent_ty.is_mixed()
412 || child_ty.is_mixed()
413 || self.type_has_named_objects(parent_ty)
414 || self.type_has_named_objects(child_ty)
415 || self.type_has_self_or_static(parent_ty)
416 || self.type_has_self_or_static(child_ty)
417 || self.return_type_has_template(parent_ty)
418 || self.return_type_has_template(child_ty)
419 {
420 continue;
421 }
422
423 if !parent_ty.is_subtype_of_simple(child_ty) {
426 issues.push(
427 Issue::new(
428 IssueKind::MethodSignatureMismatch {
429 class: fqcn.to_string(),
430 method: method_name.to_string(),
431 detail: format!(
432 "parameter ${} type '{}' is narrower than parent type '{}'",
433 child_param.name, child_ty, parent_ty
434 ),
435 },
436 loc.clone(),
437 )
438 .with_snippet(method_name.to_string()),
439 );
440 break; }
442 }
443 }
444 }
445
446 fn return_type_has_template(&self, ty: &mir_types::Union) -> bool {
454 use mir_types::Atomic;
455 ty.types.iter().any(|atomic| match atomic {
456 Atomic::TTemplateParam { .. } => true,
457 Atomic::TClassString(Some(inner)) => !self.codebase.type_exists(inner.as_ref()),
458 Atomic::TNamedObject { fqcn, type_params } => {
459 (!fqcn.contains('\\') && !self.codebase.type_exists(fqcn.as_ref()))
461 || type_params.iter().any(|tp| self.return_type_has_template(tp))
463 }
464 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
465 self.return_type_has_template(key) || self.return_type_has_template(value)
466 }
467 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
468 self.return_type_has_template(value)
469 }
470 _ => false,
471 })
472 }
473
474 fn type_has_named_objects(&self, ty: &mir_types::Union) -> bool {
479 use mir_types::Atomic;
480 ty.types.iter().any(|a| match a {
481 Atomic::TNamedObject { .. } => true,
482 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
483 self.type_has_named_objects(key) || self.type_has_named_objects(value)
484 }
485 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
486 self.type_has_named_objects(value)
487 }
488 _ => false,
489 })
490 }
491
492 fn type_has_self_or_static(&self, ty: &mir_types::Union) -> bool {
495 use mir_types::Atomic;
496 ty.types
497 .iter()
498 .any(|a| matches!(a, Atomic::TSelf { .. } | Atomic::TStaticObject { .. }))
499 }
500
501 fn find_parent_method(
503 &self,
504 cls: &mir_codebase::storage::ClassStorage,
505 method_name: &str,
506 ) -> Option<Arc<MethodStorage>> {
507 for ancestor_fqcn in &cls.all_parents {
509 if let Some(ancestor_cls) = self.codebase.classes.get(ancestor_fqcn.as_ref()) {
510 if let Some(m) = ancestor_cls.own_methods.get(method_name) {
511 return Some(Arc::clone(m));
512 }
513 } else if let Some(iface) = self.codebase.interfaces.get(ancestor_fqcn.as_ref()) {
514 if let Some(m) = iface.own_methods.get(method_name) {
515 return Some(Arc::clone(m));
516 }
517 }
518 }
519 None
520 }
521
522 fn check_circular_class_inheritance(&self, issues: &mut Vec<Issue>) {
527 let mut globally_done: HashSet<String> = HashSet::new();
528
529 let mut class_keys: Vec<Arc<str>> = self
530 .codebase
531 .classes
532 .iter()
533 .map(|e| e.key().clone())
534 .collect();
535 class_keys.sort();
536
537 for start_fqcn in &class_keys {
538 if globally_done.contains(start_fqcn.as_ref()) {
539 continue;
540 }
541
542 let mut chain: Vec<Arc<str>> = Vec::new();
544 let mut chain_set: HashSet<String> = HashSet::new();
545 let mut current: Arc<str> = start_fqcn.clone();
546
547 loop {
548 if globally_done.contains(current.as_ref()) {
549 for node in &chain {
551 globally_done.insert(node.to_string());
552 }
553 break;
554 }
555 if !chain_set.insert(current.to_string()) {
556 let cycle_start = chain
558 .iter()
559 .position(|p| p.as_ref() == current.as_ref())
560 .unwrap_or(0);
561 let cycle_nodes = &chain[cycle_start..];
562
563 let offender = cycle_nodes
566 .iter()
567 .filter(|n| self.class_in_analyzed_files(n))
568 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
569
570 if let Some(offender) = offender {
571 let cls = self.codebase.classes.get(offender.as_ref());
572 let loc = issue_location(
573 cls.as_ref().and_then(|c| c.location.as_ref()),
574 offender,
575 cls.as_ref()
576 .and_then(|c| c.location.as_ref())
577 .and_then(|l| self.sources.get(&l.file).copied()),
578 );
579 let mut issue = Issue::new(
580 IssueKind::CircularInheritance {
581 class: offender.to_string(),
582 },
583 loc,
584 );
585 if let Some(snippet) = extract_snippet(
586 cls.as_ref().and_then(|c| c.location.as_ref()),
587 &self.sources,
588 ) {
589 issue = issue.with_snippet(snippet);
590 }
591 issues.push(issue);
592 }
593
594 for node in &chain {
595 globally_done.insert(node.to_string());
596 }
597 break;
598 }
599
600 chain.push(current.clone());
601
602 let parent = self
603 .codebase
604 .classes
605 .get(current.as_ref())
606 .and_then(|c| c.parent.clone());
607
608 match parent {
609 Some(p) => current = p,
610 None => {
611 for node in &chain {
612 globally_done.insert(node.to_string());
613 }
614 break;
615 }
616 }
617 }
618 }
619 }
620
621 fn check_circular_interface_inheritance(&self, issues: &mut Vec<Issue>) {
626 let mut globally_done: HashSet<String> = HashSet::new();
627
628 let mut iface_keys: Vec<Arc<str>> = self
629 .codebase
630 .interfaces
631 .iter()
632 .map(|e| e.key().clone())
633 .collect();
634 iface_keys.sort();
635
636 for start_fqcn in &iface_keys {
637 if globally_done.contains(start_fqcn.as_ref()) {
638 continue;
639 }
640 let mut in_stack: Vec<Arc<str>> = Vec::new();
641 let mut stack_set: HashSet<String> = HashSet::new();
642 self.dfs_interface_cycle(
643 start_fqcn.clone(),
644 &mut in_stack,
645 &mut stack_set,
646 &mut globally_done,
647 issues,
648 );
649 }
650 }
651
652 fn dfs_interface_cycle(
653 &self,
654 fqcn: Arc<str>,
655 in_stack: &mut Vec<Arc<str>>,
656 stack_set: &mut HashSet<String>,
657 globally_done: &mut HashSet<String>,
658 issues: &mut Vec<Issue>,
659 ) {
660 if globally_done.contains(fqcn.as_ref()) {
661 return;
662 }
663 if stack_set.contains(fqcn.as_ref()) {
664 let cycle_start = in_stack
666 .iter()
667 .position(|p| p.as_ref() == fqcn.as_ref())
668 .unwrap_or(0);
669 let cycle_nodes = &in_stack[cycle_start..];
670
671 let offender = cycle_nodes
672 .iter()
673 .filter(|n| self.iface_in_analyzed_files(n))
674 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
675
676 if let Some(offender) = offender {
677 let iface = self.codebase.interfaces.get(offender.as_ref());
678 let loc = issue_location(
679 iface.as_ref().and_then(|i| i.location.as_ref()),
680 offender,
681 iface
682 .as_ref()
683 .and_then(|i| i.location.as_ref())
684 .and_then(|l| self.sources.get(&l.file).copied()),
685 );
686 let mut issue = Issue::new(
687 IssueKind::CircularInheritance {
688 class: offender.to_string(),
689 },
690 loc,
691 );
692 if let Some(snippet) = extract_snippet(
693 iface.as_ref().and_then(|i| i.location.as_ref()),
694 &self.sources,
695 ) {
696 issue = issue.with_snippet(snippet);
697 }
698 issues.push(issue);
699 }
700 return;
701 }
702
703 stack_set.insert(fqcn.to_string());
704 in_stack.push(fqcn.clone());
705
706 let extends = self
707 .codebase
708 .interfaces
709 .get(fqcn.as_ref())
710 .map(|i| i.extends.clone())
711 .unwrap_or_default();
712
713 for parent in extends {
714 self.dfs_interface_cycle(parent, in_stack, stack_set, globally_done, issues);
715 }
716
717 in_stack.pop();
718 stack_set.remove(fqcn.as_ref());
719 globally_done.insert(fqcn.to_string());
720 }
721
722 fn class_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
723 if self.analyzed_files.is_empty() {
724 return true;
725 }
726 self.codebase
727 .classes
728 .get(fqcn.as_ref())
729 .map(|c| {
730 c.location
731 .as_ref()
732 .map(|loc| self.analyzed_files.contains(&loc.file))
733 .unwrap_or(false)
734 })
735 .unwrap_or(false)
736 }
737
738 fn iface_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
739 if self.analyzed_files.is_empty() {
740 return true;
741 }
742 self.codebase
743 .interfaces
744 .get(fqcn.as_ref())
745 .map(|i| {
746 i.location
747 .as_ref()
748 .map(|loc| self.analyzed_files.contains(&loc.file))
749 .unwrap_or(false)
750 })
751 .unwrap_or(false)
752 }
753}
754
755fn visibility_reduced(child_vis: Visibility, parent_vis: Visibility) -> bool {
757 matches!(
760 (parent_vis, child_vis),
761 (Visibility::Public, Visibility::Protected)
762 | (Visibility::Public, Visibility::Private)
763 | (Visibility::Protected, Visibility::Private)
764 )
765}
766
767fn issue_location(
771 storage_loc: Option<&mir_codebase::storage::Location>,
772 fqcn: &Arc<str>,
773 source: Option<&str>,
774) -> Location {
775 match storage_loc {
776 Some(loc) => {
777 let col_end = if let Some(src) = source {
779 if loc.end > loc.start {
780 let end_offset = (loc.end as usize).min(src.len());
781 let line_start = src[..end_offset].rfind('\n').map(|p| p + 1).unwrap_or(0);
783 let col_end = src[line_start..end_offset].chars().count() as u16;
785
786 let col_start_offset = (loc.start as usize).min(src.len());
788 let col_start_line = src[..col_start_offset]
789 .rfind('\n')
790 .map(|p| p + 1)
791 .unwrap_or(0);
792 let col_start = src[col_start_line..col_start_offset].chars().count() as u16;
793
794 col_end.max(col_start + 1)
795 } else {
796 let col_start_offset = (loc.start as usize).min(src.len());
798 let col_start_line = src[..col_start_offset]
799 .rfind('\n')
800 .map(|p| p + 1)
801 .unwrap_or(0);
802 src[col_start_line..col_start_offset].chars().count() as u16 + 1
803 }
804 } else {
805 loc.col + 1
806 };
807
808 let col_start = if let Some(src) = source {
810 let col_start_offset = (loc.start as usize).min(src.len());
811 let col_start_line = src[..col_start_offset]
812 .rfind('\n')
813 .map(|p| p + 1)
814 .unwrap_or(0);
815 src[col_start_line..col_start_offset].chars().count() as u16
816 } else {
817 loc.col
818 };
819
820 Location {
821 file: loc.file.clone(),
822 line: loc.line,
823 col_start,
824 col_end,
825 }
826 }
827 None => Location {
828 file: fqcn.clone(),
829 line: 1,
830 col_start: 0,
831 col_end: 0,
832 },
833 }
834}
835
836fn extract_snippet(
838 storage_loc: Option<&mir_codebase::storage::Location>,
839 sources: &HashMap<Arc<str>, &str>,
840) -> Option<String> {
841 let loc = storage_loc?;
842 let src = *sources.get(&loc.file)?;
843 let start = loc.start as usize;
844 let end = loc.end as usize;
845 if start >= src.len() {
846 return None;
847 }
848 let end = end.min(src.len());
849 let span_text = &src[start..end];
850 let first_line = span_text.lines().next().unwrap_or(span_text);
852 Some(first_line.trim().to_string())
853}