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