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 ancestor = match self.codebase.classes.get(ancestor_fqcn.as_ref()) {
148 Some(a) => a,
149 None => continue,
150 };
151
152 for (method_name, method) in &ancestor.own_methods {
153 if !method.is_abstract {
154 continue;
155 }
156
157 if cls
159 .get_method(method_name.as_ref())
160 .map(|m| !m.is_abstract)
161 .unwrap_or(false)
162 {
163 continue; }
165
166 let loc = issue_location(
167 cls.location.as_ref(),
168 fqcn,
169 cls.location
170 .as_ref()
171 .and_then(|l| self.sources.get(&l.file).copied()),
172 );
173 let mut issue = Issue::new(
174 IssueKind::UnimplementedAbstractMethod {
175 class: fqcn.to_string(),
176 method: method_name.to_string(),
177 },
178 loc,
179 );
180 if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources) {
181 issue = issue.with_snippet(snippet);
182 }
183 issues.push(issue);
184 }
185 }
186 }
187
188 fn check_interface_methods_implemented(
193 &self,
194 cls: &mir_codebase::storage::ClassStorage,
195 issues: &mut Vec<Issue>,
196 ) {
197 let fqcn = &cls.fqcn;
198
199 let all_ifaces: Vec<Arc<str>> = cls
201 .all_parents
202 .iter()
203 .filter(|p| self.codebase.interfaces.contains_key(p.as_ref()))
204 .cloned()
205 .collect();
206
207 for iface_fqcn in &all_ifaces {
208 let iface = match self.codebase.interfaces.get(iface_fqcn.as_ref()) {
209 Some(i) => i,
210 None => continue,
211 };
212
213 for (method_name, _method) in &iface.own_methods {
214 let implemented = cls
216 .get_method(method_name.as_ref())
217 .map(|m| !m.is_abstract)
218 .unwrap_or(false);
219
220 if !implemented {
221 let loc = issue_location(
222 cls.location.as_ref(),
223 fqcn,
224 cls.location
225 .as_ref()
226 .and_then(|l| self.sources.get(&l.file).copied()),
227 );
228 let mut issue = Issue::new(
229 IssueKind::UnimplementedInterfaceMethod {
230 class: fqcn.to_string(),
231 interface: iface_fqcn.to_string(),
232 method: method_name.to_string(),
233 },
234 loc,
235 );
236 if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources) {
237 issue = issue.with_snippet(snippet);
238 }
239 issues.push(issue);
240 }
241 }
242 }
243 }
244
245 fn check_overrides(&self, cls: &mir_codebase::storage::ClassStorage, issues: &mut Vec<Issue>) {
250 let fqcn = &cls.fqcn;
251
252 for (method_name, own_method) in &cls.own_methods {
253 if method_name.as_ref() == "__construct" {
255 continue;
256 }
257
258 let parent_method = self.find_parent_method(cls, method_name.as_ref());
260
261 let parent = match parent_method {
262 Some(m) => m,
263 None => continue, };
265
266 let loc = issue_location(
267 own_method.location.as_ref(),
268 fqcn,
269 own_method
270 .location
271 .as_ref()
272 .and_then(|l| self.sources.get(&l.file).copied()),
273 );
274
275 if parent.is_final {
277 let mut issue = Issue::new(
278 IssueKind::FinalMethodOverridden {
279 class: fqcn.to_string(),
280 method: method_name.to_string(),
281 parent: parent.fqcn.to_string(),
282 },
283 loc.clone(),
284 );
285 if let Some(snippet) = extract_snippet(own_method.location.as_ref(), &self.sources)
286 {
287 issue = issue.with_snippet(snippet);
288 }
289 issues.push(issue);
290 }
291
292 if visibility_reduced(own_method.visibility, parent.visibility) {
294 let mut issue = Issue::new(
295 IssueKind::OverriddenMethodAccess {
296 class: fqcn.to_string(),
297 method: method_name.to_string(),
298 },
299 loc.clone(),
300 );
301 if let Some(snippet) = extract_snippet(own_method.location.as_ref(), &self.sources)
302 {
303 issue = issue.with_snippet(snippet);
304 }
305 issues.push(issue);
306 }
307
308 if let (Some(child_ret), Some(parent_ret)) =
315 (&own_method.return_type, &parent.return_type)
316 {
317 let parent_from_docblock = parent_ret.from_docblock;
318 let involves_named_objects = self.type_has_named_objects(child_ret)
319 || self.type_has_named_objects(parent_ret);
320 let involves_self_static = self.type_has_self_or_static(child_ret)
321 || self.type_has_self_or_static(parent_ret);
322
323 if !parent_from_docblock
324 && !involves_named_objects
325 && !involves_self_static
326 && !child_ret.is_subtype_of_simple(parent_ret)
327 && !parent_ret.is_mixed()
328 && !child_ret.is_mixed()
329 && !self.return_type_has_template(parent_ret)
330 {
331 issues.push(
332 Issue::new(
333 IssueKind::MethodSignatureMismatch {
334 class: fqcn.to_string(),
335 method: method_name.to_string(),
336 detail: format!(
337 "return type '{}' is not a subtype of parent '{}'",
338 child_ret, parent_ret
339 ),
340 },
341 loc.clone(),
342 )
343 .with_snippet(method_name.to_string()),
344 );
345 }
346 }
347
348 let parent_required = parent
350 .params
351 .iter()
352 .filter(|p| !p.is_optional && !p.is_variadic)
353 .count();
354 let child_required = own_method
355 .params
356 .iter()
357 .filter(|p| !p.is_optional && !p.is_variadic)
358 .count();
359
360 if child_required > parent_required {
361 issues.push(
362 Issue::new(
363 IssueKind::MethodSignatureMismatch {
364 class: fqcn.to_string(),
365 method: method_name.to_string(),
366 detail: format!(
367 "overriding method requires {} argument(s) but parent requires {}",
368 child_required, parent_required
369 ),
370 },
371 loc.clone(),
372 )
373 .with_snippet(method_name.to_string()),
374 );
375 }
376
377 let shared_len = parent.params.len().min(own_method.params.len());
388 for i in 0..shared_len {
389 let parent_param = &parent.params[i];
390 let child_param = &own_method.params[i];
391
392 let (parent_ty, child_ty) = match (&parent_param.ty, &child_param.ty) {
393 (Some(p), Some(c)) => (p, c),
394 _ => continue,
395 };
396
397 if parent_ty.is_mixed()
398 || child_ty.is_mixed()
399 || self.type_has_named_objects(parent_ty)
400 || self.type_has_named_objects(child_ty)
401 || self.type_has_self_or_static(parent_ty)
402 || self.type_has_self_or_static(child_ty)
403 || self.return_type_has_template(parent_ty)
404 || self.return_type_has_template(child_ty)
405 {
406 continue;
407 }
408
409 if !parent_ty.is_subtype_of_simple(child_ty) {
412 issues.push(
413 Issue::new(
414 IssueKind::MethodSignatureMismatch {
415 class: fqcn.to_string(),
416 method: method_name.to_string(),
417 detail: format!(
418 "parameter ${} type '{}' is narrower than parent type '{}'",
419 child_param.name, child_ty, parent_ty
420 ),
421 },
422 loc.clone(),
423 )
424 .with_snippet(method_name.to_string()),
425 );
426 break; }
428 }
429 }
430 }
431
432 fn return_type_has_template(&self, ty: &mir_types::Union) -> bool {
440 use mir_types::Atomic;
441 ty.types.iter().any(|atomic| match atomic {
442 Atomic::TTemplateParam { .. } => true,
443 Atomic::TClassString(Some(inner)) => !self.codebase.type_exists(inner.as_ref()),
444 Atomic::TNamedObject { fqcn, type_params } => {
445 (!fqcn.contains('\\') && !self.codebase.type_exists(fqcn.as_ref()))
447 || type_params.iter().any(|tp| self.return_type_has_template(tp))
449 }
450 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
451 self.return_type_has_template(key) || self.return_type_has_template(value)
452 }
453 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
454 self.return_type_has_template(value)
455 }
456 _ => false,
457 })
458 }
459
460 fn type_has_named_objects(&self, ty: &mir_types::Union) -> bool {
465 use mir_types::Atomic;
466 ty.types.iter().any(|a| match a {
467 Atomic::TNamedObject { .. } => true,
468 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
469 self.type_has_named_objects(key) || self.type_has_named_objects(value)
470 }
471 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
472 self.type_has_named_objects(value)
473 }
474 _ => false,
475 })
476 }
477
478 fn type_has_self_or_static(&self, ty: &mir_types::Union) -> bool {
481 use mir_types::Atomic;
482 ty.types
483 .iter()
484 .any(|a| matches!(a, Atomic::TSelf { .. } | Atomic::TStaticObject { .. }))
485 }
486
487 fn find_parent_method(
489 &self,
490 cls: &mir_codebase::storage::ClassStorage,
491 method_name: &str,
492 ) -> Option<MethodStorage> {
493 for ancestor_fqcn in &cls.all_parents {
495 if let Some(ancestor_cls) = self.codebase.classes.get(ancestor_fqcn.as_ref()) {
496 if let Some(m) = ancestor_cls.own_methods.get(method_name) {
497 return Some(m.clone());
498 }
499 } else if let Some(iface) = self.codebase.interfaces.get(ancestor_fqcn.as_ref()) {
500 if let Some(m) = iface.own_methods.get(method_name) {
501 return Some(m.clone());
502 }
503 }
504 }
505 None
506 }
507
508 fn check_circular_class_inheritance(&self, issues: &mut Vec<Issue>) {
513 let mut globally_done: HashSet<String> = HashSet::new();
514
515 let mut class_keys: Vec<Arc<str>> = self
516 .codebase
517 .classes
518 .iter()
519 .map(|e| e.key().clone())
520 .collect();
521 class_keys.sort();
522
523 for start_fqcn in &class_keys {
524 if globally_done.contains(start_fqcn.as_ref()) {
525 continue;
526 }
527
528 let mut chain: Vec<Arc<str>> = Vec::new();
530 let mut chain_set: HashSet<String> = HashSet::new();
531 let mut current: Arc<str> = start_fqcn.clone();
532
533 loop {
534 if globally_done.contains(current.as_ref()) {
535 for node in &chain {
537 globally_done.insert(node.to_string());
538 }
539 break;
540 }
541 if !chain_set.insert(current.to_string()) {
542 let cycle_start = chain
544 .iter()
545 .position(|p| p.as_ref() == current.as_ref())
546 .unwrap_or(0);
547 let cycle_nodes = &chain[cycle_start..];
548
549 let offender = cycle_nodes
552 .iter()
553 .filter(|n| self.class_in_analyzed_files(n))
554 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
555
556 if let Some(offender) = offender {
557 let cls = self.codebase.classes.get(offender.as_ref());
558 let loc = issue_location(
559 cls.as_ref().and_then(|c| c.location.as_ref()),
560 offender,
561 cls.as_ref()
562 .and_then(|c| c.location.as_ref())
563 .and_then(|l| self.sources.get(&l.file).copied()),
564 );
565 let mut issue = Issue::new(
566 IssueKind::CircularInheritance {
567 class: offender.to_string(),
568 },
569 loc,
570 );
571 if let Some(snippet) = extract_snippet(
572 cls.as_ref().and_then(|c| c.location.as_ref()),
573 &self.sources,
574 ) {
575 issue = issue.with_snippet(snippet);
576 }
577 issues.push(issue);
578 }
579
580 for node in &chain {
581 globally_done.insert(node.to_string());
582 }
583 break;
584 }
585
586 chain.push(current.clone());
587
588 let parent = self
589 .codebase
590 .classes
591 .get(current.as_ref())
592 .and_then(|c| c.parent.clone());
593
594 match parent {
595 Some(p) => current = p,
596 None => {
597 for node in &chain {
598 globally_done.insert(node.to_string());
599 }
600 break;
601 }
602 }
603 }
604 }
605 }
606
607 fn check_circular_interface_inheritance(&self, issues: &mut Vec<Issue>) {
612 let mut globally_done: HashSet<String> = HashSet::new();
613
614 let mut iface_keys: Vec<Arc<str>> = self
615 .codebase
616 .interfaces
617 .iter()
618 .map(|e| e.key().clone())
619 .collect();
620 iface_keys.sort();
621
622 for start_fqcn in &iface_keys {
623 if globally_done.contains(start_fqcn.as_ref()) {
624 continue;
625 }
626 let mut in_stack: Vec<Arc<str>> = Vec::new();
627 let mut stack_set: HashSet<String> = HashSet::new();
628 self.dfs_interface_cycle(
629 start_fqcn.clone(),
630 &mut in_stack,
631 &mut stack_set,
632 &mut globally_done,
633 issues,
634 );
635 }
636 }
637
638 fn dfs_interface_cycle(
639 &self,
640 fqcn: Arc<str>,
641 in_stack: &mut Vec<Arc<str>>,
642 stack_set: &mut HashSet<String>,
643 globally_done: &mut HashSet<String>,
644 issues: &mut Vec<Issue>,
645 ) {
646 if globally_done.contains(fqcn.as_ref()) {
647 return;
648 }
649 if stack_set.contains(fqcn.as_ref()) {
650 let cycle_start = in_stack
652 .iter()
653 .position(|p| p.as_ref() == fqcn.as_ref())
654 .unwrap_or(0);
655 let cycle_nodes = &in_stack[cycle_start..];
656
657 let offender = cycle_nodes
658 .iter()
659 .filter(|n| self.iface_in_analyzed_files(n))
660 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
661
662 if let Some(offender) = offender {
663 let iface = self.codebase.interfaces.get(offender.as_ref());
664 let loc = issue_location(
665 iface.as_ref().and_then(|i| i.location.as_ref()),
666 offender,
667 iface
668 .as_ref()
669 .and_then(|i| i.location.as_ref())
670 .and_then(|l| self.sources.get(&l.file).copied()),
671 );
672 let mut issue = Issue::new(
673 IssueKind::CircularInheritance {
674 class: offender.to_string(),
675 },
676 loc,
677 );
678 if let Some(snippet) = extract_snippet(
679 iface.as_ref().and_then(|i| i.location.as_ref()),
680 &self.sources,
681 ) {
682 issue = issue.with_snippet(snippet);
683 }
684 issues.push(issue);
685 }
686 return;
687 }
688
689 stack_set.insert(fqcn.to_string());
690 in_stack.push(fqcn.clone());
691
692 let extends = self
693 .codebase
694 .interfaces
695 .get(fqcn.as_ref())
696 .map(|i| i.extends.clone())
697 .unwrap_or_default();
698
699 for parent in extends {
700 self.dfs_interface_cycle(parent, in_stack, stack_set, globally_done, issues);
701 }
702
703 in_stack.pop();
704 stack_set.remove(fqcn.as_ref());
705 globally_done.insert(fqcn.to_string());
706 }
707
708 fn class_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
709 if self.analyzed_files.is_empty() {
710 return true;
711 }
712 self.codebase
713 .classes
714 .get(fqcn.as_ref())
715 .map(|c| {
716 c.location
717 .as_ref()
718 .map(|loc| self.analyzed_files.contains(&loc.file))
719 .unwrap_or(false)
720 })
721 .unwrap_or(false)
722 }
723
724 fn iface_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
725 if self.analyzed_files.is_empty() {
726 return true;
727 }
728 self.codebase
729 .interfaces
730 .get(fqcn.as_ref())
731 .map(|i| {
732 i.location
733 .as_ref()
734 .map(|loc| self.analyzed_files.contains(&loc.file))
735 .unwrap_or(false)
736 })
737 .unwrap_or(false)
738 }
739}
740
741fn visibility_reduced(child_vis: Visibility, parent_vis: Visibility) -> bool {
743 matches!(
746 (parent_vis, child_vis),
747 (Visibility::Public, Visibility::Protected)
748 | (Visibility::Public, Visibility::Private)
749 | (Visibility::Protected, Visibility::Private)
750 )
751}
752
753fn issue_location(
757 storage_loc: Option<&mir_codebase::storage::Location>,
758 fqcn: &Arc<str>,
759 source: Option<&str>,
760) -> Location {
761 match storage_loc {
762 Some(loc) => {
763 let col_end = if let Some(src) = source {
765 if loc.end > loc.start {
766 let end_offset = (loc.end as usize).min(src.len());
767 let line_start = src[..end_offset].rfind('\n').map(|p| p + 1).unwrap_or(0);
769 let utf16_col_end: u16 = src[line_start..end_offset]
771 .chars()
772 .map(|c| c.len_utf16() as u16)
773 .sum();
774
775 let col_start_offset = (loc.start as usize).min(src.len());
777 let col_start_line = src[..col_start_offset]
778 .rfind('\n')
779 .map(|p| p + 1)
780 .unwrap_or(0);
781 let col_start_utf16 = src[col_start_line..col_start_offset]
782 .chars()
783 .map(|c| c.len_utf16() as u16)
784 .sum::<u16>();
785
786 utf16_col_end.max(col_start_utf16 + 1)
788 } else {
789 let col_start_offset = (loc.start as usize).min(src.len());
791 let col_start_line = src[..col_start_offset]
792 .rfind('\n')
793 .map(|p| p + 1)
794 .unwrap_or(0);
795 src[col_start_line..col_start_offset]
796 .chars()
797 .map(|c| c.len_utf16() as u16)
798 .sum::<u16>()
799 + 1
800 }
801 } else {
802 loc.col + 1
803 };
804
805 let col_start = if let Some(src) = source {
807 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 src[col_start_line..col_start_offset]
813 .chars()
814 .map(|c| c.len_utf16() as u16)
815 .sum()
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}