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