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