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 #[doc(hidden)]
161 pub fn stub_cache_stats(&self) -> (u64, u64) {
162 match self.db.stub_cache.as_deref() {
163 Some(c) => (c.hits(), c.misses()),
164 None => (0, 0),
165 }
166 }
167
168 fn batch_php_version(&self, opts: &BatchOptions) -> PhpVersion {
169 opts.php_version_override.unwrap_or(self.php_version)
170 }
171
172 fn apply_suppressions_and_emit_unused(
190 &self,
191 issues: &mut Vec<Issue>,
192 analyzed_files: &[Arc<str>],
193 ) {
194 use crate::suppression::SuppressionMap;
195 let db = self.snapshot_db();
196 let mut cache: HashMap<Arc<str>, Option<SuppressionMap>> = HashMap::default();
197 for issue in issues.iter_mut() {
198 if issue.suppressed {
199 continue;
200 }
201 let map = cache.entry(issue.location.file.clone()).or_insert_with(|| {
202 db.lookup_source_file(&issue.location.file)
203 .map(|sf| SuppressionMap::from_source(sf.text(&db)))
204 });
205 if let Some(map) = map.as_ref() {
206 if map.is_suppressed(
207 issue.location.line,
208 issue.kind.display_name(),
209 issue.kind.code(),
210 ) {
211 issue.suppressed = true;
212 }
213 }
214 }
215 for file in analyzed_files {
219 cache.entry(file.clone()).or_insert_with(|| {
220 db.lookup_source_file(file)
221 .map(|sf| SuppressionMap::from_source(sf.text(&db)))
222 });
223 }
224 let files: Vec<Arc<str>> = cache
226 .iter()
227 .filter_map(|(f, m)| m.as_ref().map(|_| f.clone()))
228 .collect();
229 let mut issues_by_file: HashMap<&str, Vec<&Issue>> = HashMap::default();
232 for issue in issues.iter() {
233 issues_by_file
234 .entry(issue.location.file.as_ref())
235 .or_default()
236 .push(issue);
237 }
238 let mut new_issues: Vec<Issue> = Vec::new();
239 for file in files {
240 if let Some(Some(map)) = cache.get(&file) {
241 if map.named_suppressions.is_empty() {
242 continue;
243 }
244 let file_issues: &[&Issue] = issues_by_file
245 .get(file.as_ref())
246 .map(Vec::as_slice)
247 .unwrap_or(&[]);
248 let pre_suppressed: Vec<&Issue> = file_issues
253 .iter()
254 .filter(|i| i.suppressed)
255 .copied()
256 .collect();
257 let unused = map.unused_named(file_issues, &pre_suppressed);
262 for (line, kind) in unused {
263 let loc = mir_types::Location::new(file.clone(), line, line, 0, 0);
264 let mut issue = Issue::new(mir_issues::IssueKind::UnusedSuppress { kind }, loc);
265 if map.is_suppressed(line, issue.kind.display_name(), issue.kind.code()) {
266 issue.suppressed = true;
267 }
268 new_issues.push(issue);
269 }
270 }
271 }
272 issues.extend(new_issues);
273 }
274
275 fn type_exists(&self, fqcn: &str) -> bool {
276 let db = self.snapshot_db();
277 crate::db::class_exists(&db, fqcn)
278 }
279
280 fn collect_and_ingest_source(
281 &self,
282 file: Arc<str>,
283 src: &str,
284 php_version: PhpVersion,
285 ) -> FileDefinitions {
286 self.db.collect_and_ingest_file(file, src, php_version)
287 }
288
289 fn refresh_workspace_index(&self) {
297 let mut guard = self.db.salsa.write();
298 guard.rebuild_workspace_symbol_index();
299 }
300
301 fn load_batch_stubs(&self, php_version: PhpVersion) {
305 {
308 let version_str = Arc::from(php_version.to_string().as_str());
309 self.db.salsa.write().set_php_version(version_str);
310 }
311
312 let paths: Vec<&'static str> = crate::stubs::stub_files().iter().map(|&(p, _)| p).collect();
314 self.db.ingest_stub_paths(&paths, php_version);
315
316 self.db
318 .ingest_user_stubs(&self.user_stub_files, &self.user_stub_dirs);
319
320 let mut guard = self.db.salsa.write();
323 if guard.current_resolver().is_none() {
324 let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::StubClassResolver);
325 guard.set_resolver(Some(resolver));
326 }
327 }
328}
329
330mod lazy;
331mod run;
332
333pub fn analyze_source(source: &str) -> AnalysisResult {
337 let php_version = PhpVersion::LATEST;
338 let file: Arc<str> = Arc::from("<source>");
339 let mut db = MirDbStorage::default();
340 db.set_php_version(Arc::from(php_version.to_string().as_str()));
341 crate::stubs::load_stubs_for_version(&mut db, php_version);
342 let salsa_file = db.upsert_source_file(file.clone(), Arc::from(source));
348 let file_defs = collect_file_definitions(&db, salsa_file);
349 let suppressions = crate::suppression::SuppressionMap::from_source(source);
350 let mut all_issues = Arc::unwrap_or_clone(file_defs.issues.clone());
351 if all_issues.iter().any(|issue| {
352 matches!(issue.kind, mir_issues::IssueKind::ParseError { .. })
353 && issue.severity == mir_issues::Severity::Error
354 }) {
355 mark_suppressed(&mut all_issues, &suppressions);
356 return AnalysisResult::build(all_issues, rustc_hash::FxHashMap::default(), Vec::new());
357 }
358 let mut type_envs = rustc_hash::FxHashMap::default();
359 let mut all_symbols = Vec::new();
360 let result = php_rs_parser::parse(source);
361
362 let driver = BodyAnalyzer::new(&db, php_version);
363 all_issues.extend(driver.analyze_bodies_typed(
364 &result.program,
365 file.clone(),
366 source,
367 &result.source_map,
368 &mut type_envs,
369 &mut all_symbols,
370 ));
371 if let Some(plugins) = mir_plugin::snapshot() {
372 if plugins.hooks().before_add_issue {
373 all_issues.retain(|i| plugins.before_add_issue(i));
374 }
375 }
376 mark_suppressed(&mut all_issues, &suppressions);
377 emit_unused_suppressions(&mut all_issues, &suppressions, &file);
378 AnalysisResult::build(all_issues, type_envs, all_symbols)
379}
380
381fn mark_suppressed(issues: &mut [Issue], suppressions: &crate::suppression::SuppressionMap) {
385 if suppressions.is_empty() {
386 return;
387 }
388 for issue in issues.iter_mut() {
389 if !issue.suppressed
390 && suppressions.is_suppressed(
391 issue.location.line,
392 issue.kind.display_name(),
393 issue.kind.code(),
394 )
395 {
396 issue.suppressed = true;
397 }
398 }
399}
400
401fn emit_unused_suppressions(
405 all_issues: &mut Vec<Issue>,
406 suppressions: &crate::suppression::SuppressionMap,
407 file: &std::sync::Arc<str>,
408) {
409 let all_refs: Vec<&Issue> = all_issues.iter().collect();
410 let pre_suppressed: Vec<&Issue> = all_refs.iter().filter(|i| i.suppressed).copied().collect();
411 let unused = suppressions.unused_named(&all_refs, &pre_suppressed);
412 for (line, kind) in unused {
413 let loc = mir_types::Location::new(file.clone(), line, line, 0, 0);
414 let mut issue = Issue::new(mir_issues::IssueKind::UnusedSuppress { kind }, loc);
415 if suppressions.is_suppressed(line, issue.kind.display_name(), issue.kind.code()) {
416 issue.suppressed = true;
417 }
418 all_issues.push(issue);
419 }
420}
421
422pub fn discover_files(root: &Path) -> Vec<PathBuf> {
424 if root.is_file() {
425 return vec![root.to_path_buf()];
426 }
427 let mut files = Vec::new();
428 collect_php_files(root, &mut files);
429 files
430}
431
432pub(crate) fn collect_php_files(dir: &Path, out: &mut Vec<PathBuf>) {
433 if let Ok(entries) = std::fs::read_dir(dir) {
434 for entry in entries.flatten() {
435 if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) {
436 continue;
437 }
438 let path = entry.path();
439 if path.is_dir() {
440 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
441 if matches!(
442 name,
443 "vendor" | ".git" | "node_modules" | ".cache" | ".pnpm-store"
444 ) {
445 continue;
446 }
447 collect_php_files(&path, out);
448 } else if path.extension().and_then(|e| e.to_str()) == Some("php") {
449 out.push(path);
450 }
451 }
452 }
453}
454
455pub(crate) fn collect_class_referenced_fqcns(class: &crate::db::ClassLike, out: &mut Vec<String>) {
462 if let Some(p) = class.parent() {
463 out.push(p.to_string());
464 }
465 for i in class.interfaces() {
466 out.push(i.to_string());
467 }
468 for e in class.extends() {
469 out.push(e.to_string());
470 }
471 for t in class.class_traits() {
472 out.push(t.to_string());
473 }
474 for m in class.mixins() {
475 out.push(m.to_string());
476 }
477 for u in class.extends_type_args() {
478 collect_fqcns_in_union(u, out);
479 }
480 for (iface, args) in class.implements_type_args() {
481 out.push(iface.to_string());
482 for u in args {
483 collect_fqcns_in_union(u, out);
484 }
485 }
486 for (_, m) in class.own_methods().iter() {
487 for p in m.params.iter() {
488 if let Some(t) = &p.ty {
489 collect_fqcns_in_union(t, out);
490 }
491 }
492 if let Some(t) = &m.return_type {
493 collect_fqcns_in_union(t, out);
494 }
495 for thrown in m.throws.iter() {
496 out.push(thrown.to_string());
497 }
498 }
499 if let Some(props) = class.own_properties() {
500 for (_, p) in props.iter() {
501 if let Some(t) = &p.ty {
502 collect_fqcns_in_union(t, out);
503 }
504 }
505 }
506 for (_, c) in class.own_constants().iter() {
507 collect_fqcns_in_union(&c.ty, out);
508 }
509}
510
511pub(crate) fn collect_fqcns_in_union(u: &Type, out: &mut Vec<String>) {
512 for atom in u.types.iter() {
513 collect_fqcns_in_atomic(atom, out);
514 }
515}
516
517fn collect_fqcns_in_simple(t: &mir_types::compact::SimpleType, out: &mut Vec<String>) {
518 if let mir_types::compact::SimpleType::Complex(u) = t {
519 collect_fqcns_in_union(u, out);
520 }
521}
522
523pub(crate) fn collect_fqcns_in_atomic(a: &Atomic, out: &mut Vec<String>) {
524 match a {
525 Atomic::TNamedObject { fqcn, type_params } => {
526 out.push(fqcn.to_string());
527 for tp in type_params.iter() {
528 collect_fqcns_in_union(tp, out);
529 }
530 }
531 Atomic::TStaticObject { fqcn } | Atomic::TSelf { fqcn } | Atomic::TParent { fqcn } => {
532 out.push(fqcn.to_string());
533 }
534 Atomic::TLiteralEnumCase { enum_fqcn, .. } => {
535 out.push(enum_fqcn.to_string());
536 }
537 Atomic::TClassString(Some(s)) | Atomic::TInterfaceString(Some(s)) => {
538 out.push(s.to_string());
539 }
540 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
541 collect_fqcns_in_union(key, out);
542 collect_fqcns_in_union(value, out);
543 }
544 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
545 collect_fqcns_in_union(value, out);
546 }
547 Atomic::TKeyedArray { properties, .. } => {
548 for (_, kp) in properties.iter() {
549 collect_fqcns_in_union(&kp.ty, out);
550 }
551 }
552 Atomic::TClosure { data } => {
553 for p in data.params.iter() {
554 if let Some(t) = &p.ty {
555 collect_fqcns_in_simple(t, out);
556 }
557 }
558 collect_fqcns_in_union(&data.return_type, out);
559 if let Some(t) = &data.this_type {
560 collect_fqcns_in_union(t, out);
561 }
562 }
563 Atomic::TCallable {
564 params,
565 return_type,
566 } => {
567 if let Some(ps) = params {
568 for p in ps {
569 if let Some(t) = &p.ty {
570 collect_fqcns_in_simple(t, out);
571 }
572 }
573 }
574 if let Some(rt) = return_type {
575 collect_fqcns_in_union(rt, out);
576 }
577 }
578 Atomic::TIntersection { parts } => {
579 for p in parts.iter() {
580 collect_fqcns_in_union(p, out);
581 }
582 }
583 Atomic::TConditional { data } => {
584 collect_fqcns_in_union(&data.subject, out);
585 collect_fqcns_in_union(&data.if_true, out);
586 collect_fqcns_in_union(&data.if_false, out);
587 }
588 Atomic::TTemplateParam { as_type, .. } => {
589 collect_fqcns_in_union(as_type, out);
590 }
591 _ => {}
592 }
593}
594
595fn build_reverse_deps(db: &dyn crate::db::MirDatabase) -> HashMap<String, HashSet<String>> {
596 let mut reverse: HashMap<String, HashSet<String>> = HashMap::default();
597
598 let mut add_edge = |symbol: &str, dependent_file: &str| {
599 if let Some(defining_file) = db.symbol_defining_file(symbol) {
600 let def = defining_file.as_ref().to_string();
601 if def != dependent_file {
602 reverse
603 .entry(def)
604 .or_default()
605 .insert(dependent_file.to_string());
606 }
607 }
608 };
609
610 for (file, imports) in db.file_import_snapshots() {
611 let file = file.as_ref().to_string();
612 for fqcn in imports.values() {
613 add_edge(fqcn.as_str(), &file);
614 }
615 }
616
617 let extract_named_objects = |union: &mir_types::Type| {
618 union
619 .types
620 .iter()
621 .filter_map(|atomic| match atomic {
622 mir_types::atomic::Atomic::TNamedObject { fqcn, .. } => Some(*fqcn),
623 _ => None,
624 })
625 .collect::<Vec<_>>()
626 };
627
628 for fqcn in crate::db::workspace_classes(db).iter() {
629 let here = crate::db::Fqcn::from_str(db, fqcn.as_ref());
630 let Some(class) = crate::db::find_class_like(db, here) else {
631 continue;
632 };
633 if class.is_interface() || class.is_trait() || class.is_enum() {
634 continue;
635 }
636 let Some(file) = db
637 .symbol_defining_file(fqcn.as_ref())
638 .map(|f| f.as_ref().to_string())
639 .or_else(|| class.location().map(|l| l.file.as_ref().to_string()))
640 else {
641 continue;
642 };
643
644 if let Some(parent) = class.parent() {
645 add_edge(parent.as_ref(), &file);
646 }
647 for iface in class.interfaces().iter() {
648 add_edge(iface.as_ref(), &file);
649 }
650 for tr in class.class_traits().iter() {
651 add_edge(tr.as_ref(), &file);
652 }
653 if let Some(props) = class.own_properties() {
654 for (_, p) in props.iter() {
655 if let Some(ty) = &p.ty {
656 for named in extract_named_objects(ty) {
657 add_edge(named.as_ref(), &file);
658 }
659 }
660 }
661 }
662 for (_, method) in class.own_methods().iter() {
663 for param in method.params.iter() {
664 if let Some(ty) = ¶m.ty {
665 for named in extract_named_objects(ty.as_ref()) {
666 add_edge(named.as_ref(), &file);
667 }
668 }
669 }
670 if let Some(rt) = method.return_type.as_deref() {
671 for named in extract_named_objects(rt) {
672 add_edge(named.as_ref(), &file);
673 }
674 }
675 }
676 }
677
678 for fqn in crate::db::workspace_functions(db).iter() {
679 let here = crate::db::Fqcn::from_str(db, fqn.as_ref());
680 let Some(f) = crate::db::find_function(db, here) else {
681 continue;
682 };
683 let Some(file) = db
684 .symbol_defining_file(fqn.as_ref())
685 .map(|f| f.as_ref().to_string())
686 .or_else(|| f.location.as_ref().map(|l| l.file.as_ref().to_string()))
687 else {
688 continue;
689 };
690
691 for param in f.params.iter() {
692 if let Some(ty) = ¶m.ty {
693 for named in extract_named_objects(ty.as_ref()) {
694 add_edge(named.as_ref(), &file);
695 }
696 }
697 }
698 if let Some(rt) = f.return_type.as_deref() {
699 for named in extract_named_objects(rt) {
700 add_edge(named.as_ref(), &file);
701 }
702 }
703 }
704
705 for (ref_file, symbol_key) in db.all_reference_location_pairs() {
706 let file_str = ref_file.as_ref().to_string();
707 let lookup = crate::defining_file_lookup_key(&symbol_key);
708 add_edge(lookup, &file_str);
709 }
710
711 reverse
712}
713
714fn extract_reference_locations(
715 db: &dyn crate::db::MirDatabase,
716 file: &Arc<str>,
717) -> Arc<[crate::cache::CachedRefLoc]> {
718 db.extract_file_reference_locations(file.as_ref()).into()
719}
720
721pub struct AnalysisResult {
722 pub issues: Vec<Issue>,
723 #[doc(hidden)]
724 pub type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
725 pub symbols: Vec<crate::symbol::ResolvedSymbol>,
727 symbols_by_file: HashMap<Arc<str>, std::ops::Range<usize>>,
730}
731
732impl AnalysisResult {
733 fn build(
734 issues: Vec<Issue>,
735 type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
736 mut symbols: Vec<crate::symbol::ResolvedSymbol>,
737 ) -> Self {
738 symbols.sort_unstable_by(|a, b| a.file.as_ref().cmp(b.file.as_ref()));
739 let mut symbols_by_file: HashMap<Arc<str>, std::ops::Range<usize>> = HashMap::default();
740 let mut i = 0;
741 while i < symbols.len() {
742 let file = Arc::clone(&symbols[i].file);
743 let start = i;
744 while i < symbols.len() && symbols[i].file == file {
745 i += 1;
746 }
747 symbols_by_file.insert(file, start..i);
748 }
749 Self {
750 issues,
751 type_envs,
752 symbols,
753 symbols_by_file,
754 }
755 }
756
757 pub fn error_count(&self) -> usize {
758 self.issues
759 .iter()
760 .filter(|i| i.severity == mir_issues::Severity::Error)
761 .count()
762 }
763
764 pub fn warning_count(&self) -> usize {
765 self.issues
766 .iter()
767 .filter(|i| i.severity == mir_issues::Severity::Warning)
768 .count()
769 }
770
771 pub fn issues_by_file(&self) -> HashMap<Arc<str>, Vec<&Issue>> {
772 let mut map: HashMap<Arc<str>, Vec<&Issue>> = HashMap::default();
773 for issue in &self.issues {
774 map.entry(issue.location.file.clone())
775 .or_default()
776 .push(issue);
777 }
778 map
779 }
780
781 pub fn count_by_severity(&self) -> Vec<(mir_issues::Severity, usize)> {
782 let mut counts: std::collections::BTreeMap<mir_issues::Severity, usize> =
783 std::collections::BTreeMap::new();
784 for issue in &self.issues {
785 *counts.entry(issue.severity).or_insert(0) += 1;
786 }
787 counts.into_iter().collect()
788 }
789
790 pub fn total_issue_count(&self) -> usize {
791 self.issues.len()
792 }
793
794 pub fn filter_issues<'a, F>(&'a self, predicate: F) -> impl Iterator<Item = &'a Issue>
795 where
796 F: Fn(&Issue) -> bool + 'a,
797 {
798 self.issues.iter().filter(move |i| predicate(i))
799 }
800
801 pub fn symbol_at(
802 &self,
803 file: &str,
804 byte_offset: u32,
805 ) -> Option<&crate::symbol::ResolvedSymbol> {
806 let range = self.symbols_by_file.get(file)?;
807 let symbols = &self.symbols[range.clone()];
808
809 if let Some(sym) = symbols
811 .iter()
812 .filter(|s| s.span.start <= byte_offset && byte_offset < s.span.end)
813 .min_by_key(|s| s.span.end - s.span.start)
814 {
815 return Some(sym);
816 }
817
818 symbols
824 .iter()
825 .filter(|s| {
826 s.expr_span
827 .is_some_and(|es| es.start <= byte_offset && byte_offset < es.end)
828 })
829 .min_by_key(|s| {
830 let es = s.expr_span.unwrap();
831 es.end - es.start
832 })
833 }
834}