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 method_name_lower = method_name.to_lowercase();
218 let implemented = cls
220 .get_method(&method_name_lower)
221 .map(|m| !m.is_abstract)
222 .unwrap_or(false);
223
224 if !implemented {
225 let loc = issue_location(
226 cls.location.as_ref(),
227 fqcn,
228 cls.location
229 .as_ref()
230 .and_then(|l| self.sources.get(&l.file).copied()),
231 );
232 let mut issue = Issue::new(
233 IssueKind::UnimplementedInterfaceMethod {
234 class: fqcn.to_string(),
235 interface: iface_fqcn.to_string(),
236 method: method_name.to_string(),
237 },
238 loc,
239 );
240 if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources) {
241 issue = issue.with_snippet(snippet);
242 }
243 issues.push(issue);
244 }
245 }
246 }
247 }
248
249 fn check_overrides(&self, cls: &mir_codebase::storage::ClassStorage, issues: &mut Vec<Issue>) {
254 let fqcn = &cls.fqcn;
255
256 for (method_name, own_method) in &cls.own_methods {
257 if method_name.as_ref() == "__construct" {
259 continue;
260 }
261
262 let parent_method = self.find_parent_method(cls, method_name.as_ref());
264
265 let parent = match parent_method {
266 Some(m) => m,
267 None => continue, };
269
270 let loc = issue_location(
271 own_method.location.as_ref(),
272 fqcn,
273 own_method
274 .location
275 .as_ref()
276 .and_then(|l| self.sources.get(&l.file).copied()),
277 );
278
279 if parent.is_final {
281 let mut issue = Issue::new(
282 IssueKind::FinalMethodOverridden {
283 class: fqcn.to_string(),
284 method: method_name.to_string(),
285 parent: parent.fqcn.to_string(),
286 },
287 loc.clone(),
288 );
289 if let Some(snippet) = extract_snippet(own_method.location.as_ref(), &self.sources)
290 {
291 issue = issue.with_snippet(snippet);
292 }
293 issues.push(issue);
294 }
295
296 if visibility_reduced(own_method.visibility, parent.visibility) {
298 let mut issue = Issue::new(
299 IssueKind::OverriddenMethodAccess {
300 class: fqcn.to_string(),
301 method: method_name.to_string(),
302 },
303 loc.clone(),
304 );
305 if let Some(snippet) = extract_snippet(own_method.location.as_ref(), &self.sources)
306 {
307 issue = issue.with_snippet(snippet);
308 }
309 issues.push(issue);
310 }
311
312 if let (Some(child_ret), Some(parent_ret)) =
319 (&own_method.return_type, &parent.return_type)
320 {
321 let parent_from_docblock = parent_ret.from_docblock;
322 let involves_named_objects = self.type_has_named_objects(child_ret)
323 || self.type_has_named_objects(parent_ret);
324 let involves_self_static = self.type_has_self_or_static(child_ret)
325 || self.type_has_self_or_static(parent_ret);
326
327 if !parent_from_docblock
328 && !involves_named_objects
329 && !involves_self_static
330 && !child_ret.is_subtype_of_simple(parent_ret)
331 && !parent_ret.is_mixed()
332 && !child_ret.is_mixed()
333 && !self.return_type_has_template(parent_ret)
334 {
335 issues.push(
336 Issue::new(
337 IssueKind::MethodSignatureMismatch {
338 class: fqcn.to_string(),
339 method: method_name.to_string(),
340 detail: format!(
341 "return type '{}' is not a subtype of parent '{}'",
342 child_ret, parent_ret
343 ),
344 },
345 loc.clone(),
346 )
347 .with_snippet(method_name.to_string()),
348 );
349 }
350 }
351
352 let parent_required = parent
354 .params
355 .iter()
356 .filter(|p| !p.is_optional && !p.is_variadic)
357 .count();
358 let child_required = own_method
359 .params
360 .iter()
361 .filter(|p| !p.is_optional && !p.is_variadic)
362 .count();
363
364 if child_required > parent_required {
365 issues.push(
366 Issue::new(
367 IssueKind::MethodSignatureMismatch {
368 class: fqcn.to_string(),
369 method: method_name.to_string(),
370 detail: format!(
371 "overriding method requires {} argument(s) but parent requires {}",
372 child_required, parent_required
373 ),
374 },
375 loc.clone(),
376 )
377 .with_snippet(method_name.to_string()),
378 );
379 }
380
381 let shared_len = parent.params.len().min(own_method.params.len());
392 for i in 0..shared_len {
393 let parent_param = &parent.params[i];
394 let child_param = &own_method.params[i];
395
396 let (parent_ty, child_ty) = match (&parent_param.ty, &child_param.ty) {
397 (Some(p), Some(c)) => (p, c),
398 _ => continue,
399 };
400
401 if parent_ty.is_mixed()
402 || child_ty.is_mixed()
403 || self.type_has_named_objects(parent_ty)
404 || self.type_has_named_objects(child_ty)
405 || self.type_has_self_or_static(parent_ty)
406 || self.type_has_self_or_static(child_ty)
407 || self.return_type_has_template(parent_ty)
408 || self.return_type_has_template(child_ty)
409 {
410 continue;
411 }
412
413 if !parent_ty.is_subtype_of_simple(child_ty) {
416 issues.push(
417 Issue::new(
418 IssueKind::MethodSignatureMismatch {
419 class: fqcn.to_string(),
420 method: method_name.to_string(),
421 detail: format!(
422 "parameter ${} type '{}' is narrower than parent type '{}'",
423 child_param.name, child_ty, parent_ty
424 ),
425 },
426 loc.clone(),
427 )
428 .with_snippet(method_name.to_string()),
429 );
430 break; }
432 }
433 }
434 }
435
436 fn return_type_has_template(&self, ty: &mir_types::Union) -> bool {
444 use mir_types::Atomic;
445 ty.types.iter().any(|atomic| match atomic {
446 Atomic::TTemplateParam { .. } => true,
447 Atomic::TClassString(Some(inner)) => !self.codebase.type_exists(inner.as_ref()),
448 Atomic::TNamedObject { fqcn, type_params } => {
449 (!fqcn.contains('\\') && !self.codebase.type_exists(fqcn.as_ref()))
451 || type_params.iter().any(|tp| self.return_type_has_template(tp))
453 }
454 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
455 self.return_type_has_template(key) || self.return_type_has_template(value)
456 }
457 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
458 self.return_type_has_template(value)
459 }
460 _ => false,
461 })
462 }
463
464 fn type_has_named_objects(&self, ty: &mir_types::Union) -> bool {
469 use mir_types::Atomic;
470 ty.types.iter().any(|a| match a {
471 Atomic::TNamedObject { .. } => true,
472 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
473 self.type_has_named_objects(key) || self.type_has_named_objects(value)
474 }
475 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
476 self.type_has_named_objects(value)
477 }
478 _ => false,
479 })
480 }
481
482 fn type_has_self_or_static(&self, ty: &mir_types::Union) -> bool {
485 use mir_types::Atomic;
486 ty.types
487 .iter()
488 .any(|a| matches!(a, Atomic::TSelf { .. } | Atomic::TStaticObject { .. }))
489 }
490
491 fn find_parent_method(
493 &self,
494 cls: &mir_codebase::storage::ClassStorage,
495 method_name: &str,
496 ) -> Option<MethodStorage> {
497 for ancestor_fqcn in &cls.all_parents {
499 if let Some(ancestor_cls) = self.codebase.classes.get(ancestor_fqcn.as_ref()) {
500 if let Some(m) = ancestor_cls.own_methods.get(method_name) {
501 return Some(m.clone());
502 }
503 } else if let Some(iface) = self.codebase.interfaces.get(ancestor_fqcn.as_ref()) {
504 if let Some(m) = iface.own_methods.get(method_name) {
505 return Some(m.clone());
506 }
507 }
508 }
509 None
510 }
511
512 fn check_circular_class_inheritance(&self, issues: &mut Vec<Issue>) {
517 let mut globally_done: HashSet<String> = HashSet::new();
518
519 let mut class_keys: Vec<Arc<str>> = self
520 .codebase
521 .classes
522 .iter()
523 .map(|e| e.key().clone())
524 .collect();
525 class_keys.sort();
526
527 for start_fqcn in &class_keys {
528 if globally_done.contains(start_fqcn.as_ref()) {
529 continue;
530 }
531
532 let mut chain: Vec<Arc<str>> = Vec::new();
534 let mut chain_set: HashSet<String> = HashSet::new();
535 let mut current: Arc<str> = start_fqcn.clone();
536
537 loop {
538 if globally_done.contains(current.as_ref()) {
539 for node in &chain {
541 globally_done.insert(node.to_string());
542 }
543 break;
544 }
545 if !chain_set.insert(current.to_string()) {
546 let cycle_start = chain
548 .iter()
549 .position(|p| p.as_ref() == current.as_ref())
550 .unwrap_or(0);
551 let cycle_nodes = &chain[cycle_start..];
552
553 let offender = cycle_nodes
556 .iter()
557 .filter(|n| self.class_in_analyzed_files(n))
558 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
559
560 if let Some(offender) = offender {
561 let cls = self.codebase.classes.get(offender.as_ref());
562 let loc = issue_location(
563 cls.as_ref().and_then(|c| c.location.as_ref()),
564 offender,
565 cls.as_ref()
566 .and_then(|c| c.location.as_ref())
567 .and_then(|l| self.sources.get(&l.file).copied()),
568 );
569 let mut issue = Issue::new(
570 IssueKind::CircularInheritance {
571 class: offender.to_string(),
572 },
573 loc,
574 );
575 if let Some(snippet) = extract_snippet(
576 cls.as_ref().and_then(|c| c.location.as_ref()),
577 &self.sources,
578 ) {
579 issue = issue.with_snippet(snippet);
580 }
581 issues.push(issue);
582 }
583
584 for node in &chain {
585 globally_done.insert(node.to_string());
586 }
587 break;
588 }
589
590 chain.push(current.clone());
591
592 let parent = self
593 .codebase
594 .classes
595 .get(current.as_ref())
596 .and_then(|c| c.parent.clone());
597
598 match parent {
599 Some(p) => current = p,
600 None => {
601 for node in &chain {
602 globally_done.insert(node.to_string());
603 }
604 break;
605 }
606 }
607 }
608 }
609 }
610
611 fn check_circular_interface_inheritance(&self, issues: &mut Vec<Issue>) {
616 let mut globally_done: HashSet<String> = HashSet::new();
617
618 let mut iface_keys: Vec<Arc<str>> = self
619 .codebase
620 .interfaces
621 .iter()
622 .map(|e| e.key().clone())
623 .collect();
624 iface_keys.sort();
625
626 for start_fqcn in &iface_keys {
627 if globally_done.contains(start_fqcn.as_ref()) {
628 continue;
629 }
630 let mut in_stack: Vec<Arc<str>> = Vec::new();
631 let mut stack_set: HashSet<String> = HashSet::new();
632 self.dfs_interface_cycle(
633 start_fqcn.clone(),
634 &mut in_stack,
635 &mut stack_set,
636 &mut globally_done,
637 issues,
638 );
639 }
640 }
641
642 fn dfs_interface_cycle(
643 &self,
644 fqcn: Arc<str>,
645 in_stack: &mut Vec<Arc<str>>,
646 stack_set: &mut HashSet<String>,
647 globally_done: &mut HashSet<String>,
648 issues: &mut Vec<Issue>,
649 ) {
650 if globally_done.contains(fqcn.as_ref()) {
651 return;
652 }
653 if stack_set.contains(fqcn.as_ref()) {
654 let cycle_start = in_stack
656 .iter()
657 .position(|p| p.as_ref() == fqcn.as_ref())
658 .unwrap_or(0);
659 let cycle_nodes = &in_stack[cycle_start..];
660
661 let offender = cycle_nodes
662 .iter()
663 .filter(|n| self.iface_in_analyzed_files(n))
664 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
665
666 if let Some(offender) = offender {
667 let iface = self.codebase.interfaces.get(offender.as_ref());
668 let loc = issue_location(
669 iface.as_ref().and_then(|i| i.location.as_ref()),
670 offender,
671 iface
672 .as_ref()
673 .and_then(|i| i.location.as_ref())
674 .and_then(|l| self.sources.get(&l.file).copied()),
675 );
676 let mut issue = Issue::new(
677 IssueKind::CircularInheritance {
678 class: offender.to_string(),
679 },
680 loc,
681 );
682 if let Some(snippet) = extract_snippet(
683 iface.as_ref().and_then(|i| i.location.as_ref()),
684 &self.sources,
685 ) {
686 issue = issue.with_snippet(snippet);
687 }
688 issues.push(issue);
689 }
690 return;
691 }
692
693 stack_set.insert(fqcn.to_string());
694 in_stack.push(fqcn.clone());
695
696 let extends = self
697 .codebase
698 .interfaces
699 .get(fqcn.as_ref())
700 .map(|i| i.extends.clone())
701 .unwrap_or_default();
702
703 for parent in extends {
704 self.dfs_interface_cycle(parent, in_stack, stack_set, globally_done, issues);
705 }
706
707 in_stack.pop();
708 stack_set.remove(fqcn.as_ref());
709 globally_done.insert(fqcn.to_string());
710 }
711
712 fn class_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
713 if self.analyzed_files.is_empty() {
714 return true;
715 }
716 self.codebase
717 .classes
718 .get(fqcn.as_ref())
719 .map(|c| {
720 c.location
721 .as_ref()
722 .map(|loc| self.analyzed_files.contains(&loc.file))
723 .unwrap_or(false)
724 })
725 .unwrap_or(false)
726 }
727
728 fn iface_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
729 if self.analyzed_files.is_empty() {
730 return true;
731 }
732 self.codebase
733 .interfaces
734 .get(fqcn.as_ref())
735 .map(|i| {
736 i.location
737 .as_ref()
738 .map(|loc| self.analyzed_files.contains(&loc.file))
739 .unwrap_or(false)
740 })
741 .unwrap_or(false)
742 }
743}
744
745fn visibility_reduced(child_vis: Visibility, parent_vis: Visibility) -> bool {
747 matches!(
750 (parent_vis, child_vis),
751 (Visibility::Public, Visibility::Protected)
752 | (Visibility::Public, Visibility::Private)
753 | (Visibility::Protected, Visibility::Private)
754 )
755}
756
757fn issue_location(
761 storage_loc: Option<&mir_codebase::storage::Location>,
762 fqcn: &Arc<str>,
763 source: Option<&str>,
764) -> Location {
765 match storage_loc {
766 Some(loc) => {
767 let col_end = if let Some(src) = source {
769 if loc.end > loc.start {
770 let end_offset = (loc.end as usize).min(src.len());
771 let line_start = src[..end_offset].rfind('\n').map(|p| p + 1).unwrap_or(0);
773 let utf16_col_end: u16 = src[line_start..end_offset]
775 .chars()
776 .map(|c| c.len_utf16() as u16)
777 .sum();
778
779 let col_start_offset = (loc.start as usize).min(src.len());
781 let col_start_line = src[..col_start_offset]
782 .rfind('\n')
783 .map(|p| p + 1)
784 .unwrap_or(0);
785 let col_start_utf16 = src[col_start_line..col_start_offset]
786 .chars()
787 .map(|c| c.len_utf16() as u16)
788 .sum::<u16>();
789
790 utf16_col_end.max(col_start_utf16 + 1)
792 } else {
793 let col_start_offset = (loc.start as usize).min(src.len());
795 let col_start_line = src[..col_start_offset]
796 .rfind('\n')
797 .map(|p| p + 1)
798 .unwrap_or(0);
799 src[col_start_line..col_start_offset]
800 .chars()
801 .map(|c| c.len_utf16() as u16)
802 .sum::<u16>()
803 + 1
804 }
805 } else {
806 loc.col + 1
807 };
808
809 let col_start = if let Some(src) = source {
811 let col_start_offset = (loc.start as usize).min(src.len());
812 let col_start_line = src[..col_start_offset]
813 .rfind('\n')
814 .map(|p| p + 1)
815 .unwrap_or(0);
816 src[col_start_line..col_start_offset]
817 .chars()
818 .map(|c| c.len_utf16() as u16)
819 .sum()
820 } else {
821 loc.col
822 };
823
824 Location {
825 file: loc.file.clone(),
826 line: loc.line,
827 col_start,
828 col_end,
829 }
830 }
831 None => Location {
832 file: fqcn.clone(),
833 line: 1,
834 col_start: 0,
835 col_end: 0,
836 },
837 }
838}
839
840fn extract_snippet(
842 storage_loc: Option<&mir_codebase::storage::Location>,
843 sources: &HashMap<Arc<str>, &str>,
844) -> Option<String> {
845 let loc = storage_loc?;
846 let src = *sources.get(&loc.file)?;
847 let start = loc.start as usize;
848 let end = loc.end as usize;
849 if start >= src.len() {
850 return None;
851 }
852 let end = end.min(src.len());
853 let span_text = &src[start..end];
854 let first_line = span_text.lines().next().unwrap_or(span_text);
856 Some(first_line.trim().to_string())
857}