1use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17use rayon::prelude::*;
18use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
19
20use mir_issues::Issue;
21use mir_types::{Atomic, Type};
22
23use crate::body_analysis::BodyAnalyzer;
24use crate::cache::hash_content;
25use crate::db::{
26 collect_file_definitions, FileDefinitions, MirDatabase, MirDbStorage, RefLoc, SourceFile,
27};
28use crate::php_version::PhpVersion;
29use crate::session::AnalysisSession;
30use crate::stub_cache::{hash_source, prepare_for_ingest};
31
32pub fn dead_code_issue_kinds() -> &'static [&'static str] {
39 &[
40 "UnusedMethod",
41 "UnusedProperty",
42 "UnusedFunction",
43 "UnusedClass",
44 ]
45}
46
47#[derive(Clone, Default)]
53pub struct BatchOptions {
54 pub suppressed_issue_kinds: HashSet<String>,
59 pub on_file_done: Option<Arc<dyn Fn() + Send + Sync>>,
61 pub php_version_override: Option<PhpVersion>,
64 pub skip_symbols: bool,
71}
72
73impl BatchOptions {
74 pub fn new() -> Self {
75 Self::default()
76 }
77
78 pub fn with_suppressed<I, S>(mut self, kinds: I) -> Self
79 where
80 I: IntoIterator<Item = S>,
81 S: Into<String>,
82 {
83 self.suppressed_issue_kinds = kinds.into_iter().map(Into::into).collect();
84 self
85 }
86
87 pub fn with_progress_callback(mut self, callback: Arc<dyn Fn() + Send + Sync>) -> Self {
88 self.on_file_done = Some(callback);
89 self
90 }
91
92 pub fn with_php_version(mut self, version: PhpVersion) -> Self {
93 self.php_version_override = Some(version);
94 self
95 }
96
97 pub fn without_symbols(mut self) -> Self {
101 self.skip_symbols = true;
102 self
103 }
104
105 fn should_run_dead_code(&self) -> bool {
108 dead_code_issue_kinds()
109 .iter()
110 .any(|k| !self.suppressed_issue_kinds.contains(*k))
111 }
112
113 fn apply(&self, issues: &mut Vec<Issue>) {
116 if self.suppressed_issue_kinds.is_empty() {
117 return;
118 }
119 issues.retain(|i| !self.suppressed_issue_kinds.contains(i.kind.name()));
120 }
121}
122
123struct ParsedProjectFile {
124 file: Arc<str>,
125 source: Arc<str>,
126 parsed: php_rs_parser::ParseResult,
127}
128
129impl ParsedProjectFile {
130 fn new(file: Arc<str>, source: Arc<str>) -> Self {
131 let parsed = php_rs_parser::parse(source.as_ref());
132 Self {
133 file,
134 source,
135 parsed,
136 }
137 }
138
139 fn source(&self) -> &str {
140 self.source.as_ref()
141 }
142
143 fn source_map(&self) -> &php_rs_parser::source_map::SourceMap {
144 &self.parsed.source_map
145 }
146
147 fn errors(&self) -> &[php_rs_parser::diagnostics::ParseError] {
148 &self.parsed.errors
149 }
150
151 fn owned(&self) -> &php_ast::owned::Program {
152 &self.parsed.program
153 }
154}
155
156impl AnalysisSession {
157 #[doc(hidden)]
160 pub fn stub_cache_stats(&self) -> (u64, u64) {
161 match self.db.stub_cache.as_deref() {
162 Some(c) => (c.hits(), c.misses()),
163 None => (0, 0),
164 }
165 }
166
167 fn batch_php_version(&self, opts: &BatchOptions) -> PhpVersion {
168 opts.php_version_override.unwrap_or(self.php_version)
169 }
170
171 fn apply_suppressions_and_emit_unused(
189 &self,
190 issues: &mut Vec<Issue>,
191 analyzed_files: &[Arc<str>],
192 ) {
193 use crate::suppression::SuppressionMap;
194 let db = self.snapshot_db();
195 let mut cache: HashMap<Arc<str>, Option<SuppressionMap>> = HashMap::default();
196 for issue in issues.iter_mut() {
197 if issue.suppressed {
198 continue;
199 }
200 let map = cache.entry(issue.location.file.clone()).or_insert_with(|| {
201 db.lookup_source_file(&issue.location.file)
202 .map(|sf| SuppressionMap::from_source(&sf.text(&db)))
203 });
204 if let Some(map) = map.as_ref() {
205 if map.is_suppressed(issue.location.line, issue.kind.name(), issue.kind.code()) {
206 issue.suppressed = true;
207 }
208 }
209 }
210 for file in analyzed_files {
214 cache.entry(file.clone()).or_insert_with(|| {
215 db.lookup_source_file(file)
216 .map(|sf| SuppressionMap::from_source(&sf.text(&db)))
217 });
218 }
219 let files: Vec<Arc<str>> = cache
221 .iter()
222 .filter_map(|(f, m)| m.as_ref().map(|_| f.clone()))
223 .collect();
224 let mut new_issues: Vec<Issue> = Vec::new();
225 for file in files {
226 if let Some(Some(map)) = cache.get(&file) {
227 if map.named_suppressions.is_empty() {
228 continue;
229 }
230 let file_issues: Vec<Issue> = issues
231 .iter()
232 .filter(|i| i.location.file == file)
233 .cloned()
234 .collect();
235 let pre_suppressed: Vec<&Issue> =
240 file_issues.iter().filter(|i| i.suppressed).collect();
241 let unused = map.unused_named(&file_issues, &pre_suppressed);
246 for (line, kind) in unused {
247 let loc = mir_types::Location::new(file.clone(), line, line, 0, 0);
248 let mut issue =
249 Issue::new(mir_issues::IssueKind::UnusedPsalmSuppress { kind }, loc);
250 if map.is_suppressed(line, issue.kind.name(), issue.kind.code()) {
251 issue.suppressed = true;
252 }
253 new_issues.push(issue);
254 }
255 }
256 }
257 issues.extend(new_issues);
258 }
259
260 fn type_exists(&self, fqcn: &str) -> bool {
261 let db = self.snapshot_db();
262 crate::db::class_exists(&db, fqcn)
263 }
264
265 fn collect_and_ingest_source(
266 &self,
267 file: Arc<str>,
268 src: &str,
269 php_version: PhpVersion,
270 ) -> FileDefinitions {
271 self.db.collect_and_ingest_file(file, src, php_version)
272 }
273
274 fn refresh_workspace_index(&self) {
282 let mut guard = self.db.salsa.write();
283 guard.rebuild_workspace_symbol_index();
284 }
285
286 fn load_batch_stubs(&self, php_version: PhpVersion) {
290 {
293 let version_str = Arc::from(php_version.to_string().as_str());
294 self.db.salsa.write().set_php_version(version_str);
295 }
296
297 let paths: Vec<&'static str> = crate::stubs::stub_files().iter().map(|&(p, _)| p).collect();
299 self.db.ingest_stub_paths(&paths, php_version);
300
301 self.db
303 .ingest_user_stubs(&self.user_stub_files, &self.user_stub_dirs);
304
305 let mut guard = self.db.salsa.write();
308 if guard.current_resolver().is_none() {
309 let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::StubClassResolver);
310 guard.set_resolver(Some(resolver));
311 }
312 }
313}
314
315mod lazy;
316mod run;
317
318pub fn analyze_source(source: &str) -> AnalysisResult {
322 let php_version = PhpVersion::LATEST;
323 let file: Arc<str> = Arc::from("<source>");
324 let mut db = MirDbStorage::default();
325 db.set_php_version(Arc::from(php_version.to_string().as_str()));
326 crate::stubs::load_stubs_for_version(&mut db, php_version);
327 let salsa_file = SourceFile::new(&db, file.clone(), Arc::from(source));
328 let file_defs = collect_file_definitions(&db, salsa_file);
329 let suppressions = crate::suppression::SuppressionMap::from_source(source);
330 let mut all_issues = Arc::unwrap_or_clone(file_defs.issues);
331 if all_issues.iter().any(|issue| {
332 matches!(issue.kind, mir_issues::IssueKind::ParseError { .. })
333 && issue.severity == mir_issues::Severity::Error
334 }) {
335 mark_suppressed(&mut all_issues, &suppressions);
336 return AnalysisResult::build(all_issues, rustc_hash::FxHashMap::default(), Vec::new());
337 }
338 let mut type_envs = rustc_hash::FxHashMap::default();
339 let mut all_symbols = Vec::new();
340 let result = php_rs_parser::parse(source);
341
342 let driver = BodyAnalyzer::new(&db, php_version);
343 all_issues.extend(driver.analyze_bodies_typed(
344 &result.program,
345 file.clone(),
346 source,
347 &result.source_map,
348 &mut type_envs,
349 &mut all_symbols,
350 ));
351 mark_suppressed(&mut all_issues, &suppressions);
352 emit_unused_suppressions(&mut all_issues, &suppressions, &file);
353 AnalysisResult::build(all_issues, type_envs, all_symbols)
354}
355
356fn mark_suppressed(issues: &mut [Issue], suppressions: &crate::suppression::SuppressionMap) {
360 if suppressions.is_empty() {
361 return;
362 }
363 for issue in issues.iter_mut() {
364 if !issue.suppressed
365 && suppressions.is_suppressed(issue.location.line, issue.kind.name(), issue.kind.code())
366 {
367 issue.suppressed = true;
368 }
369 }
370}
371
372fn emit_unused_suppressions(
376 all_issues: &mut Vec<Issue>,
377 suppressions: &crate::suppression::SuppressionMap,
378 file: &std::sync::Arc<str>,
379) {
380 let pre_suppressed_cloned: Vec<Issue> = all_issues
381 .iter()
382 .filter(|i| i.suppressed)
383 .cloned()
384 .collect();
385 let pre_suppressed: Vec<&Issue> = pre_suppressed_cloned.iter().collect();
386 let unused = suppressions.unused_named(all_issues, &pre_suppressed);
387 for (line, kind) in unused {
388 let loc = mir_types::Location::new(file.clone(), line, line, 0, 0);
389 let mut issue = Issue::new(mir_issues::IssueKind::UnusedPsalmSuppress { kind }, loc);
390 if suppressions.is_suppressed(line, issue.kind.name(), issue.kind.code()) {
391 issue.suppressed = true;
392 }
393 all_issues.push(issue);
394 }
395}
396
397pub fn discover_files(root: &Path) -> Vec<PathBuf> {
399 if root.is_file() {
400 return vec![root.to_path_buf()];
401 }
402 let mut files = Vec::new();
403 collect_php_files(root, &mut files);
404 files
405}
406
407pub(crate) fn collect_php_files(dir: &Path, out: &mut Vec<PathBuf>) {
408 if let Ok(entries) = std::fs::read_dir(dir) {
409 for entry in entries.flatten() {
410 if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) {
411 continue;
412 }
413 let path = entry.path();
414 if path.is_dir() {
415 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
416 if matches!(
417 name,
418 "vendor" | ".git" | "node_modules" | ".cache" | ".pnpm-store"
419 ) {
420 continue;
421 }
422 collect_php_files(&path, out);
423 } else if path.extension().and_then(|e| e.to_str()) == Some("php") {
424 out.push(path);
425 }
426 }
427 }
428}
429
430pub(crate) fn collect_class_referenced_fqcns(class: &crate::db::ClassLike, out: &mut Vec<String>) {
437 if let Some(p) = class.parent() {
438 out.push(p.to_string());
439 }
440 for i in class.interfaces() {
441 out.push(i.to_string());
442 }
443 for e in class.extends() {
444 out.push(e.to_string());
445 }
446 for t in class.class_traits() {
447 out.push(t.to_string());
448 }
449 for m in class.mixins() {
450 out.push(m.to_string());
451 }
452 for u in class.extends_type_args() {
453 collect_fqcns_in_union(u, out);
454 }
455 for (iface, args) in class.implements_type_args() {
456 out.push(iface.to_string());
457 for u in args {
458 collect_fqcns_in_union(u, out);
459 }
460 }
461 for (_, m) in class.own_methods().iter() {
462 for p in m.params.iter() {
463 if let Some(t) = &p.ty {
464 collect_fqcns_in_union(t, out);
465 }
466 }
467 if let Some(t) = &m.return_type {
468 collect_fqcns_in_union(t, out);
469 }
470 for thrown in m.throws.iter() {
471 out.push(thrown.to_string());
472 }
473 }
474 if let Some(props) = class.own_properties() {
475 for (_, p) in props.iter() {
476 if let Some(t) = &p.ty {
477 collect_fqcns_in_union(t, out);
478 }
479 }
480 }
481 for (_, c) in class.own_constants().iter() {
482 collect_fqcns_in_union(&c.ty, out);
483 }
484}
485
486pub(crate) fn collect_fqcns_in_union(u: &Type, out: &mut Vec<String>) {
487 for atom in u.types.iter() {
488 collect_fqcns_in_atomic(atom, out);
489 }
490}
491
492fn collect_fqcns_in_simple(t: &mir_types::compact::SimpleType, out: &mut Vec<String>) {
493 if let mir_types::compact::SimpleType::Complex(u) = t {
494 collect_fqcns_in_union(u, out);
495 }
496}
497
498pub(crate) fn collect_fqcns_in_atomic(a: &Atomic, out: &mut Vec<String>) {
499 match a {
500 Atomic::TNamedObject { fqcn, type_params } => {
501 out.push(fqcn.to_string());
502 for tp in type_params.iter() {
503 collect_fqcns_in_union(tp, out);
504 }
505 }
506 Atomic::TStaticObject { fqcn } | Atomic::TSelf { fqcn } | Atomic::TParent { fqcn } => {
507 out.push(fqcn.to_string());
508 }
509 Atomic::TLiteralEnumCase { enum_fqcn, .. } => {
510 out.push(enum_fqcn.to_string());
511 }
512 Atomic::TClassString(Some(s)) => {
513 out.push(s.to_string());
514 }
515 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
516 collect_fqcns_in_union(key, out);
517 collect_fqcns_in_union(value, out);
518 }
519 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
520 collect_fqcns_in_union(value, out);
521 }
522 Atomic::TKeyedArray { properties, .. } => {
523 for (_, kp) in properties.iter() {
524 collect_fqcns_in_union(&kp.ty, out);
525 }
526 }
527 Atomic::TClosure {
528 params,
529 return_type,
530 this_type,
531 } => {
532 for p in params {
533 if let Some(t) = &p.ty {
534 collect_fqcns_in_simple(t, out);
535 }
536 }
537 collect_fqcns_in_union(return_type, out);
538 if let Some(t) = this_type {
539 collect_fqcns_in_union(t, out);
540 }
541 }
542 Atomic::TCallable {
543 params,
544 return_type,
545 } => {
546 if let Some(ps) = params {
547 for p in ps {
548 if let Some(t) = &p.ty {
549 collect_fqcns_in_simple(t, out);
550 }
551 }
552 }
553 if let Some(rt) = return_type {
554 collect_fqcns_in_union(rt, out);
555 }
556 }
557 Atomic::TIntersection { parts } => {
558 for p in parts.iter() {
559 collect_fqcns_in_union(p, out);
560 }
561 }
562 Atomic::TConditional {
563 param_name: _,
564 subject,
565 if_true,
566 if_false,
567 } => {
568 collect_fqcns_in_union(subject, out);
569 collect_fqcns_in_union(if_true, out);
570 collect_fqcns_in_union(if_false, out);
571 }
572 Atomic::TTemplateParam { as_type, .. } => {
573 collect_fqcns_in_union(as_type, out);
574 }
575 _ => {}
576 }
577}
578
579fn build_reverse_deps(db: &dyn crate::db::MirDatabase) -> HashMap<String, HashSet<String>> {
580 let mut reverse: HashMap<String, HashSet<String>> = HashMap::default();
581
582 let mut add_edge = |symbol: &str, dependent_file: &str| {
583 if let Some(defining_file) = db.symbol_defining_file(symbol) {
584 let def = defining_file.as_ref().to_string();
585 if def != dependent_file {
586 reverse
587 .entry(def)
588 .or_default()
589 .insert(dependent_file.to_string());
590 }
591 }
592 };
593
594 for (file, imports) in db.file_import_snapshots() {
595 let file = file.as_ref().to_string();
596 for fqcn in imports.values() {
597 add_edge(fqcn.as_str(), &file);
598 }
599 }
600
601 let extract_named_objects = |union: &mir_types::Type| {
602 union
603 .types
604 .iter()
605 .filter_map(|atomic| match atomic {
606 mir_types::atomic::Atomic::TNamedObject { fqcn, .. } => Some(*fqcn),
607 _ => None,
608 })
609 .collect::<Vec<_>>()
610 };
611
612 for fqcn in crate::db::workspace_classes(db).iter() {
613 let here = crate::db::Fqcn::from_str(db, fqcn.as_ref());
614 let Some(class) = crate::db::find_class_like(db, here) else {
615 continue;
616 };
617 if class.is_interface() || class.is_trait() || class.is_enum() {
618 continue;
619 }
620 let Some(file) = db
621 .symbol_defining_file(fqcn.as_ref())
622 .map(|f| f.as_ref().to_string())
623 .or_else(|| class.location().map(|l| l.file.as_ref().to_string()))
624 else {
625 continue;
626 };
627
628 if let Some(parent) = class.parent() {
629 add_edge(parent.as_ref(), &file);
630 }
631 for iface in class.interfaces().iter() {
632 add_edge(iface.as_ref(), &file);
633 }
634 for tr in class.class_traits().iter() {
635 add_edge(tr.as_ref(), &file);
636 }
637 if let Some(props) = class.own_properties() {
638 for (_, p) in props.iter() {
639 if let Some(ty) = &p.ty {
640 for named in extract_named_objects(ty) {
641 add_edge(named.as_ref(), &file);
642 }
643 }
644 }
645 }
646 for (_, method) in class.own_methods().iter() {
647 for param in method.params.iter() {
648 if let Some(ty) = ¶m.ty {
649 for named in extract_named_objects(ty.as_ref()) {
650 add_edge(named.as_ref(), &file);
651 }
652 }
653 }
654 if let Some(rt) = method.return_type.as_deref() {
655 for named in extract_named_objects(rt) {
656 add_edge(named.as_ref(), &file);
657 }
658 }
659 }
660 }
661
662 for fqn in crate::db::workspace_functions(db).iter() {
663 let here = crate::db::Fqcn::from_str(db, fqn.as_ref());
664 let Some(f) = crate::db::find_function(db, here) else {
665 continue;
666 };
667 let Some(file) = db
668 .symbol_defining_file(fqn.as_ref())
669 .map(|f| f.as_ref().to_string())
670 .or_else(|| f.location.as_ref().map(|l| l.file.as_ref().to_string()))
671 else {
672 continue;
673 };
674
675 for param in f.params.iter() {
676 if let Some(ty) = ¶m.ty {
677 for named in extract_named_objects(ty.as_ref()) {
678 add_edge(named.as_ref(), &file);
679 }
680 }
681 }
682 if let Some(rt) = f.return_type.as_deref() {
683 for named in extract_named_objects(rt) {
684 add_edge(named.as_ref(), &file);
685 }
686 }
687 }
688
689 for (ref_file, symbol_key) in db.all_reference_location_pairs() {
690 let file_str = ref_file.as_ref().to_string();
691 let lookup: &str = match symbol_key.split_once("::") {
692 Some((class, _)) => class,
693 None => &symbol_key,
694 };
695 add_edge(lookup, &file_str);
696 }
697
698 reverse
699}
700
701fn extract_reference_locations(
702 db: &dyn crate::db::MirDatabase,
703 file: &Arc<str>,
704) -> Vec<(String, u32, u16, u16)> {
705 db.extract_file_reference_locations(file.as_ref())
706 .into_iter()
707 .map(|(sym, line, col_start, col_end)| (sym.to_string(), line, col_start, col_end))
708 .collect()
709}
710
711pub struct AnalysisResult {
712 pub issues: Vec<Issue>,
713 #[doc(hidden)]
714 pub type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
715 pub symbols: Vec<crate::symbol::ResolvedSymbol>,
717 symbols_by_file: HashMap<Arc<str>, std::ops::Range<usize>>,
720}
721
722impl AnalysisResult {
723 fn build(
724 issues: Vec<Issue>,
725 type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
726 mut symbols: Vec<crate::symbol::ResolvedSymbol>,
727 ) -> Self {
728 symbols.sort_unstable_by(|a, b| a.file.as_ref().cmp(b.file.as_ref()));
729 let mut symbols_by_file: HashMap<Arc<str>, std::ops::Range<usize>> = HashMap::default();
730 let mut i = 0;
731 while i < symbols.len() {
732 let file = Arc::clone(&symbols[i].file);
733 let start = i;
734 while i < symbols.len() && symbols[i].file == file {
735 i += 1;
736 }
737 symbols_by_file.insert(file, start..i);
738 }
739 Self {
740 issues,
741 type_envs,
742 symbols,
743 symbols_by_file,
744 }
745 }
746
747 pub fn error_count(&self) -> usize {
748 self.issues
749 .iter()
750 .filter(|i| i.severity == mir_issues::Severity::Error)
751 .count()
752 }
753
754 pub fn warning_count(&self) -> usize {
755 self.issues
756 .iter()
757 .filter(|i| i.severity == mir_issues::Severity::Warning)
758 .count()
759 }
760
761 pub fn issues_by_file(&self) -> HashMap<Arc<str>, Vec<&Issue>> {
762 let mut map: HashMap<Arc<str>, Vec<&Issue>> = HashMap::default();
763 for issue in &self.issues {
764 map.entry(issue.location.file.clone())
765 .or_default()
766 .push(issue);
767 }
768 map
769 }
770
771 pub fn count_by_severity(&self) -> Vec<(mir_issues::Severity, usize)> {
772 let mut counts: std::collections::BTreeMap<mir_issues::Severity, usize> =
773 std::collections::BTreeMap::new();
774 for issue in &self.issues {
775 *counts.entry(issue.severity).or_insert(0) += 1;
776 }
777 counts.into_iter().collect()
778 }
779
780 pub fn total_issue_count(&self) -> usize {
781 self.issues.len()
782 }
783
784 pub fn filter_issues<'a, F>(&'a self, predicate: F) -> impl Iterator<Item = &'a Issue>
785 where
786 F: Fn(&Issue) -> bool + 'a,
787 {
788 self.issues.iter().filter(move |i| predicate(i))
789 }
790
791 pub fn symbol_at(
792 &self,
793 file: &str,
794 byte_offset: u32,
795 ) -> Option<&crate::symbol::ResolvedSymbol> {
796 let range = self.symbols_by_file.get(file)?;
797 let symbols = &self.symbols[range.clone()];
798
799 if let Some(sym) = symbols
801 .iter()
802 .filter(|s| s.span.start <= byte_offset && byte_offset < s.span.end)
803 .min_by_key(|s| s.span.end - s.span.start)
804 {
805 return Some(sym);
806 }
807
808 symbols
814 .iter()
815 .filter(|s| {
816 s.expr_span
817 .is_some_and(|es| es.start <= byte_offset && byte_offset < es.end)
818 })
819 .min_by_key(|s| {
820 let es = s.expr_span.unwrap();
821 es.end - es.start
822 })
823 }
824}