1use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::sync::OnceLock;
17
18use rayon::prelude::*;
19use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
20
21use mir_issues::Issue;
22use mir_types::{Atomic, Type};
23
24use crate::body_analysis::BodyAnalyzer;
25use crate::cache::{hash_content, surface_fingerprint};
26use crate::db::{
27 collect_file_definitions, FileDefinitions, MirDatabase, MirDbStorage, RefLoc, SourceFile,
28};
29use crate::php_version::PhpVersion;
30use crate::session::AnalysisSession;
31use crate::stub_cache::{hash_source, prepare_for_ingest};
32
33pub fn dead_code_issue_kinds() -> &'static [&'static str] {
40 &[
41 "UnusedMethod",
42 "UnusedProperty",
43 "UnusedFunction",
44 "UnusedClass",
45 ]
46}
47
48#[derive(Clone, Default)]
54pub struct BatchOptions {
55 pub suppressed_issue_kinds: HashSet<String>,
60 pub on_file_done: Option<Arc<dyn Fn() + Send + Sync>>,
62 pub php_version_override: Option<PhpVersion>,
65 pub skip_symbols: bool,
72}
73
74impl BatchOptions {
75 pub fn new() -> Self {
76 Self::default()
77 }
78
79 pub fn with_suppressed<I, S>(mut self, kinds: I) -> Self
80 where
81 I: IntoIterator<Item = S>,
82 S: Into<String>,
83 {
84 self.suppressed_issue_kinds = kinds.into_iter().map(Into::into).collect();
85 self
86 }
87
88 pub fn with_progress_callback(mut self, callback: Arc<dyn Fn() + Send + Sync>) -> Self {
89 self.on_file_done = Some(callback);
90 self
91 }
92
93 pub fn with_php_version(mut self, version: PhpVersion) -> Self {
94 self.php_version_override = Some(version);
95 self
96 }
97
98 pub fn without_symbols(mut self) -> Self {
102 self.skip_symbols = true;
103 self
104 }
105
106 fn should_run_dead_code(&self) -> bool {
109 dead_code_issue_kinds()
110 .iter()
111 .any(|k| !self.suppressed_issue_kinds.contains(*k))
112 }
113
114 fn apply(&self, issues: &mut Vec<Issue>) {
118 if self.suppressed_issue_kinds.is_empty() {
119 return;
120 }
121 issues.retain(|i| !self.suppressed_issue_kinds.contains(i.kind.display_name()));
122 }
123}
124
125struct ParsedProjectFile {
126 file: Arc<str>,
127 source: Arc<str>,
128 parsed: php_rs_parser::ParseResult,
129}
130
131impl ParsedProjectFile {
132 fn new(file: Arc<str>, source: Arc<str>) -> Self {
133 let parsed = php_rs_parser::parse(source.as_ref());
134 Self {
135 file,
136 source,
137 parsed,
138 }
139 }
140
141 fn source(&self) -> &str {
142 self.source.as_ref()
143 }
144
145 fn source_map(&self) -> &php_rs_parser::source_map::SourceMap {
146 &self.parsed.source_map
147 }
148
149 fn errors(&self) -> &[php_rs_parser::diagnostics::ParseError] {
150 &self.parsed.errors
151 }
152
153 fn owned(&self) -> &php_ast::owned::Program {
154 &self.parsed.program
155 }
156}
157
158impl AnalysisSession {
159 fn suppression_map_for_file(
160 &self,
161 db: &dyn MirDatabase,
162 file: &Arc<str>,
163 ) -> Option<Arc<crate::suppression::SuppressionMap>> {
164 let sf = db.lookup_source_file(file)?;
165 let text = sf.text(db).clone();
166 if let Some((_, map)) = self
167 .suppression_maps
168 .read()
169 .get(file)
170 .filter(|(cached_text, _)| cached_text.as_ref() == text.as_ref())
171 {
172 return Some(map.clone());
173 }
174
175 let map = Arc::new(crate::suppression::SuppressionMap::from_source(
176 text.as_ref(),
177 ));
178 self.suppression_maps
179 .write()
180 .insert(file.clone(), (text, map.clone()));
181 Some(map)
182 }
183
184 #[doc(hidden)]
187 pub fn stub_cache_stats(&self) -> (u64, u64) {
188 match self.db.stub_cache.as_deref() {
189 Some(c) => (c.hits(), c.misses()),
190 None => (0, 0),
191 }
192 }
193
194 fn batch_php_version(&self, opts: &BatchOptions) -> PhpVersion {
195 opts.php_version_override.unwrap_or(self.php_version)
196 }
197
198 pub(crate) fn apply_suppressions_and_emit_unused(
216 &self,
217 issues: &mut Vec<Issue>,
218 analyzed_files: &[Arc<str>],
219 ) {
220 let db = self.snapshot_db();
221 let mut cache: HashMap<Arc<str>, Option<Arc<crate::suppression::SuppressionMap>>> =
222 HashMap::default();
223 for issue in issues.iter_mut() {
224 if issue.suppressed {
225 continue;
226 }
227 let map = cache
228 .entry(issue.location.file.clone())
229 .or_insert_with(|| self.suppression_map_for_file(&db, &issue.location.file));
230 if let Some(map) = map.as_ref() {
231 if map.is_suppressed(
232 issue.location.line,
233 issue.kind.display_name(),
234 issue.kind.code(),
235 ) {
236 issue.suppressed = true;
237 }
238 }
239 }
240 for file in analyzed_files {
244 cache
245 .entry(file.clone())
246 .or_insert_with(|| self.suppression_map_for_file(&db, file));
247 }
248 let files: Vec<Arc<str>> = cache
250 .iter()
251 .filter_map(|(f, m)| m.as_ref().map(|_| f.clone()))
252 .collect();
253 let mut issues_by_file: HashMap<&str, Vec<&Issue>> = HashMap::default();
256 for issue in issues.iter() {
257 issues_by_file
258 .entry(issue.location.file.as_ref())
259 .or_default()
260 .push(issue);
261 }
262 let mut new_issues: Vec<Issue> = Vec::new();
263 for file in files {
264 if let Some(Some(map)) = cache.get(&file) {
265 if map.named_suppressions.is_empty() {
266 continue;
267 }
268 let file_issues: &[&Issue] = issues_by_file
269 .get(file.as_ref())
270 .map(Vec::as_slice)
271 .unwrap_or(&[]);
272 let pre_suppressed: Vec<&Issue> = file_issues
277 .iter()
278 .filter(|i| i.suppressed)
279 .copied()
280 .collect();
281 let unused = map.unused_named(file_issues, &pre_suppressed);
286 for (line, kind) in unused {
287 let loc = mir_types::Location::new(file.clone(), line, line, 0, 0);
288 let mut issue = Issue::new(mir_issues::IssueKind::UnusedSuppress { kind }, loc);
289 if map.is_suppressed(line, issue.kind.display_name(), issue.kind.code()) {
290 issue.suppressed = true;
291 }
292 new_issues.push(issue);
293 }
294 }
295 }
296 issues.extend(new_issues);
297 }
298
299 fn type_exists(&self, fqcn: &str) -> bool {
300 let db = self.snapshot_db();
301 crate::db::class_exists(&db, fqcn)
302 }
303
304 fn collect_and_ingest_source(
305 &mut self,
306 file: Arc<str>,
307 src: &str,
308 php_version: PhpVersion,
309 ) -> FileDefinitions {
310 self.db.collect_and_ingest_file(file, src, php_version)
311 }
312
313 fn refresh_workspace_index(&mut self) {
321 let guard = &mut self.db.salsa;
322 guard.rebuild_workspace_symbol_index();
323 }
324
325 fn load_batch_stubs(&mut self, php_version: PhpVersion) {
329 {
332 let version_str = Arc::from(php_version.to_string());
333 self.db.salsa.set_php_version(version_str);
334 }
335
336 let paths: Vec<&'static str> = crate::stubs::stub_files().iter().map(|&(p, _)| p).collect();
338 self.db.ingest_stub_paths(&paths, php_version);
339
340 self.db
342 .ingest_user_stubs(&self.user_stub_files, &self.user_stub_dirs);
343
344 let guard = &mut self.db.salsa;
347 if guard.current_resolver().is_none() {
348 let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::StubClassResolver);
349 guard.set_resolver(Some(resolver));
350 }
351 }
352}
353
354mod lazy;
355mod run;
356
357pub fn analyze_source(source: &str) -> AnalysisResult {
365 analyze_source_with_options(source, &BatchOptions::new())
366}
367
368pub fn analyze_source_with_options(source: &str, opts: &BatchOptions) -> AnalysisResult {
374 use mir_issues::Issue;
375
376 let php_version = PhpVersion::LATEST;
377 let file: Arc<str> = Arc::from("<source>");
378 let mut db = MirDbStorage::default();
379 db.set_php_version(Arc::from(php_version.to_string()));
380 crate::stubs::load_stubs_for_version(&mut db, php_version);
381 db.upsert_source_file(file.clone(), Arc::from(source));
387 let parsed = php_rs_parser::parse(source);
388 let mut collected_issues: Vec<Issue> = parsed
389 .errors
390 .iter()
391 .filter(|err| !crate::parser::is_spurious_reserved_class_error(err))
392 .map(|err| crate::parser::parse_error_to_issue(err, &file, source, &parsed.source_map))
393 .collect();
394 let collector = crate::collector::DefinitionCollector::new_for_slice(
395 file.clone(),
396 source,
397 &parsed.source_map,
398 )
399 .with_php_version(php_version);
400 let (mut slice, collector_issues) = collector.collect_slice(&parsed.program);
401 collected_issues.extend(collector_issues);
402 mir_codebase::definitions::deduplicate_params_in_slice(&mut slice);
403 let slice_arc = Arc::new(slice);
404 let issues_arc = Arc::new(collected_issues);
405 db.prime_parse_cache(
406 crate::stub_cache::hash_source(source),
407 php_version.cache_byte(),
408 Arc::clone(&slice_arc),
409 Arc::clone(&issues_arc),
410 );
411 let _file_defs = crate::db::FileDefinitions {
412 slice: slice_arc,
413 issues: issues_arc.clone(),
414 };
415 let suppressions = crate::suppression::SuppressionMap::from_source(source);
416 let mut all_issues = Arc::unwrap_or_clone(issues_arc);
417 if all_issues.iter().any(|issue| {
418 matches!(issue.kind, mir_issues::IssueKind::ParseError { .. })
419 && issue.severity == mir_issues::Severity::Error
420 }) {
421 mark_suppressed(&mut all_issues, &suppressions);
422 return AnalysisResult::build(all_issues, rustc_hash::FxHashMap::default(), Vec::new());
423 }
424 let mut type_envs = rustc_hash::FxHashMap::default();
425 let mut all_symbols = Vec::new();
426 let mut driver = BodyAnalyzer::new(&db, php_version);
427 driver.collect_symbols = !opts.skip_symbols;
428 all_issues.extend(driver.analyze_bodies_typed(
429 &parsed.program,
430 file.clone(),
431 source,
432 &parsed.source_map,
433 &mut type_envs,
434 &mut all_symbols,
435 ));
436 if let Some(plugins) = mir_plugin::snapshot() {
437 if plugins.hooks().before_add_issue {
438 all_issues.retain(|i| plugins.before_add_issue(i));
439 }
440 }
441 mark_suppressed(&mut all_issues, &suppressions);
442 emit_unused_suppressions(&mut all_issues, &suppressions, &file);
443 opts.apply(&mut all_issues);
444 AnalysisResult::build(all_issues, type_envs, all_symbols)
445}
446
447fn mark_suppressed(issues: &mut [Issue], suppressions: &crate::suppression::SuppressionMap) {
451 if suppressions.is_empty() {
452 return;
453 }
454 for issue in issues.iter_mut() {
455 if !issue.suppressed
456 && suppressions.is_suppressed(
457 issue.location.line,
458 issue.kind.display_name(),
459 issue.kind.code(),
460 )
461 {
462 issue.suppressed = true;
463 }
464 }
465}
466
467fn emit_unused_suppressions(
471 all_issues: &mut Vec<Issue>,
472 suppressions: &crate::suppression::SuppressionMap,
473 file: &std::sync::Arc<str>,
474) {
475 let all_refs: Vec<&Issue> = all_issues.iter().collect();
476 let pre_suppressed: Vec<&Issue> = all_refs.iter().filter(|i| i.suppressed).copied().collect();
477 let unused = suppressions.unused_named(&all_refs, &pre_suppressed);
478 for (line, kind) in unused {
479 let loc = mir_types::Location::new(file.clone(), line, line, 0, 0);
480 let mut issue = Issue::new(mir_issues::IssueKind::UnusedSuppress { kind }, loc);
481 if suppressions.is_suppressed(line, issue.kind.display_name(), issue.kind.code()) {
482 issue.suppressed = true;
483 }
484 all_issues.push(issue);
485 }
486}
487
488pub fn discover_files(root: &Path) -> Vec<PathBuf> {
490 if root.is_file() {
491 return vec![root.to_path_buf()];
492 }
493 let mut files = Vec::new();
494 collect_php_files(root, &mut files);
495 files
496}
497
498pub(crate) fn collect_php_files(dir: &Path, out: &mut Vec<PathBuf>) {
499 if let Ok(entries) = std::fs::read_dir(dir) {
500 for entry in entries.flatten() {
501 if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) {
502 continue;
503 }
504 let path = entry.path();
505 if path.is_dir() {
506 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
507 if matches!(
508 name,
509 "vendor" | ".git" | "node_modules" | ".cache" | ".pnpm-store"
510 ) {
511 continue;
512 }
513 collect_php_files(&path, out);
514 } else if path.extension().and_then(|e| e.to_str()) == Some("php") {
515 out.push(path);
516 }
517 }
518 }
519}
520
521pub(crate) fn collect_class_referenced_fqcns(class: &crate::db::ClassLike, out: &mut Vec<String>) {
528 if let Some(p) = class.parent() {
529 out.push(p.to_string());
530 }
531 for i in class.interfaces() {
532 out.push(i.to_string());
533 }
534 for e in class.extends() {
535 out.push(e.to_string());
536 }
537 for t in class.class_traits() {
538 out.push(t.to_string());
539 }
540 for m in class.mixins() {
541 out.push(m.to_string());
542 }
543 for u in class.extends_type_args() {
544 collect_fqcns_in_union(u, out);
545 }
546 for (iface, args) in class.implements_type_args() {
547 out.push(iface.to_string());
548 for u in args {
549 collect_fqcns_in_union(u, out);
550 }
551 }
552 for (_, m) in class.own_methods().iter() {
553 for p in m.params.iter() {
554 if let Some(t) = &p.ty {
555 collect_fqcns_in_union(t, out);
556 }
557 }
558 if let Some(t) = &m.return_type {
559 collect_fqcns_in_union(t, out);
560 }
561 for thrown in m.throws.iter() {
562 out.push(thrown.to_string());
563 }
564 }
565 if let Some(props) = class.own_properties() {
566 for (_, p) in props.iter() {
567 if let Some(t) = &p.ty {
568 collect_fqcns_in_union(t, out);
569 }
570 }
571 }
572 for (_, c) in class.own_constants().iter() {
573 collect_fqcns_in_union(&c.ty, out);
574 }
575}
576
577pub(crate) fn collect_fqcns_in_union(u: &Type, out: &mut Vec<String>) {
578 for atom in u.types.iter() {
579 collect_fqcns_in_atomic(atom, out);
580 }
581}
582
583fn collect_fqcns_in_simple(t: &mir_types::compact::SimpleType, out: &mut Vec<String>) {
584 if let mir_types::compact::SimpleType::Complex(u) = t {
585 collect_fqcns_in_union(u, out);
586 }
587}
588
589pub(crate) fn collect_fqcns_in_atomic(a: &Atomic, out: &mut Vec<String>) {
590 match a {
591 Atomic::TNamedObject { fqcn, type_params } => {
592 out.push(fqcn.to_string());
593 for tp in type_params.iter() {
594 collect_fqcns_in_union(tp, out);
595 }
596 }
597 Atomic::TStaticObject { fqcn } | Atomic::TSelf { fqcn } | Atomic::TParent { fqcn } => {
598 out.push(fqcn.to_string());
599 }
600 Atomic::TLiteralEnumCase { enum_fqcn, .. } => {
601 out.push(enum_fqcn.to_string());
602 }
603 Atomic::TClassString(Some(s)) | Atomic::TInterfaceString(Some(s)) => {
604 out.push(s.to_string());
605 }
606 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
607 collect_fqcns_in_union(key, out);
608 collect_fqcns_in_union(value, out);
609 }
610 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
611 collect_fqcns_in_union(value, out);
612 }
613 Atomic::TKeyedArray { properties, .. } => {
614 for (_, kp) in properties.iter() {
615 collect_fqcns_in_union(&kp.ty, out);
616 }
617 }
618 Atomic::TClosure { data } => {
619 for p in data.params.iter() {
620 if let Some(t) = &p.ty {
621 collect_fqcns_in_simple(t, out);
622 }
623 }
624 collect_fqcns_in_union(&data.return_type, out);
625 if let Some(t) = &data.this_type {
626 collect_fqcns_in_union(t, out);
627 }
628 }
629 Atomic::TCallable {
630 params,
631 return_type,
632 } => {
633 if let Some(ps) = params {
634 for p in ps {
635 if let Some(t) = &p.ty {
636 collect_fqcns_in_simple(t, out);
637 }
638 }
639 }
640 if let Some(rt) = return_type {
641 collect_fqcns_in_union(rt, out);
642 }
643 }
644 Atomic::TIntersection { parts } => {
645 for p in parts.iter() {
646 collect_fqcns_in_union(p, out);
647 }
648 }
649 Atomic::TConditional { data } => {
650 collect_fqcns_in_union(&data.subject, out);
651 collect_fqcns_in_union(&data.if_true, out);
652 collect_fqcns_in_union(&data.if_false, out);
653 }
654 Atomic::TTemplateParam { as_type, .. } => {
655 collect_fqcns_in_union(as_type, out);
656 }
657 _ => {}
658 }
659}
660
661fn build_reverse_deps(db: &dyn crate::db::MirDatabase) -> HashMap<String, HashSet<String>> {
662 let mut reverse: HashMap<String, HashSet<String>> = HashMap::default();
663
664 let mut add_symbol_edge = |symbol_key: &str, dependent_file: &str| {
665 let lookup = crate::defining_file_lookup_key(symbol_key);
666 if let Some(defining_file) = db.symbol_defining_file(lookup) {
667 let def = defining_file.as_ref().to_string();
668 if def != dependent_file {
669 reverse
670 .entry(def)
671 .or_default()
672 .insert(dependent_file.to_string());
673 }
674 }
675 };
676
677 for sf in db.all_source_files() {
678 let file = sf.path(db);
679 for symbol in crate::db::file_structural_symbols(db, sf).iter() {
680 add_symbol_edge(symbol, file.as_ref());
681 }
682 }
683
684 for (ref_file, symbol_key) in db.all_reference_location_pairs() {
685 add_symbol_edge(&symbol_key, ref_file.as_ref());
686 }
687
688 reverse
689}
690
691fn extract_reference_locations(
692 db: &dyn crate::db::MirDatabase,
693 file: &Arc<str>,
694) -> Arc<[crate::cache::CachedRefLoc]> {
695 db.extract_file_reference_locations(file.as_ref()).into()
696}
697
698pub struct AnalysisResult {
699 pub issues: Vec<Issue>,
700 #[doc(hidden)]
701 pub type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
702 pub symbols: Vec<crate::symbol::ResolvedSymbol>,
706 symbols_by_file: OnceLock<HashMap<Arc<str>, Vec<usize>>>,
710}
711
712impl AnalysisResult {
713 fn build(
714 issues: Vec<Issue>,
715 type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
716 symbols: Vec<crate::symbol::ResolvedSymbol>,
717 ) -> Self {
718 Self {
719 issues,
720 type_envs,
721 symbols,
722 symbols_by_file: OnceLock::new(),
723 }
724 }
725
726 fn symbols_by_file(&self) -> &HashMap<Arc<str>, Vec<usize>> {
727 self.symbols_by_file.get_or_init(|| {
728 let mut by_file: HashMap<Arc<str>, Vec<usize>> = HashMap::default();
729 for (idx, symbol) in self.symbols.iter().enumerate() {
730 by_file.entry(symbol.file.clone()).or_default().push(idx);
731 }
732 by_file
733 })
734 }
735
736 pub fn error_count(&self) -> usize {
737 self.issues
738 .iter()
739 .filter(|i| i.severity == mir_issues::Severity::Error)
740 .count()
741 }
742
743 pub fn warning_count(&self) -> usize {
744 self.issues
745 .iter()
746 .filter(|i| i.severity == mir_issues::Severity::Warning)
747 .count()
748 }
749
750 pub fn issues_by_file(&self) -> HashMap<Arc<str>, Vec<&Issue>> {
751 let mut map: HashMap<Arc<str>, Vec<&Issue>> = HashMap::default();
752 for issue in &self.issues {
753 map.entry(issue.location.file.clone())
754 .or_default()
755 .push(issue);
756 }
757 map
758 }
759
760 pub fn count_by_severity(&self) -> Vec<(mir_issues::Severity, usize)> {
761 let mut counts: std::collections::BTreeMap<mir_issues::Severity, usize> =
762 std::collections::BTreeMap::new();
763 for issue in &self.issues {
764 *counts.entry(issue.severity).or_insert(0) += 1;
765 }
766 counts.into_iter().collect()
767 }
768
769 pub fn total_issue_count(&self) -> usize {
770 self.issues.len()
771 }
772
773 pub fn filter_issues<'a, F>(&'a self, predicate: F) -> impl Iterator<Item = &'a Issue>
774 where
775 F: Fn(&Issue) -> bool + 'a,
776 {
777 self.issues.iter().filter(move |i| predicate(i))
778 }
779
780 pub fn symbol_at(
781 &self,
782 file: &str,
783 byte_offset: u32,
784 ) -> Option<&crate::symbol::ResolvedSymbol> {
785 let indices = self.symbols_by_file().get(file)?;
786
787 if let Some(sym) = indices
789 .iter()
790 .map(|idx| &self.symbols[*idx])
791 .filter(|s| s.span.start <= byte_offset && byte_offset < s.span.end)
792 .min_by_key(|s| s.span.end - s.span.start)
793 {
794 return Some(sym);
795 }
796
797 indices
803 .iter()
804 .map(|idx| &self.symbols[*idx])
805 .filter(|s| {
806 s.expr_span
807 .is_some_and(|es| es.start <= byte_offset && byte_offset < es.end)
808 })
809 .min_by_key(|s| {
810 let es = s.expr_span.unwrap();
811 es.end - es.start
812 })
813 }
814}