1use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::sync::OnceLock;
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, surface_fingerprint};
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>) {
117 if self.suppressed_issue_kinds.is_empty() {
118 return;
119 }
120 issues.retain(|i| !self.suppressed_issue_kinds.contains(i.kind.display_name()));
121 }
122}
123
124struct ParsedProjectFile {
125 file: Arc<str>,
126 source: Arc<str>,
127 parsed: php_rs_parser::ParseResult,
128}
129
130impl ParsedProjectFile {
131 fn new(file: Arc<str>, source: Arc<str>) -> Self {
132 let parsed = php_rs_parser::parse(source.as_ref());
133 Self {
134 file,
135 source,
136 parsed,
137 }
138 }
139
140 fn source(&self) -> &str {
141 self.source.as_ref()
142 }
143
144 fn source_map(&self) -> &php_rs_parser::source_map::SourceMap {
145 &self.parsed.source_map
146 }
147
148 fn errors(&self) -> &[php_rs_parser::diagnostics::ParseError] {
149 &self.parsed.errors
150 }
151
152 fn owned(&self) -> &php_ast::owned::Program {
153 &self.parsed.program
154 }
155}
156
157impl AnalysisSession {
158 fn suppression_map_for_file(
159 &mut self,
160 db: &dyn MirDatabase,
161 file: &Arc<str>,
162 ) -> Option<Arc<crate::suppression::SuppressionMap>> {
163 let sf = db.lookup_source_file(file)?;
164 let text = sf.text(db).clone();
165 if let Some((_, map)) = self
166 .suppression_maps
167 .get(file)
168 .filter(|(cached_text, _)| cached_text.as_ref() == text.as_ref())
169 {
170 return Some(map.clone());
171 }
172
173 let map = Arc::new(crate::suppression::SuppressionMap::from_source(
174 text.as_ref(),
175 ));
176 self.suppression_maps
177 .insert(file.clone(), (text, map.clone()));
178 Some(map)
179 }
180
181 #[doc(hidden)]
184 pub fn stub_cache_stats(&self) -> (u64, u64) {
185 match self.db.stub_cache.as_deref() {
186 Some(c) => (c.hits(), c.misses()),
187 None => (0, 0),
188 }
189 }
190
191 fn batch_php_version(&self, opts: &BatchOptions) -> PhpVersion {
192 opts.php_version_override.unwrap_or(self.php_version)
193 }
194
195 pub(crate) fn apply_suppressions_and_emit_unused(
213 &mut self,
214 issues: &mut Vec<Issue>,
215 analyzed_files: &[Arc<str>],
216 ) {
217 let db = self.snapshot_db();
218 let mut cache: HashMap<Arc<str>, Option<Arc<crate::suppression::SuppressionMap>>> =
219 HashMap::default();
220 for issue in issues.iter_mut() {
221 if issue.suppressed {
222 continue;
223 }
224 let map = cache
225 .entry(issue.location.file.clone())
226 .or_insert_with(|| self.suppression_map_for_file(&db, &issue.location.file));
227 if let Some(map) = map.as_ref() {
228 if map.is_suppressed(
229 issue.location.line,
230 issue.kind.display_name(),
231 issue.kind.code(),
232 ) {
233 issue.suppressed = true;
234 }
235 }
236 }
237 for file in analyzed_files {
241 cache
242 .entry(file.clone())
243 .or_insert_with(|| self.suppression_map_for_file(&db, file));
244 }
245 let files: Vec<Arc<str>> = cache
247 .iter()
248 .filter_map(|(f, m)| m.as_ref().map(|_| f.clone()))
249 .collect();
250 let mut issues_by_file: HashMap<&str, Vec<&Issue>> = HashMap::default();
253 for issue in issues.iter() {
254 issues_by_file
255 .entry(issue.location.file.as_ref())
256 .or_default()
257 .push(issue);
258 }
259 let mut new_issues: Vec<Issue> = Vec::new();
260 for file in files {
261 if let Some(Some(map)) = cache.get(&file) {
262 if map.named_suppressions.is_empty() {
263 continue;
264 }
265 let file_issues: &[&Issue] = issues_by_file
266 .get(file.as_ref())
267 .map(Vec::as_slice)
268 .unwrap_or(&[]);
269 let pre_suppressed: Vec<&Issue> = file_issues
274 .iter()
275 .filter(|i| i.suppressed)
276 .copied()
277 .collect();
278 let unused = map.unused_named(file_issues, &pre_suppressed);
283 for (line, kind) in unused {
284 let loc = mir_types::Location::new(file.clone(), line, line, 0, 0);
285 let mut issue = Issue::new(mir_issues::IssueKind::UnusedSuppress { kind }, loc);
286 if map.is_suppressed(line, issue.kind.display_name(), issue.kind.code()) {
287 issue.suppressed = true;
288 }
289 new_issues.push(issue);
290 }
291 }
292 }
293 issues.extend(new_issues);
294 }
295
296 fn type_exists(&self, fqcn: &str) -> bool {
297 let db = self.snapshot_db();
298 crate::db::class_exists(&db, fqcn)
299 }
300
301 fn collect_and_ingest_source(
302 &mut self,
303 file: Arc<str>,
304 src: &str,
305 php_version: PhpVersion,
306 ) -> FileDefinitions {
307 self.db.collect_and_ingest_file(file, src, php_version)
308 }
309
310 fn refresh_workspace_index(&mut self) {
318 self.db.salsa.rebuild_workspace_symbol_index();
319 }
320
321 fn load_batch_stubs(&mut self, php_version: PhpVersion) {
325 {
328 let version_str = Arc::from(php_version.to_string());
329 self.db.salsa.set_php_version(version_str);
330 }
331
332 let paths: Vec<&'static str> = crate::stubs::stub_files().iter().map(|&(p, _)| p).collect();
334 self.db.ingest_stub_paths(&paths);
335
336 self.db
338 .ingest_user_stubs(&self.user_stub_files, &self.user_stub_dirs);
339
340 let db = &mut self.db.salsa;
343 if db.current_resolver().is_none() {
344 let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::StubClassResolver);
345 db.set_resolver(Some(resolver));
346 }
347 }
348}
349
350mod lazy;
351mod run;
352
353pub fn analyze_source(source: &str) -> AnalysisResult {
360 analyze_source_with_options(source, &BatchOptions::new())
361}
362
363pub fn analyze_source_with_options(source: &str, opts: &BatchOptions) -> AnalysisResult {
369 use mir_issues::Issue;
370
371 let php_version = PhpVersion::LATEST;
372 let file: Arc<str> = Arc::from("<source>");
373 let mut db = MirDbStorage::default();
374 db.set_php_version(Arc::from(php_version.to_string()));
375 crate::stubs::load_stubs_for_version(&mut db, php_version);
376 db.upsert_source_file(file.clone(), Arc::from(source));
382 let parsed = php_rs_parser::parse(source);
383 let mut collected_issues: Vec<Issue> = parsed
384 .errors
385 .iter()
386 .filter(|err| !crate::parser::is_spurious_reserved_class_error(err))
387 .map(|err| crate::parser::parse_error_to_issue(err, &file, source, &parsed.source_map))
388 .collect();
389 let collector = crate::collector::DefinitionCollector::new_for_slice(
390 file.clone(),
391 source,
392 &parsed.source_map,
393 )
394 .with_php_version(php_version);
395 let (mut slice, collector_issues) = collector.collect_slice(&parsed.program);
396 collected_issues.extend(collector_issues);
397 mir_codebase::definitions::deduplicate_params_in_slice(&mut slice);
398 let slice_arc = Arc::new(slice);
399 let issues_arc = Arc::new(collected_issues);
400 db.prime_parse_cache(
401 crate::stub_cache::hash_source(source),
402 php_version.cache_byte(),
403 Arc::clone(&slice_arc),
404 Arc::clone(&issues_arc),
405 );
406 let _file_defs = crate::db::FileDefinitions {
407 slice: slice_arc,
408 issues: issues_arc.clone(),
409 };
410 let suppressions = crate::suppression::SuppressionMap::from_source(source);
411 let mut all_issues = Arc::unwrap_or_clone(issues_arc);
412 if all_issues.iter().any(|issue| {
413 matches!(issue.kind, mir_issues::IssueKind::ParseError { .. })
414 && issue.severity == mir_issues::Severity::Error
415 }) {
416 mark_suppressed(&mut all_issues, &suppressions);
417 return AnalysisResult::build(all_issues, rustc_hash::FxHashMap::default(), Vec::new());
418 }
419 let mut type_envs = rustc_hash::FxHashMap::default();
420 let mut all_symbols = Vec::new();
421 let mut driver = BodyAnalyzer::new(&db, php_version);
422 driver.collect_symbols = !opts.skip_symbols;
423 all_issues.extend(driver.analyze_bodies_typed(
424 &parsed.program,
425 file.clone(),
426 source,
427 &parsed.source_map,
428 &mut type_envs,
429 &mut all_symbols,
430 ));
431 if let Some(plugins) = mir_plugin::snapshot() {
432 if plugins.hooks().before_add_issue {
433 all_issues.retain(|i| plugins.before_add_issue(i));
434 }
435 }
436 mark_suppressed(&mut all_issues, &suppressions);
437 emit_unused_suppressions(&mut all_issues, &suppressions, &file);
438 opts.apply(&mut all_issues);
439 AnalysisResult::build(all_issues, type_envs, all_symbols)
440}
441
442fn mark_suppressed(issues: &mut [Issue], suppressions: &crate::suppression::SuppressionMap) {
446 if suppressions.is_empty() {
447 return;
448 }
449 for issue in issues.iter_mut() {
450 if !issue.suppressed
451 && suppressions.is_suppressed(
452 issue.location.line,
453 issue.kind.display_name(),
454 issue.kind.code(),
455 )
456 {
457 issue.suppressed = true;
458 }
459 }
460}
461
462fn emit_unused_suppressions(
466 all_issues: &mut Vec<Issue>,
467 suppressions: &crate::suppression::SuppressionMap,
468 file: &std::sync::Arc<str>,
469) {
470 let all_refs: Vec<&Issue> = all_issues.iter().collect();
471 let pre_suppressed: Vec<&Issue> = all_refs.iter().filter(|i| i.suppressed).copied().collect();
472 let unused = suppressions.unused_named(&all_refs, &pre_suppressed);
473 for (line, kind) in unused {
474 let loc = mir_types::Location::new(file.clone(), line, line, 0, 0);
475 let mut issue = Issue::new(mir_issues::IssueKind::UnusedSuppress { kind }, loc);
476 if suppressions.is_suppressed(line, issue.kind.display_name(), issue.kind.code()) {
477 issue.suppressed = true;
478 }
479 all_issues.push(issue);
480 }
481}
482
483pub fn discover_files(root: &Path) -> Vec<PathBuf> {
485 if root.is_file() {
486 return vec![root.to_path_buf()];
487 }
488 let mut files = Vec::new();
489 collect_php_files(root, &mut files);
490 files
491}
492
493pub(crate) fn collect_php_files(dir: &Path, out: &mut Vec<PathBuf>) {
494 if let Ok(entries) = std::fs::read_dir(dir) {
495 for entry in entries.flatten() {
496 if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) {
497 continue;
498 }
499 let path = entry.path();
500 if path.is_dir() {
501 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
502 if matches!(
503 name,
504 "vendor" | ".git" | "node_modules" | ".cache" | ".pnpm-store"
505 ) {
506 continue;
507 }
508 collect_php_files(&path, out);
509 } else if path.extension().and_then(|e| e.to_str()) == Some("php") {
510 out.push(path);
511 }
512 }
513 }
514}
515
516pub(crate) fn collect_class_referenced_fqcns(class: &crate::db::ClassLike, out: &mut Vec<String>) {
523 if let Some(p) = class.parent() {
524 out.push(p.to_string());
525 }
526 for i in class.interfaces() {
527 out.push(i.to_string());
528 }
529 for e in class.extends() {
530 out.push(e.to_string());
531 }
532 for t in class.class_traits() {
533 out.push(t.to_string());
534 }
535 for m in class.mixins() {
536 out.push(m.to_string());
537 }
538 for u in class.extends_type_args() {
539 collect_fqcns_in_union(u, out);
540 }
541 for (iface, args) in class.implements_type_args() {
542 out.push(iface.to_string());
543 for u in args {
544 collect_fqcns_in_union(u, out);
545 }
546 }
547 for (_, m) in class.own_methods().iter() {
548 for p in m.params.iter() {
549 if let Some(t) = &p.ty {
550 collect_fqcns_in_union(t, out);
551 }
552 }
553 if let Some(t) = &m.return_type {
554 collect_fqcns_in_union(t, out);
555 }
556 for thrown in m.throws.iter() {
557 out.push(thrown.to_string());
558 }
559 }
560 if let Some(props) = class.own_properties() {
561 for (_, p) in props.iter() {
562 if let Some(t) = &p.ty {
563 collect_fqcns_in_union(t, out);
564 }
565 }
566 }
567 for (_, c) in class.own_constants().iter() {
568 collect_fqcns_in_union(&c.ty, out);
569 }
570}
571
572pub(crate) fn collect_fqcns_in_union(u: &Type, out: &mut Vec<String>) {
573 for atom in u.types.iter() {
574 collect_fqcns_in_atomic(atom, out);
575 }
576}
577
578fn collect_fqcns_in_simple(t: &mir_types::compact::SimpleType, out: &mut Vec<String>) {
579 if let mir_types::compact::SimpleType::Complex(u) = t {
580 collect_fqcns_in_union(u, out);
581 }
582}
583
584pub(crate) fn collect_fqcns_in_atomic(a: &Atomic, out: &mut Vec<String>) {
585 match a {
586 Atomic::TNamedObject { fqcn, type_params } => {
587 out.push(fqcn.to_string());
588 for tp in type_params.iter() {
589 collect_fqcns_in_union(tp, out);
590 }
591 }
592 Atomic::TStaticObject { fqcn } | Atomic::TSelf { fqcn } | Atomic::TParent { fqcn } => {
593 out.push(fqcn.to_string());
594 }
595 Atomic::TLiteralEnumCase { enum_fqcn, .. } => {
596 out.push(enum_fqcn.to_string());
597 }
598 Atomic::TClassString(Some(s)) | Atomic::TInterfaceString(Some(s)) => {
599 out.push(s.to_string());
600 }
601 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
602 collect_fqcns_in_union(key, out);
603 collect_fqcns_in_union(value, out);
604 }
605 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
606 collect_fqcns_in_union(value, out);
607 }
608 Atomic::TKeyedArray { properties, .. } => {
609 for (_, kp) in properties.iter() {
610 collect_fqcns_in_union(&kp.ty, out);
611 }
612 }
613 Atomic::TClosure { data } => {
614 for p in data.params.iter() {
615 if let Some(t) = &p.ty {
616 collect_fqcns_in_simple(t, out);
617 }
618 }
619 collect_fqcns_in_union(&data.return_type, out);
620 if let Some(t) = &data.this_type {
621 collect_fqcns_in_union(t, out);
622 }
623 }
624 Atomic::TCallable {
625 params,
626 return_type,
627 } => {
628 if let Some(ps) = params {
629 for p in ps {
630 if let Some(t) = &p.ty {
631 collect_fqcns_in_simple(t, out);
632 }
633 }
634 }
635 if let Some(rt) = return_type {
636 collect_fqcns_in_union(rt, out);
637 }
638 }
639 Atomic::TIntersection { parts } => {
640 for p in parts.iter() {
641 collect_fqcns_in_union(p, out);
642 }
643 }
644 Atomic::TConditional { data } => {
645 collect_fqcns_in_union(&data.subject, out);
646 collect_fqcns_in_union(&data.if_true, out);
647 collect_fqcns_in_union(&data.if_false, out);
648 }
649 Atomic::TTemplateParam { as_type, .. } => {
650 collect_fqcns_in_union(as_type, out);
651 }
652 _ => {}
653 }
654}
655
656fn build_reverse_deps(db: &dyn crate::db::MirDatabase) -> HashMap<String, HashSet<String>> {
657 let mut reverse: HashMap<String, HashSet<String>> = HashMap::default();
658
659 let mut add_symbol_edge = |symbol_key: &str, dependent_file: &str| {
660 let lookup = crate::defining_file_lookup_key(symbol_key);
661 if let Some(defining_file) = db.symbol_defining_file(lookup) {
662 let def = defining_file.as_ref().to_string();
663 if def != dependent_file {
664 reverse
665 .entry(def)
666 .or_default()
667 .insert(dependent_file.to_string());
668 }
669 }
670 };
671
672 for sf in db.all_source_files() {
673 let file = sf.path(db);
674 for symbol in crate::db::file_structural_symbols(db, sf).iter() {
675 add_symbol_edge(symbol, file.as_ref());
676 }
677 }
678
679 for (ref_file, symbol_key) in db.all_reference_location_pairs() {
680 add_symbol_edge(&symbol_key, ref_file.as_ref());
681 }
682
683 reverse
684}
685
686fn extract_reference_locations(
687 db: &dyn crate::db::MirDatabase,
688 file: &Arc<str>,
689) -> Arc<[crate::cache::CachedRefLoc]> {
690 db.extract_file_reference_locations(file.as_ref()).into()
691}
692
693pub struct AnalysisResult {
694 pub issues: Vec<Issue>,
695 #[doc(hidden)]
696 pub type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
697 pub symbols: Vec<crate::symbol::ResolvedSymbol>,
701 symbols_by_file: OnceLock<HashMap<Arc<str>, Vec<usize>>>,
704}
705
706impl AnalysisResult {
707 fn build(
708 issues: Vec<Issue>,
709 type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
710 symbols: Vec<crate::symbol::ResolvedSymbol>,
711 ) -> Self {
712 Self {
713 issues,
714 type_envs,
715 symbols,
716 symbols_by_file: OnceLock::new(),
717 }
718 }
719
720 fn symbols_by_file(&self) -> &HashMap<Arc<str>, Vec<usize>> {
721 self.symbols_by_file.get_or_init(|| {
722 let mut by_file: HashMap<Arc<str>, Vec<usize>> = HashMap::default();
723 for (idx, symbol) in self.symbols.iter().enumerate() {
724 by_file.entry(symbol.file.clone()).or_default().push(idx);
725 }
726 by_file
727 })
728 }
729
730 pub fn error_count(&self) -> usize {
731 self.issues
732 .iter()
733 .filter(|i| i.severity == mir_issues::Severity::Error)
734 .count()
735 }
736
737 pub fn warning_count(&self) -> usize {
738 self.issues
739 .iter()
740 .filter(|i| i.severity == mir_issues::Severity::Warning)
741 .count()
742 }
743
744 pub fn issues_by_file(&self) -> HashMap<Arc<str>, Vec<&Issue>> {
745 let mut map: HashMap<Arc<str>, Vec<&Issue>> = HashMap::default();
746 for issue in &self.issues {
747 map.entry(issue.location.file.clone())
748 .or_default()
749 .push(issue);
750 }
751 map
752 }
753
754 pub fn count_by_severity(&self) -> Vec<(mir_issues::Severity, usize)> {
755 let mut counts: std::collections::BTreeMap<mir_issues::Severity, usize> =
756 std::collections::BTreeMap::new();
757 for issue in &self.issues {
758 *counts.entry(issue.severity).or_insert(0) += 1;
759 }
760 counts.into_iter().collect()
761 }
762
763 pub fn total_issue_count(&self) -> usize {
764 self.issues.len()
765 }
766
767 pub fn filter_issues<'a, F>(&'a self, predicate: F) -> impl Iterator<Item = &'a Issue>
768 where
769 F: Fn(&Issue) -> bool + 'a,
770 {
771 self.issues.iter().filter(move |i| predicate(i))
772 }
773
774 pub fn symbol_at(
775 &self,
776 file: &str,
777 byte_offset: u32,
778 ) -> Option<&crate::symbol::ResolvedSymbol> {
779 let indices = self.symbols_by_file().get(file)?;
780
781 if let Some(sym) = indices
783 .iter()
784 .map(|idx| &self.symbols[*idx])
785 .filter(|s| s.span.start <= byte_offset && byte_offset < s.span.end)
786 .min_by_key(|s| s.span.end - s.span.start)
787 {
788 return Some(sym);
789 }
790
791 indices
797 .iter()
798 .map(|idx| &self.symbols[*idx])
799 .filter(|s| {
800 s.expr_span
801 .is_some_and(|es| es.start <= byte_offset && byte_offset < es.end)
802 })
803 .min_by_key(|s| {
804 let es = s.expr_span.unwrap();
805 es.end - es.start
806 })
807 }
808}