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(cls.location.as_ref(), fqcn);
88 let mut issue = Issue::new(
89 IssueKind::FinalClassExtended {
90 parent: parent_fqcn.to_string(),
91 child: fqcn.to_string(),
92 },
93 loc,
94 );
95 if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources)
96 {
97 issue = issue.with_snippet(snippet);
98 }
99 issues.push(issue);
100 }
101 }
102 }
103
104 if cls.is_abstract {
106 self.check_overrides(&cls, &mut issues);
108 continue;
109 }
110
111 self.check_abstract_methods_implemented(&cls, &mut issues);
113
114 self.check_interface_methods_implemented(&cls, &mut issues);
116
117 self.check_overrides(&cls, &mut issues);
119 }
120
121 self.check_circular_class_inheritance(&mut issues);
123 self.check_circular_interface_inheritance(&mut issues);
124
125 issues
126 }
127
128 fn check_abstract_methods_implemented(
133 &self,
134 cls: &mir_codebase::storage::ClassStorage,
135 issues: &mut Vec<Issue>,
136 ) {
137 let fqcn = &cls.fqcn;
138
139 for ancestor_fqcn in &cls.all_parents {
141 let ancestor = match self.codebase.classes.get(ancestor_fqcn.as_ref()) {
142 Some(a) => a,
143 None => continue,
144 };
145
146 for (method_name, method) in &ancestor.own_methods {
147 if !method.is_abstract {
148 continue;
149 }
150
151 if cls
153 .get_method(method_name.as_ref())
154 .map(|m| !m.is_abstract)
155 .unwrap_or(false)
156 {
157 continue; }
159
160 let loc = issue_location(cls.location.as_ref(), fqcn);
161 let mut issue = Issue::new(
162 IssueKind::UnimplementedAbstractMethod {
163 class: fqcn.to_string(),
164 method: method_name.to_string(),
165 },
166 loc,
167 );
168 if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources) {
169 issue = issue.with_snippet(snippet);
170 }
171 issues.push(issue);
172 }
173 }
174 }
175
176 fn check_interface_methods_implemented(
181 &self,
182 cls: &mir_codebase::storage::ClassStorage,
183 issues: &mut Vec<Issue>,
184 ) {
185 let fqcn = &cls.fqcn;
186
187 let all_ifaces: Vec<Arc<str>> = cls
189 .all_parents
190 .iter()
191 .filter(|p| self.codebase.interfaces.contains_key(p.as_ref()))
192 .cloned()
193 .collect();
194
195 for iface_fqcn in &all_ifaces {
196 let iface = match self.codebase.interfaces.get(iface_fqcn.as_ref()) {
197 Some(i) => i,
198 None => continue,
199 };
200
201 for (method_name, _method) in &iface.own_methods {
202 let implemented = cls
204 .get_method(method_name.as_ref())
205 .map(|m| !m.is_abstract)
206 .unwrap_or(false);
207
208 if !implemented {
209 let loc = issue_location(cls.location.as_ref(), fqcn);
210 let mut issue = Issue::new(
211 IssueKind::UnimplementedInterfaceMethod {
212 class: fqcn.to_string(),
213 interface: iface_fqcn.to_string(),
214 method: method_name.to_string(),
215 },
216 loc,
217 );
218 if let Some(snippet) = extract_snippet(cls.location.as_ref(), &self.sources) {
219 issue = issue.with_snippet(snippet);
220 }
221 issues.push(issue);
222 }
223 }
224 }
225 }
226
227 fn check_overrides(&self, cls: &mir_codebase::storage::ClassStorage, issues: &mut Vec<Issue>) {
232 let fqcn = &cls.fqcn;
233
234 for (method_name, own_method) in &cls.own_methods {
235 if method_name.as_ref() == "__construct" {
237 continue;
238 }
239
240 let parent_method = self.find_parent_method(cls, method_name.as_ref());
242
243 let parent = match parent_method {
244 Some(m) => m,
245 None => continue, };
247
248 let loc = issue_location(own_method.location.as_ref(), fqcn);
249
250 if parent.is_final {
252 let mut issue = Issue::new(
253 IssueKind::FinalMethodOverridden {
254 class: fqcn.to_string(),
255 method: method_name.to_string(),
256 parent: parent.fqcn.to_string(),
257 },
258 loc.clone(),
259 );
260 if let Some(snippet) = extract_snippet(own_method.location.as_ref(), &self.sources)
261 {
262 issue = issue.with_snippet(snippet);
263 }
264 issues.push(issue);
265 }
266
267 if visibility_reduced(own_method.visibility, parent.visibility) {
269 let mut issue = Issue::new(
270 IssueKind::OverriddenMethodAccess {
271 class: fqcn.to_string(),
272 method: method_name.to_string(),
273 },
274 loc.clone(),
275 );
276 if let Some(snippet) = extract_snippet(own_method.location.as_ref(), &self.sources)
277 {
278 issue = issue.with_snippet(snippet);
279 }
280 issues.push(issue);
281 }
282
283 if let (Some(child_ret), Some(parent_ret)) =
290 (&own_method.return_type, &parent.return_type)
291 {
292 let parent_from_docblock = parent_ret.from_docblock;
293 let involves_named_objects = self.type_has_named_objects(child_ret)
294 || self.type_has_named_objects(parent_ret);
295 let involves_self_static = self.type_has_self_or_static(child_ret)
296 || self.type_has_self_or_static(parent_ret);
297
298 if !parent_from_docblock
299 && !involves_named_objects
300 && !involves_self_static
301 && !child_ret.is_subtype_of_simple(parent_ret)
302 && !parent_ret.is_mixed()
303 && !child_ret.is_mixed()
304 && !self.return_type_has_template(parent_ret)
305 {
306 issues.push(
307 Issue::new(
308 IssueKind::MethodSignatureMismatch {
309 class: fqcn.to_string(),
310 method: method_name.to_string(),
311 detail: format!(
312 "return type '{}' is not a subtype of parent '{}'",
313 child_ret, parent_ret
314 ),
315 },
316 loc.clone(),
317 )
318 .with_snippet(method_name.to_string()),
319 );
320 }
321 }
322
323 let parent_required = parent
325 .params
326 .iter()
327 .filter(|p| !p.is_optional && !p.is_variadic)
328 .count();
329 let child_required = own_method
330 .params
331 .iter()
332 .filter(|p| !p.is_optional && !p.is_variadic)
333 .count();
334
335 if child_required > parent_required {
336 issues.push(
337 Issue::new(
338 IssueKind::MethodSignatureMismatch {
339 class: fqcn.to_string(),
340 method: method_name.to_string(),
341 detail: format!(
342 "overriding method requires {} argument(s) but parent requires {}",
343 child_required, parent_required
344 ),
345 },
346 loc.clone(),
347 )
348 .with_snippet(method_name.to_string()),
349 );
350 }
351
352 let shared_len = parent.params.len().min(own_method.params.len());
363 for i in 0..shared_len {
364 let parent_param = &parent.params[i];
365 let child_param = &own_method.params[i];
366
367 let (parent_ty, child_ty) = match (&parent_param.ty, &child_param.ty) {
368 (Some(p), Some(c)) => (p, c),
369 _ => continue,
370 };
371
372 if parent_ty.is_mixed()
373 || child_ty.is_mixed()
374 || self.type_has_named_objects(parent_ty)
375 || self.type_has_named_objects(child_ty)
376 || self.type_has_self_or_static(parent_ty)
377 || self.type_has_self_or_static(child_ty)
378 || self.return_type_has_template(parent_ty)
379 || self.return_type_has_template(child_ty)
380 {
381 continue;
382 }
383
384 if !parent_ty.is_subtype_of_simple(child_ty) {
387 issues.push(
388 Issue::new(
389 IssueKind::MethodSignatureMismatch {
390 class: fqcn.to_string(),
391 method: method_name.to_string(),
392 detail: format!(
393 "parameter ${} type '{}' is narrower than parent type '{}'",
394 child_param.name, child_ty, parent_ty
395 ),
396 },
397 loc.clone(),
398 )
399 .with_snippet(method_name.to_string()),
400 );
401 break; }
403 }
404 }
405 }
406
407 fn return_type_has_template(&self, ty: &mir_types::Union) -> bool {
415 use mir_types::Atomic;
416 ty.types.iter().any(|atomic| match atomic {
417 Atomic::TTemplateParam { .. } => true,
418 Atomic::TClassString(Some(inner)) => !self.codebase.type_exists(inner.as_ref()),
419 Atomic::TNamedObject { fqcn, type_params } => {
420 (!fqcn.contains('\\') && !self.codebase.type_exists(fqcn.as_ref()))
422 || type_params.iter().any(|tp| self.return_type_has_template(tp))
424 }
425 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
426 self.return_type_has_template(key) || self.return_type_has_template(value)
427 }
428 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
429 self.return_type_has_template(value)
430 }
431 _ => false,
432 })
433 }
434
435 fn type_has_named_objects(&self, ty: &mir_types::Union) -> bool {
440 use mir_types::Atomic;
441 ty.types.iter().any(|a| match a {
442 Atomic::TNamedObject { .. } => true,
443 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
444 self.type_has_named_objects(key) || self.type_has_named_objects(value)
445 }
446 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
447 self.type_has_named_objects(value)
448 }
449 _ => false,
450 })
451 }
452
453 fn type_has_self_or_static(&self, ty: &mir_types::Union) -> bool {
456 use mir_types::Atomic;
457 ty.types
458 .iter()
459 .any(|a| matches!(a, Atomic::TSelf { .. } | Atomic::TStaticObject { .. }))
460 }
461
462 fn find_parent_method(
464 &self,
465 cls: &mir_codebase::storage::ClassStorage,
466 method_name: &str,
467 ) -> Option<MethodStorage> {
468 for ancestor_fqcn in &cls.all_parents {
470 if let Some(ancestor_cls) = self.codebase.classes.get(ancestor_fqcn.as_ref()) {
471 if let Some(m) = ancestor_cls.own_methods.get(method_name) {
472 return Some(m.clone());
473 }
474 } else if let Some(iface) = self.codebase.interfaces.get(ancestor_fqcn.as_ref()) {
475 if let Some(m) = iface.own_methods.get(method_name) {
476 return Some(m.clone());
477 }
478 }
479 }
480 None
481 }
482
483 fn check_circular_class_inheritance(&self, issues: &mut Vec<Issue>) {
488 let mut globally_done: HashSet<String> = HashSet::new();
489
490 let mut class_keys: Vec<Arc<str>> = self
491 .codebase
492 .classes
493 .iter()
494 .map(|e| e.key().clone())
495 .collect();
496 class_keys.sort();
497
498 for start_fqcn in &class_keys {
499 if globally_done.contains(start_fqcn.as_ref()) {
500 continue;
501 }
502
503 let mut chain: Vec<Arc<str>> = Vec::new();
505 let mut chain_set: HashSet<String> = HashSet::new();
506 let mut current: Arc<str> = start_fqcn.clone();
507
508 loop {
509 if globally_done.contains(current.as_ref()) {
510 for node in &chain {
512 globally_done.insert(node.to_string());
513 }
514 break;
515 }
516 if !chain_set.insert(current.to_string()) {
517 let cycle_start = chain
519 .iter()
520 .position(|p| p.as_ref() == current.as_ref())
521 .unwrap_or(0);
522 let cycle_nodes = &chain[cycle_start..];
523
524 let offender = cycle_nodes
527 .iter()
528 .filter(|n| self.class_in_analyzed_files(n))
529 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
530
531 if let Some(offender) = offender {
532 let cls = self.codebase.classes.get(offender.as_ref());
533 let loc = issue_location(
534 cls.as_ref().and_then(|c| c.location.as_ref()),
535 offender,
536 );
537 let mut issue = Issue::new(
538 IssueKind::CircularInheritance {
539 class: offender.to_string(),
540 },
541 loc,
542 );
543 if let Some(snippet) = extract_snippet(
544 cls.as_ref().and_then(|c| c.location.as_ref()),
545 &self.sources,
546 ) {
547 issue = issue.with_snippet(snippet);
548 }
549 issues.push(issue);
550 }
551
552 for node in &chain {
553 globally_done.insert(node.to_string());
554 }
555 break;
556 }
557
558 chain.push(current.clone());
559
560 let parent = self
561 .codebase
562 .classes
563 .get(current.as_ref())
564 .and_then(|c| c.parent.clone());
565
566 match parent {
567 Some(p) => current = p,
568 None => {
569 for node in &chain {
570 globally_done.insert(node.to_string());
571 }
572 break;
573 }
574 }
575 }
576 }
577 }
578
579 fn check_circular_interface_inheritance(&self, issues: &mut Vec<Issue>) {
584 let mut globally_done: HashSet<String> = HashSet::new();
585
586 let mut iface_keys: Vec<Arc<str>> = self
587 .codebase
588 .interfaces
589 .iter()
590 .map(|e| e.key().clone())
591 .collect();
592 iface_keys.sort();
593
594 for start_fqcn in &iface_keys {
595 if globally_done.contains(start_fqcn.as_ref()) {
596 continue;
597 }
598 let mut in_stack: Vec<Arc<str>> = Vec::new();
599 let mut stack_set: HashSet<String> = HashSet::new();
600 self.dfs_interface_cycle(
601 start_fqcn.clone(),
602 &mut in_stack,
603 &mut stack_set,
604 &mut globally_done,
605 issues,
606 );
607 }
608 }
609
610 fn dfs_interface_cycle(
611 &self,
612 fqcn: Arc<str>,
613 in_stack: &mut Vec<Arc<str>>,
614 stack_set: &mut HashSet<String>,
615 globally_done: &mut HashSet<String>,
616 issues: &mut Vec<Issue>,
617 ) {
618 if globally_done.contains(fqcn.as_ref()) {
619 return;
620 }
621 if stack_set.contains(fqcn.as_ref()) {
622 let cycle_start = in_stack
624 .iter()
625 .position(|p| p.as_ref() == fqcn.as_ref())
626 .unwrap_or(0);
627 let cycle_nodes = &in_stack[cycle_start..];
628
629 let offender = cycle_nodes
630 .iter()
631 .filter(|n| self.iface_in_analyzed_files(n))
632 .max_by(|a, b| a.as_ref().cmp(b.as_ref()));
633
634 if let Some(offender) = offender {
635 let iface = self.codebase.interfaces.get(offender.as_ref());
636 let loc =
637 issue_location(iface.as_ref().and_then(|i| i.location.as_ref()), offender);
638 let mut issue = Issue::new(
639 IssueKind::CircularInheritance {
640 class: offender.to_string(),
641 },
642 loc,
643 );
644 if let Some(snippet) = extract_snippet(
645 iface.as_ref().and_then(|i| i.location.as_ref()),
646 &self.sources,
647 ) {
648 issue = issue.with_snippet(snippet);
649 }
650 issues.push(issue);
651 }
652 return;
653 }
654
655 stack_set.insert(fqcn.to_string());
656 in_stack.push(fqcn.clone());
657
658 let extends = self
659 .codebase
660 .interfaces
661 .get(fqcn.as_ref())
662 .map(|i| i.extends.clone())
663 .unwrap_or_default();
664
665 for parent in extends {
666 self.dfs_interface_cycle(parent, in_stack, stack_set, globally_done, issues);
667 }
668
669 in_stack.pop();
670 stack_set.remove(fqcn.as_ref());
671 globally_done.insert(fqcn.to_string());
672 }
673
674 fn class_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
675 if self.analyzed_files.is_empty() {
676 return true;
677 }
678 self.codebase
679 .classes
680 .get(fqcn.as_ref())
681 .map(|c| {
682 c.location
683 .as_ref()
684 .map(|loc| self.analyzed_files.contains(&loc.file))
685 .unwrap_or(false)
686 })
687 .unwrap_or(false)
688 }
689
690 fn iface_in_analyzed_files(&self, fqcn: &Arc<str>) -> bool {
691 if self.analyzed_files.is_empty() {
692 return true;
693 }
694 self.codebase
695 .interfaces
696 .get(fqcn.as_ref())
697 .map(|i| {
698 i.location
699 .as_ref()
700 .map(|loc| self.analyzed_files.contains(&loc.file))
701 .unwrap_or(false)
702 })
703 .unwrap_or(false)
704 }
705}
706
707fn visibility_reduced(child_vis: Visibility, parent_vis: Visibility) -> bool {
709 matches!(
712 (parent_vis, child_vis),
713 (Visibility::Public, Visibility::Protected)
714 | (Visibility::Public, Visibility::Private)
715 | (Visibility::Protected, Visibility::Private)
716 )
717}
718
719fn issue_location(
722 storage_loc: Option<&mir_codebase::storage::Location>,
723 fqcn: &Arc<str>,
724) -> Location {
725 match storage_loc {
726 Some(loc) => Location {
727 file: loc.file.clone(),
728 line: loc.line,
729 col_start: loc.col,
730 col_end: loc.col,
731 },
732 None => Location {
733 file: fqcn.clone(),
734 line: 1,
735 col_start: 0,
736 col_end: 0,
737 },
738 }
739}
740
741fn extract_snippet(
743 storage_loc: Option<&mir_codebase::storage::Location>,
744 sources: &HashMap<Arc<str>, &str>,
745) -> Option<String> {
746 let loc = storage_loc?;
747 let src = *sources.get(&loc.file)?;
748 let start = loc.start as usize;
749 let end = loc.end as usize;
750 if start >= src.len() {
751 return None;
752 }
753 let end = end.min(src.len());
754 let span_text = &src[start..end];
755 let first_line = span_text.lines().next().unwrap_or(span_text);
757 Some(first_line.trim().to_string())
758}