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 ns in unused {
291 let loc = unused_suppress_location(&file, ns);
292 let mut issue = Issue::new(
293 mir_issues::IssueKind::UnusedSuppress {
294 kind: ns.kind.clone(),
295 },
296 loc,
297 );
298 if map.is_suppressed(
299 ns.report_line,
300 issue.kind.display_name(),
301 issue.kind.code(),
302 ) {
303 issue.suppressed = true;
304 }
305 new_issues.push(issue);
306 }
307 }
308 }
309 issues.extend(new_issues);
310 }
311
312 fn type_exists(&self, fqcn: &str) -> bool {
313 let db = self.snapshot_db();
314 crate::db::class_exists(&db, fqcn)
315 }
316
317 fn collect_and_ingest_source(
318 &mut self,
319 file: Arc<str>,
320 src: &str,
321 php_version: PhpVersion,
322 ) -> FileDefinitions {
323 self.db.collect_and_ingest_file(file, src, php_version)
324 }
325
326 fn refresh_workspace_index(&mut self) {
334 self.db.salsa.rebuild_workspace_symbol_index();
335 }
336
337 fn load_batch_stubs(&mut self, php_version: PhpVersion) {
341 {
344 let version_str = Arc::from(php_version.to_string());
345 self.db.salsa.set_php_version(version_str);
346 }
347
348 let paths: Vec<&'static str> = crate::stubs::stub_files().iter().map(|&(p, _)| p).collect();
350 self.db.ingest_stub_paths(&paths);
351
352 self.db
354 .ingest_user_stubs(&self.user_stub_files, &self.user_stub_dirs);
355
356 let db = &mut self.db.salsa;
359 if db.current_resolver().is_none() {
360 let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::StubClassResolver);
361 db.set_resolver(Some(resolver));
362 }
363 }
364}
365
366mod lazy;
367mod run;
368
369pub fn analyze_source(source: &str) -> AnalysisResult {
376 analyze_source_with_options(source, &BatchOptions::new())
377}
378
379pub fn analyze_source_with_options(source: &str, opts: &BatchOptions) -> AnalysisResult {
385 use mir_issues::Issue;
386
387 let php_version = PhpVersion::LATEST;
388 let file: Arc<str> = Arc::from("<source>");
389 let mut db = MirDbStorage::default();
390 db.set_php_version(Arc::from(php_version.to_string()));
391 crate::stubs::load_stubs_for_version(&mut db, php_version);
392 db.upsert_source_file(file.clone(), Arc::from(source));
398 let parsed = crate::recursion::parse_versioned(source, php_version.parser_version());
399 let mut collected_issues: Vec<Issue> = parsed
400 .errors
401 .iter()
402 .filter(|err| !crate::parser::is_spurious_reserved_class_error(err))
403 .map(|err| crate::parser::parse_error_to_issue(err, &file, source, &parsed.source_map))
404 .collect();
405 let collector = crate::collector::DefinitionCollector::new_for_slice(
406 file.clone(),
407 source,
408 &parsed.source_map,
409 )
410 .with_php_version(php_version);
411 let (mut slice, collector_issues) = collector.collect_slice(&parsed.program);
412 collected_issues.extend(collector_issues);
413 mir_codebase::definitions::deduplicate_params_in_slice(&mut slice);
414 let slice_arc = Arc::new(slice);
415 let issues_arc = Arc::new(collected_issues);
416 db.prime_parse_cache(
417 crate::stub_cache::hash_source(source),
418 php_version.cache_byte(),
419 Arc::clone(&slice_arc),
420 Arc::clone(&issues_arc),
421 );
422 let _file_defs = crate::db::FileDefinitions {
423 slice: slice_arc,
424 issues: issues_arc.clone(),
425 };
426 let suppressions = crate::suppression::SuppressionMap::from_source(source);
427 let mut all_issues = Arc::unwrap_or_clone(issues_arc);
428 if all_issues.iter().any(|issue| {
429 matches!(issue.kind, mir_issues::IssueKind::ParseError { .. })
430 && issue.severity == mir_issues::Severity::Error
431 }) {
432 mark_suppressed(&mut all_issues, &suppressions);
433 return AnalysisResult::build(all_issues, rustc_hash::FxHashMap::default(), Vec::new());
434 }
435 let mut type_envs = rustc_hash::FxHashMap::default();
436 let mut all_symbols = Vec::new();
437 let mut driver = BodyAnalyzer::new(&db, php_version);
438 driver.collect_symbols = !opts.skip_symbols;
439 all_issues.extend(driver.analyze_bodies_typed(
440 &parsed.program,
441 file.clone(),
442 source,
443 &parsed.source_map,
444 &mut type_envs,
445 &mut all_symbols,
446 ));
447 if let Some(plugins) = mir_plugin::snapshot() {
448 if plugins.hooks().before_add_issue {
449 all_issues.retain(|i| plugins.before_add_issue(i));
450 }
451 }
452 mark_suppressed(&mut all_issues, &suppressions);
453 emit_unused_suppressions(&mut all_issues, &suppressions, &file);
454 opts.apply(&mut all_issues);
455 AnalysisResult::build(all_issues, type_envs, all_symbols)
456}
457
458fn mark_suppressed(issues: &mut [Issue], suppressions: &crate::suppression::SuppressionMap) {
462 if suppressions.is_empty() {
463 return;
464 }
465 for issue in issues.iter_mut() {
466 if !issue.suppressed
467 && suppressions.is_suppressed(
468 issue.location.line,
469 issue.kind.display_name(),
470 issue.kind.code(),
471 )
472 {
473 issue.suppressed = true;
474 }
475 }
476}
477
478fn emit_unused_suppressions(
482 all_issues: &mut Vec<Issue>,
483 suppressions: &crate::suppression::SuppressionMap,
484 file: &std::sync::Arc<str>,
485) {
486 let all_refs: Vec<&Issue> = all_issues.iter().collect();
487 let pre_suppressed: Vec<&Issue> = all_refs.iter().filter(|i| i.suppressed).copied().collect();
488 let unused = suppressions.unused_named(&all_refs, &pre_suppressed);
489 for ns in unused {
490 let loc = unused_suppress_location(file, ns);
491 let mut issue = Issue::new(
492 mir_issues::IssueKind::UnusedSuppress {
493 kind: ns.kind.clone(),
494 },
495 loc,
496 );
497 if suppressions.is_suppressed(ns.report_line, issue.kind.display_name(), issue.kind.code())
498 {
499 issue.suppressed = true;
500 }
501 all_issues.push(issue);
502 }
503}
504
505fn unused_suppress_location(
506 file: &std::sync::Arc<str>,
507 ns: &crate::suppression::NamedSuppression,
508) -> mir_types::Location {
509 mir_types::Location::new(
510 file.clone(),
511 ns.tag_line,
512 ns.tag_line,
513 ns.col_start,
514 ns.col_end,
515 )
516}
517
518pub fn discover_files(root: &Path) -> Vec<PathBuf> {
520 discover_files_with_extensions(root, &PhpFileExtensions::default())
521}
522
523pub fn discover_files_with_extensions(root: &Path, extensions: &PhpFileExtensions) -> Vec<PathBuf> {
525 if root.is_file() {
526 return vec![root.to_path_buf()];
527 }
528 let mut files = Vec::new();
529 collect_php_files(root, &mut files, extensions);
530 files
531}
532
533pub(crate) fn collect_php_files(
534 dir: &Path,
535 out: &mut Vec<PathBuf>,
536 extensions: &PhpFileExtensions,
537) {
538 if let Ok(entries) = std::fs::read_dir(dir) {
539 for entry in entries.flatten() {
540 if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) {
541 continue;
542 }
543 let path = entry.path();
544 if path.is_dir() {
545 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
546 if matches!(
547 name,
548 "vendor" | ".git" | "node_modules" | ".cache" | ".pnpm-store"
549 ) {
550 continue;
551 }
552 collect_php_files(&path, out, extensions);
553 } else if extensions.is_php_source(&path) {
554 out.push(path);
555 }
556 }
557 }
558}
559
560pub(crate) fn collect_class_referenced_fqcns(class: &crate::db::ClassLike, out: &mut Vec<String>) {
567 if let Some(p) = class.parent() {
568 out.push(p.to_string());
569 }
570 for i in class.interfaces() {
571 out.push(i.to_string());
572 }
573 for e in class.extends() {
574 out.push(e.to_string());
575 }
576 for t in class.class_traits() {
577 out.push(t.to_string());
578 }
579 for m in class.mixins() {
580 out.push(m.to_string());
581 }
582 for u in class.extends_type_args() {
583 collect_fqcns_in_union(u, out);
584 }
585 for (iface, args) in class.implements_type_args() {
586 out.push(iface.to_string());
587 for u in args {
588 collect_fqcns_in_union(u, out);
589 }
590 }
591 for (_, m) in class.own_methods().iter() {
592 for p in m.params.iter() {
593 if let Some(t) = &p.ty {
594 collect_fqcns_in_union(t, out);
595 }
596 }
597 if let Some(t) = &m.return_type {
598 collect_fqcns_in_union(t, out);
599 }
600 for thrown in m.throws.iter() {
601 out.push(thrown.to_string());
602 }
603 }
604 if let Some(props) = class.own_properties() {
605 for (_, p) in props.iter() {
606 if let Some(t) = &p.ty {
607 collect_fqcns_in_union(t, out);
608 }
609 }
610 }
611 for (_, c) in class.own_constants().iter() {
612 collect_fqcns_in_union(&c.ty, out);
613 }
614}
615
616pub(crate) fn collect_fqcns_in_union(u: &Type, out: &mut Vec<String>) {
617 for atom in u.types.iter() {
618 collect_fqcns_in_atomic(atom, out);
619 }
620}
621
622fn collect_fqcns_in_simple(t: &mir_types::compact::SimpleType, out: &mut Vec<String>) {
623 if let mir_types::compact::SimpleType::Complex(u) = t {
624 collect_fqcns_in_union(u, out);
625 }
626}
627
628pub(crate) fn collect_fqcns_in_atomic(a: &Atomic, out: &mut Vec<String>) {
629 match a {
630 Atomic::TNamedObject { fqcn, type_params } => {
631 out.push(fqcn.to_string());
632 for tp in type_params.iter() {
633 collect_fqcns_in_union(tp, out);
634 }
635 }
636 Atomic::TStaticObject { fqcn } | Atomic::TSelf { fqcn } | Atomic::TParent { fqcn } => {
637 out.push(fqcn.to_string());
638 }
639 Atomic::TLiteralEnumCase { enum_fqcn, .. } => {
640 out.push(enum_fqcn.to_string());
641 }
642 Atomic::TClassString(Some(s)) | Atomic::TInterfaceString(Some(s)) => {
643 out.push(s.to_string());
644 }
645 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
646 collect_fqcns_in_union(key, out);
647 collect_fqcns_in_union(value, out);
648 }
649 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
650 collect_fqcns_in_union(value, out);
651 }
652 Atomic::TKeyedArray { properties, .. } => {
653 for (_, kp) in properties.iter() {
654 collect_fqcns_in_union(&kp.ty, out);
655 }
656 }
657 Atomic::TClosure { data } => {
658 for p in data.params.iter() {
659 if let Some(t) = &p.ty {
660 collect_fqcns_in_simple(t, out);
661 }
662 }
663 collect_fqcns_in_union(&data.return_type, out);
664 if let Some(t) = &data.this_type {
665 collect_fqcns_in_union(t, out);
666 }
667 }
668 Atomic::TCallable {
669 params,
670 return_type,
671 ..
672 } => {
673 if let Some(ps) = params {
674 for p in ps.iter() {
675 if let Some(t) = &p.ty {
676 collect_fqcns_in_simple(t, out);
677 }
678 }
679 }
680 if let Some(rt) = return_type {
681 collect_fqcns_in_union(rt, out);
682 }
683 }
684 Atomic::TIntersection { parts } => {
685 for p in parts.iter() {
686 collect_fqcns_in_union(p, out);
687 }
688 }
689 Atomic::TConditional { data } => {
690 collect_fqcns_in_union(&data.subject, out);
691 collect_fqcns_in_union(&data.if_true, out);
692 collect_fqcns_in_union(&data.if_false, out);
693 }
694 Atomic::TTemplateParam { as_type, .. } => {
695 collect_fqcns_in_union(as_type, out);
696 }
697 _ => {}
698 }
699}
700
701fn build_reverse_deps(db: &dyn crate::db::MirDatabase) -> HashMap<String, HashSet<String>> {
702 let mut reverse: HashMap<String, HashSet<String>> = HashMap::default();
703
704 let mut add_symbol_edge = |symbol_key: &str, dependent_file: &str| {
705 let lookup = crate::defining_file_lookup_key(symbol_key);
706 if let Some(defining_file) = db.symbol_defining_file(lookup) {
707 let def = defining_file.as_ref().to_string();
708 if def != dependent_file {
709 reverse
710 .entry(def)
711 .or_default()
712 .insert(dependent_file.to_string());
713 }
714 }
715 };
716
717 for sf in db.all_source_files() {
718 let file = sf.path(db);
719 for symbol in crate::db::file_structural_symbols(db, sf).iter() {
720 add_symbol_edge(symbol, file.as_ref());
721 }
722 }
723
724 for (ref_file, symbol_key) in db.all_reference_location_pairs() {
725 add_symbol_edge(&symbol_key, ref_file.as_ref());
726 }
727
728 reverse
729}
730
731fn extract_reference_locations(
732 db: &dyn crate::db::MirDatabase,
733 file: &Arc<str>,
734) -> Arc<[crate::cache::CachedRefLoc]> {
735 db.extract_file_reference_locations(file.as_ref()).into()
736}
737
738pub struct AnalysisResult {
739 pub issues: Vec<Issue>,
740 #[doc(hidden)]
741 pub type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
742 pub symbols: Vec<crate::symbol::ResolvedSymbol>,
746 symbols_by_file: OnceLock<HashMap<Arc<str>, Vec<usize>>>,
749}
750
751impl AnalysisResult {
752 fn build(
753 issues: Vec<Issue>,
754 type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
755 symbols: Vec<crate::symbol::ResolvedSymbol>,
756 ) -> Self {
757 Self {
758 issues,
759 type_envs,
760 symbols,
761 symbols_by_file: OnceLock::new(),
762 }
763 }
764
765 fn symbols_by_file(&self) -> &HashMap<Arc<str>, Vec<usize>> {
766 self.symbols_by_file.get_or_init(|| {
767 let mut by_file: HashMap<Arc<str>, Vec<usize>> = HashMap::default();
768 for (idx, symbol) in self.symbols.iter().enumerate() {
769 by_file.entry(symbol.file.clone()).or_default().push(idx);
770 }
771 by_file
772 })
773 }
774
775 pub fn error_count(&self) -> usize {
776 self.issues
777 .iter()
778 .filter(|i| i.severity == mir_issues::Severity::Error)
779 .count()
780 }
781
782 pub fn warning_count(&self) -> usize {
783 self.issues
784 .iter()
785 .filter(|i| i.severity == mir_issues::Severity::Warning)
786 .count()
787 }
788
789 pub fn issues_by_file(&self) -> HashMap<Arc<str>, Vec<&Issue>> {
790 let mut map: HashMap<Arc<str>, Vec<&Issue>> = HashMap::default();
791 for issue in &self.issues {
792 map.entry(issue.location.file.clone())
793 .or_default()
794 .push(issue);
795 }
796 map
797 }
798
799 pub fn count_by_severity(&self) -> Vec<(mir_issues::Severity, usize)> {
800 let mut counts: std::collections::BTreeMap<mir_issues::Severity, usize> =
801 std::collections::BTreeMap::new();
802 for issue in &self.issues {
803 *counts.entry(issue.severity).or_insert(0) += 1;
804 }
805 counts.into_iter().collect()
806 }
807
808 pub fn total_issue_count(&self) -> usize {
809 self.issues.len()
810 }
811
812 pub fn filter_issues<'a, F>(&'a self, predicate: F) -> impl Iterator<Item = &'a Issue>
813 where
814 F: Fn(&Issue) -> bool + 'a,
815 {
816 self.issues.iter().filter(move |i| predicate(i))
817 }
818
819 pub fn symbol_at(
820 &self,
821 file: &str,
822 byte_offset: u32,
823 ) -> Option<&crate::symbol::ResolvedSymbol> {
824 let indices = self.symbols_by_file().get(file)?;
825
826 if let Some(sym) = indices
828 .iter()
829 .map(|idx| &self.symbols[*idx])
830 .filter(|s| s.span.start <= byte_offset && byte_offset < s.span.end)
831 .min_by_key(|s| s.span.end - s.span.start)
832 {
833 return Some(sym);
834 }
835
836 indices
842 .iter()
843 .map(|idx| &self.symbols[*idx])
844 .filter(|s| {
845 s.expr_span
846 .is_some_and(|es| es.start <= byte_offset && byte_offset < es.end)
847 })
848 .min_by_key(|s| {
849 let es = s.expr_span.unwrap();
850 es.end - es.start
851 })
852 }
853}