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;
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 &["UnusedMethod", "UnusedProperty", "UnusedFunction"]
40}
41
42#[derive(Clone, Default)]
48pub struct BatchOptions {
49 pub suppressed_issue_kinds: HashSet<String>,
54 pub on_file_done: Option<Arc<dyn Fn() + Send + Sync>>,
56 pub php_version_override: Option<PhpVersion>,
59}
60
61impl BatchOptions {
62 pub fn new() -> Self {
63 Self::default()
64 }
65
66 pub fn with_suppressed<I, S>(mut self, kinds: I) -> Self
67 where
68 I: IntoIterator<Item = S>,
69 S: Into<String>,
70 {
71 self.suppressed_issue_kinds = kinds.into_iter().map(Into::into).collect();
72 self
73 }
74
75 pub fn with_progress_callback(mut self, callback: Arc<dyn Fn() + Send + Sync>) -> Self {
76 self.on_file_done = Some(callback);
77 self
78 }
79
80 pub fn with_php_version(mut self, version: PhpVersion) -> Self {
81 self.php_version_override = Some(version);
82 self
83 }
84
85 fn should_run_dead_code(&self) -> bool {
88 dead_code_issue_kinds()
89 .iter()
90 .any(|k| !self.suppressed_issue_kinds.contains(*k))
91 }
92
93 fn apply(&self, issues: &mut Vec<Issue>) {
96 if self.suppressed_issue_kinds.is_empty() {
97 return;
98 }
99 issues.retain(|i| !self.suppressed_issue_kinds.contains(i.kind.name()));
100 }
101}
102
103struct ParsedProjectFile {
104 file: Arc<str>,
105 source: Arc<str>,
106 parsed: php_rs_parser::ParseResult,
107}
108
109impl ParsedProjectFile {
110 fn new(file: Arc<str>, source: Arc<str>) -> Self {
111 let parsed = php_rs_parser::parse(source.as_ref());
112 Self {
113 file,
114 source,
115 parsed,
116 }
117 }
118
119 fn source(&self) -> &str {
120 self.source.as_ref()
121 }
122
123 fn source_map(&self) -> &php_rs_parser::source_map::SourceMap {
124 &self.parsed.source_map
125 }
126
127 fn errors(&self) -> &[php_rs_parser::diagnostics::ParseError] {
128 &self.parsed.errors
129 }
130
131 fn owned(&self) -> &php_ast::owned::Program {
132 &self.parsed.program
133 }
134}
135
136impl AnalysisSession {
137 #[doc(hidden)]
140 pub fn stub_cache_stats(&self) -> (u64, u64) {
141 match self.db.stub_cache.as_deref() {
142 Some(c) => (c.hits(), c.misses()),
143 None => (0, 0),
144 }
145 }
146
147 fn batch_php_version(&self, opts: &BatchOptions) -> PhpVersion {
148 opts.php_version_override.unwrap_or(self.php_version)
149 }
150
151 fn apply_inline_suppressions(&self, issues: &mut [Issue]) {
163 use crate::suppression::SuppressionMap;
164 if issues.iter().all(|i| i.suppressed) {
165 return;
166 }
167 let db = self.snapshot_db();
168 let mut cache: HashMap<Arc<str>, Option<SuppressionMap>> = HashMap::default();
171 for issue in issues.iter_mut() {
172 if issue.suppressed {
173 continue;
174 }
175 let map = cache.entry(issue.location.file.clone()).or_insert_with(|| {
176 db.lookup_source_file(&issue.location.file)
177 .map(|sf| SuppressionMap::from_source(&sf.text(&db)))
178 .filter(|m| !m.is_empty())
179 });
180 if let Some(map) = map.as_ref() {
181 if map.is_suppressed(issue.location.line, issue.kind.name(), issue.kind.code()) {
182 issue.suppressed = true;
183 }
184 }
185 }
186 }
187
188 fn type_exists(&self, fqcn: &str) -> bool {
189 let db = self.snapshot_db();
190 crate::db::class_exists(&db, fqcn)
191 }
192
193 fn collect_and_ingest_source(
194 &self,
195 file: Arc<str>,
196 src: &str,
197 php_version: PhpVersion,
198 ) -> FileDefinitions {
199 self.db.collect_and_ingest_file(file, src, php_version)
200 }
201
202 fn load_batch_stubs(&self, php_version: PhpVersion) {
206 {
209 let version_str = Arc::from(php_version.to_string().as_str());
210 self.db.salsa.write().set_php_version(version_str);
211 }
212
213 let paths: Vec<&'static str> = crate::stubs::stub_files().iter().map(|&(p, _)| p).collect();
215 self.db.ingest_stub_paths(&paths, php_version);
216
217 self.db
219 .ingest_user_stubs(&self.user_stub_files, &self.user_stub_dirs);
220
221 let mut guard = self.db.salsa.write();
224 if guard.current_resolver().is_none() {
225 let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::StubClassResolver);
226 guard.set_resolver(Some(resolver));
227 }
228 }
229
230 pub fn analyze_paths(&self, paths: &[PathBuf], opts: &BatchOptions) -> AnalysisResult {
232 let php_version = self.batch_php_version(opts);
233 let mut all_issues = Vec::new();
234 let _t0 = std::time::Instant::now();
235
236 self.load_batch_stubs(php_version);
238 let _t_stubs = _t0.elapsed();
239
240 let parsed_files: Vec<ParsedProjectFile> = paths
242 .par_iter()
243 .filter_map(|path| match std::fs::read_to_string(path) {
244 Ok(src) => {
245 let file = Arc::from(path.to_string_lossy().as_ref());
246 Some(ParsedProjectFile::new(file, Arc::from(src)))
247 }
248 Err(e) => {
249 eprintln!("Cannot read {}: {}", path.display(), e);
250 None
251 }
252 })
253 .collect();
254 let _t_read = _t0.elapsed();
255
256 let file_data: Vec<(Arc<str>, Arc<str>)> = parsed_files
257 .iter()
258 .map(|parsed| (parsed.file.clone(), parsed.source.clone()))
259 .collect();
260
261 if let Some(cache) = &self.cache {
263 let mut invalidated: Vec<String> = file_data
264 .par_iter()
265 .filter_map(|(f, src)| {
266 let h = hash_content(src.as_ref());
267 if cache.get(f, &h).is_none() {
268 Some(f.to_string())
269 } else {
270 None
271 }
272 })
273 .collect();
274
275 let current: std::collections::HashSet<&str> =
281 file_data.iter().map(|(f, _)| f.as_ref()).collect();
282 let removed: Vec<String> = cache
283 .cached_files()
284 .into_iter()
285 .filter(|f| !current.contains(f.as_str()) && !std::path::Path::new(f).exists())
286 .collect();
287 for f in &removed {
288 cache.evict(f);
289 }
290 invalidated.extend(removed);
291
292 if !invalidated.is_empty() {
293 cache.evict_with_dependents(&invalidated);
294 }
295 }
296
297 {
299 let mut guard = self.db.salsa.write();
300 for parsed in &parsed_files {
301 guard.upsert_source_file(parsed.file.clone(), parsed.source.clone());
302 }
303 }
304 let _t_salsa_reg = _t0.elapsed();
305
306 type Pass1Entry = (FileDefinitions, [u8; 32], bool);
310 let file_defs: Vec<Pass1Entry> = parsed_files
311 .par_iter()
312 .map(|parsed| {
313 let content_hash = hash_source(parsed.source());
314 let has_hard_parse_errors = parsed
315 .errors()
316 .iter()
317 .any(crate::parser::is_hard_parse_error);
318 let mut all_issues: Vec<Issue> = parsed
319 .errors()
320 .iter()
321 .map(|err| {
322 crate::parser::parse_error_to_issue(
323 err,
324 &parsed.file,
325 parsed.source(),
326 parsed.source_map(),
327 )
328 })
329 .collect();
330 let collector = crate::collector::DefinitionCollector::new_for_slice(
331 parsed.file.clone(),
332 parsed.source(),
333 parsed.source_map(),
334 );
335 let (mut slice, collector_issues) = collector.collect_slice(parsed.owned());
336 all_issues.extend(collector_issues);
337 mir_codebase::storage::deduplicate_params_in_slice(&mut slice);
338 let defs = FileDefinitions {
339 slice: Arc::new(slice),
340 issues: Arc::new(all_issues),
341 };
342 (defs, content_hash, has_hard_parse_errors)
343 })
344 .collect();
345 let _t_collect_defs = _t0.elapsed();
346
347 {
350 let guard = self.db.salsa.read();
351 for (defs, hash, has_hard_parse_errors) in &file_defs {
352 if !*has_hard_parse_errors {
353 guard.prime_parse_cache(*hash, Arc::clone(&defs.slice));
354 }
355 }
356 }
357
358 let mut files_with_parse_errors: HashSet<Arc<str>> = HashSet::default();
359 for (defs, _hash, _hard_err) in file_defs {
360 for issue in defs.issues.iter() {
361 if matches!(issue.kind, mir_issues::IssueKind::ParseError { .. })
362 && issue.severity == mir_issues::Severity::Error
363 {
364 files_with_parse_errors.insert(issue.location.file.clone());
365 }
366 }
367 all_issues.extend(Arc::unwrap_or_clone(defs.issues));
368 }
369 let _t_ingest = _t0.elapsed();
370
371 {
373 let db_prewarm = {
374 let guard = self.db.salsa.read();
375 (**guard).clone()
376 };
377 let project_source_files: Vec<SourceFile> = {
378 let guard = self.db.salsa.read();
379 parsed_files
380 .iter()
381 .filter_map(|p| (**guard).lookup_source_file(&p.file))
382 .collect()
383 };
384 project_source_files
385 .into_par_iter()
386 .for_each_with(db_prewarm, |db, sf| {
387 let _ = collect_file_definitions(db as &dyn MirDatabase, sf);
388 });
389 }
390 let _t_prewarm_ms = (_t0.elapsed() - _t_ingest).as_secs_f64() * 1000.0;
391
392 let _t_before_lazy = _t0.elapsed();
394 if let Some(psr4) = self.psr4.clone() {
395 self.lazy_load_missing_classes(psr4, php_version, &mut all_issues);
396 }
397 let _t_lazyload_ms = (_t0.elapsed() - _t_before_lazy).as_secs_f64() * 1000.0;
398
399 let analyzed_file_set: HashSet<Arc<str>> =
401 file_data.iter().map(|(f, _)| f.clone()).collect();
402 let _t_class_analyzer = std::time::Instant::now();
403 {
404 let class_db = {
405 let guard = self.db.salsa.read();
406 (**guard).clone()
407 };
408 let class_issues = crate::class::ClassAnalyzer::with_files(
409 &class_db,
410 analyzed_file_set.clone(),
411 &file_data,
412 )
413 .analyze_all();
414 all_issues.extend(class_issues);
415 }
416 let _t_class_analyzer_ms = _t_class_analyzer.elapsed().as_secs_f64() * 1000.0;
417
418 let _t_class_checks = _t0.elapsed();
419
420 let db_main = {
421 let guard = self.db.salsa.read();
422 (**guard).clone()
423 };
424
425 let body_results: Vec<(Vec<Issue>, Vec<crate::symbol::ResolvedSymbol>, Vec<RefLoc>)> =
427 parsed_files
428 .par_iter()
429 .filter(|parsed| !files_with_parse_errors.contains(&parsed.file))
430 .map_with(db_main, |db, parsed| {
431 let driver = BodyAnalyzer::new(&*db as &dyn MirDatabase, php_version);
432 let (issues, symbols) = if let Some(cache) = &self.cache {
433 let h = hash_content(parsed.source());
434 if let Some((cached_issues, ref_locs)) = cache.get(&parsed.file, &h) {
435 db.replay_reference_locations(parsed.file.clone(), &ref_locs);
436 (cached_issues, Vec::new())
437 } else {
438 let (issues, symbols) = driver.analyze_bodies(
439 parsed.owned(),
440 parsed.file.clone(),
441 parsed.source(),
442 parsed.source_map(),
443 );
444 let pending = db.take_pending_ref_locs();
445 let cache_locs = pending
446 .iter()
447 .map(|r| (r.symbol_key.to_string(), r.line, r.col_start, r.col_end))
448 .collect();
449 cache.put(&parsed.file, h, issues.clone(), cache_locs);
450 if let Some(cb) = &opts.on_file_done {
451 cb();
452 }
453 return (issues, symbols, pending);
454 }
455 } else {
456 driver.analyze_bodies(
457 parsed.owned(),
458 parsed.file.clone(),
459 parsed.source(),
460 parsed.source_map(),
461 )
462 };
463 let pending = db.take_pending_ref_locs();
464 if let Some(cb) = &opts.on_file_done {
465 cb();
466 }
467 (issues, symbols, pending)
468 })
469 .collect();
470
471 let _t_body_analysis = _t0.elapsed();
472
473 let mut all_ref_locs: Vec<RefLoc> = Vec::new();
475 let mut all_symbols = Vec::new();
476 for (issues, symbols, ref_locs) in body_results {
477 all_issues.extend(issues);
478 all_symbols.extend(symbols);
479 all_ref_locs.extend(ref_locs);
480 }
481 {
482 let guard = self.db.salsa.read();
483 guard.commit_reference_locations_batch(all_ref_locs);
484 }
485
486 if let Some(psr4) = self.psr4.clone() {
488 self.lazy_load_from_body_issues(
489 psr4,
490 php_version,
491 &file_data,
492 &files_with_parse_errors,
493 &mut all_issues,
494 &mut all_symbols,
495 );
496 }
497
498 if let Some(cache) = &self.cache {
506 let db_snapshot = {
507 let guard = self.db.salsa.read();
508 (**guard).clone()
509 };
510 let rev = build_reverse_deps(&db_snapshot);
511 cache.set_reverse_deps(rev);
512 }
513
514 if let Some(cache) = &self.cache {
516 cache.flush();
517 }
518
519 if opts.should_run_dead_code() {
521 let salsa = self.snapshot_db();
522 let _t_dead_code = std::time::Instant::now();
523 let dead_code_issues =
524 crate::dead_code::DeadCodeAnalyzer::with_files(&salsa, analyzed_file_set.clone())
525 .analyze();
526 all_issues.extend(dead_code_issues);
527 if std::env::var("MIR_TIMING").is_ok() {
528 eprintln!(
529 "[timing] dead_code_analyzer={:.0}ms",
530 _t_dead_code.elapsed().as_secs_f64() * 1000.0
531 );
532 }
533 }
534
535 let _t_total = _t0.elapsed();
536 if std::env::var("MIR_TIMING").is_ok() {
537 eprintln!(
538 "[timing] stubs={:.0}ms read={:.0}ms salsa_reg={:.0}ms collect_defs={:.0}ms ingest={:.0}ms class_checks={:.0}ms (prewarm={:.0}ms lazy_load={:.0}ms class_analyzer={:.0}ms) body_analysis={:.0}ms total={:.0}ms",
539 _t_stubs.as_secs_f64() * 1000.0,
540 (_t_read - _t_stubs).as_secs_f64() * 1000.0,
541 (_t_salsa_reg - _t_read).as_secs_f64() * 1000.0,
542 (_t_collect_defs - _t_salsa_reg).as_secs_f64() * 1000.0,
543 (_t_ingest - _t_collect_defs).as_secs_f64() * 1000.0,
544 (_t_class_checks - _t_ingest).as_secs_f64() * 1000.0,
545 _t_prewarm_ms,
546 _t_lazyload_ms,
547 _t_class_analyzer_ms,
548 (_t_body_analysis - _t_class_checks).as_secs_f64() * 1000.0,
549 _t_total.as_secs_f64() * 1000.0,
550 );
551 }
552
553 opts.apply(&mut all_issues);
554 self.apply_inline_suppressions(&mut all_issues);
555 if let Some(dump) = crate::metrics::dump() {
556 eprintln!("{dump}");
557 }
558
559 {
561 let mut guard = self.db.salsa.write();
562 guard.rebuild_workspace_symbol_index();
563 }
564
565 AnalysisResult::build(all_issues, rustc_hash::FxHashMap::default(), all_symbols)
566 }
567
568 fn lazy_load_missing_classes(
569 &self,
570 psr4: Arc<crate::composer::Psr4Map>,
571 php_version: PhpVersion,
572 all_issues: &mut Vec<Issue>,
573 ) {
574 let max_depth = 10;
575 let mut loaded: HashSet<String> = HashSet::default();
576 let mut scanned: HashSet<Arc<str>> = HashSet::default();
577
578 for _ in 0..max_depth {
579 let mut to_load: Vec<(String, PathBuf)> = Vec::new();
580
581 let mut try_queue = |fqcn: &str| {
582 if !self.type_exists(fqcn) && !loaded.contains(fqcn) {
583 if let Some(path) = psr4.resolve(fqcn) {
584 to_load.push((fqcn.to_string(), path));
585 }
586 }
587 };
588
589 let mut candidates: Vec<String> = Vec::new();
590 let import_candidates = {
591 let db_owned = self.snapshot_db();
592 let db = &db_owned;
593 for fqcn in crate::db::workspace_classes(db).iter() {
594 if scanned.contains(fqcn.as_ref()) {
595 continue;
596 }
597 let here = crate::db::Fqcn::from_str(db, fqcn.as_ref());
598 let Some(class) = crate::db::find_class_like(db, here) else {
599 continue;
600 };
601 scanned.insert(fqcn.clone());
602 collect_class_referenced_fqcns(&class, &mut candidates);
603 }
604 db.file_import_snapshots()
605 .into_iter()
606 .flat_map(|(_, imports)| {
607 imports
608 .values()
609 .map(|sym| sym.as_str().to_string())
610 .collect::<Vec<_>>()
611 })
612 .collect::<Vec<_>>()
613 };
614 for fqcn in candidates {
615 try_queue(&fqcn);
616 }
617 for fqcn in import_candidates {
618 try_queue(&fqcn);
619 }
620
621 if to_load.is_empty() {
622 break;
623 }
624
625 for (fqcn, path) in to_load {
626 loaded.insert(fqcn);
627 if let Ok(src) = std::fs::read_to_string(&path) {
628 let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
629 let is_vendor = file.contains("/vendor/") || file.contains("\\vendor\\");
630 let defs = self.collect_and_ingest_source(file, &src, php_version);
631 if !is_vendor {
632 all_issues.extend(Arc::unwrap_or_clone(defs.issues));
633 }
634 }
635 }
636 }
637 }
638
639 fn lazy_load_from_body_issues(
640 &self,
641 psr4: Arc<crate::composer::Psr4Map>,
642 php_version: PhpVersion,
643 file_data: &[(Arc<str>, Arc<str>)],
644 files_with_parse_errors: &HashSet<Arc<str>>,
645 all_issues: &mut Vec<Issue>,
646 all_symbols: &mut Vec<crate::symbol::ResolvedSymbol>,
647 ) {
648 use mir_issues::IssueKind;
649
650 let max_depth = 5;
651 let mut loaded: HashSet<String> = HashSet::default();
652
653 for _ in 0..max_depth {
654 let mut to_load: HashMap<String, PathBuf> = HashMap::default();
655
656 for issue in all_issues.iter() {
657 if let IssueKind::UndefinedClass { name } = &issue.kind {
658 if !self.type_exists(name) && !loaded.contains(name) {
659 if let Some(path) = psr4.resolve(name) {
660 to_load.entry(name.clone()).or_insert(path);
661 }
662 }
663 }
664 }
665
666 if to_load.is_empty() {
667 break;
668 }
669
670 loaded.extend(to_load.keys().cloned());
671
672 for path in to_load.values() {
673 if let Ok(src) = std::fs::read_to_string(path) {
674 let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
675 let _ = self.collect_and_ingest_source(file, &src, php_version);
676 }
677 }
678
679 self.lazy_load_missing_classes(psr4.clone(), php_version, all_issues);
680
681 let files_to_reanalyze: HashSet<Arc<str>> = all_issues
682 .iter()
683 .filter_map(|i| {
684 if let IssueKind::UndefinedClass { name } = &i.kind {
685 if self.type_exists(name) {
686 return Some(i.location.file.clone());
687 }
688 }
689 None
690 })
691 .collect();
692
693 if files_to_reanalyze.is_empty() {
694 break;
695 }
696
697 all_issues.retain(|i| !files_to_reanalyze.contains(&i.location.file));
698 all_symbols.retain(|s| !files_to_reanalyze.contains(&s.file));
699
700 let db_full = {
701 let guard = self.db.salsa.read();
702 (**guard).clone()
703 };
704
705 let reanalysis: Vec<(Vec<Issue>, Vec<crate::symbol::ResolvedSymbol>, Vec<RefLoc>)> =
706 file_data
707 .par_iter()
708 .filter(|(f, _)| {
709 !files_with_parse_errors.contains(f) && files_to_reanalyze.contains(f)
710 })
711 .map_with(db_full, |db, (file, src)| {
712 let driver = BodyAnalyzer::new(&*db as &dyn MirDatabase, php_version);
713 let parsed = php_rs_parser::parse(src);
714 let (issues, symbols) = driver.analyze_bodies(
715 &parsed.program,
716 file.clone(),
717 src,
718 &parsed.source_map,
719 );
720 let pending = db.take_pending_ref_locs();
721 (issues, symbols, pending)
722 })
723 .collect();
724
725 let mut reanalysis_ref_locs: Vec<RefLoc> = Vec::new();
726 for (issues, symbols, ref_locs) in reanalysis {
727 all_issues.extend(issues);
728 all_symbols.extend(symbols);
729 reanalysis_ref_locs.extend(ref_locs);
730 }
731 {
732 let guard = self.db.salsa.read();
733 guard.commit_reference_locations_batch(reanalysis_ref_locs);
734 }
735 }
736 }
737
738 pub fn re_analyze_file(
744 &self,
745 file_path: &str,
746 new_content: &str,
747 opts: &BatchOptions,
748 ) -> AnalysisResult {
749 let php_version = self.batch_php_version(opts);
750
751 if let Some(cache) = &self.cache {
753 let h = hash_content(new_content);
754 if let Some((mut issues, ref_locs)) = cache.get(file_path, &h) {
755 let file: Arc<str> = Arc::from(file_path);
756 let guard = self.db.salsa.read();
757 guard.replay_reference_locations(file, &ref_locs);
758 guard.commit_pending_to_maps();
759 drop(guard);
760 opts.apply(&mut issues);
761 self.apply_inline_suppressions(&mut issues);
762 return AnalysisResult::build(issues, HashMap::default(), Vec::new());
763 }
764 }
765
766 let file: Arc<str> = Arc::from(file_path);
767
768 {
769 let mut guard = self.db.salsa.write();
770 guard.remove_file_definitions(file_path);
771 }
772
773 let file_defs = {
774 let mut guard = self.db.salsa.write();
775 let salsa_file = guard.upsert_source_file(file.clone(), Arc::from(new_content));
776 collect_file_definitions(&**guard, salsa_file)
777 };
778
779 let mut all_issues: Vec<Issue> = Arc::unwrap_or_clone(file_defs.issues.clone());
780
781 {
782 let mut guard = self.db.salsa.write();
783 if guard.workspace_symbol_index_singleton().is_some() {
784 if let Some(sf) = guard.lookup_source_file(file.as_ref()) {
785 if guard.file_declarations_changed(sf) {
786 guard.rebuild_workspace_symbol_index();
787 }
788 }
789 }
790 }
791
792 let symbols = {
793 let guard = self.db.salsa.write();
794
795 let parsed = php_rs_parser::parse(new_content);
796
797 let has_hard_errors = parsed.errors.iter().any(crate::parser::is_hard_parse_error);
798 if !has_hard_errors {
799 let db_ref: &dyn MirDatabase = &**guard;
800 let driver = BodyAnalyzer::new(db_ref, php_version);
801 let (body_issues, symbols) = driver.analyze_bodies(
802 &parsed.program,
803 file.clone(),
804 new_content,
805 &parsed.source_map,
806 );
807 all_issues.extend(body_issues);
808 guard.commit_pending_to_maps();
809 symbols
810 } else {
811 Vec::new()
812 }
813 };
814
815 mark_suppressed(
823 &mut all_issues,
824 &crate::suppression::SuppressionMap::from_source(new_content),
825 );
826
827 if let Some(cache) = &self.cache {
828 let h = hash_content(new_content);
829 cache.evict_with_dependents(&[file_path.to_string()]);
830 let db = self.snapshot_db();
831 let ref_locs = extract_reference_locations(&db, &file);
832 cache.put(file_path, h, all_issues.clone(), ref_locs);
833 }
834
835 opts.apply(&mut all_issues);
836 AnalysisResult::build(all_issues, HashMap::default(), symbols)
837 }
838
839 pub fn collect_definitions(&self, paths: &[PathBuf]) {
848 let _timing = std::env::var("MIR_TIMING").is_ok();
849 let _t0 = std::time::Instant::now();
850
851 let php_v = self.php_version.cache_byte();
852
853 struct FileEntry {
854 file: Arc<str>,
855 src: Arc<str>,
856 hash: [u8; 32],
857 cached: Option<mir_codebase::storage::StubSlice>,
858 }
859 let entries: Vec<FileEntry> = paths
860 .par_iter()
861 .filter_map(|path| {
862 let src = std::fs::read_to_string(path).ok()?;
863 let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
864 let src: Arc<str> = Arc::from(src);
865 let hash = hash_source(&src);
866 let cached = self.db.stub_cache.as_ref().and_then(|c| {
867 let mut slice = c.get(&file, &hash, php_v)?;
868 prepare_for_ingest(&mut slice);
869 Some(slice)
870 });
871 Some(FileEntry {
872 file,
873 src,
874 hash,
875 cached,
876 })
877 })
878 .collect();
879 let _t_read = _t0.elapsed();
880
881 let source_files: Vec<SourceFile> = {
882 let mut guard = self.db.salsa.write();
883 entries
884 .iter()
885 .map(|e| {
886 guard.upsert_source_file_with_durability(
887 e.file.clone(),
888 e.src.clone(),
889 salsa::Durability::HIGH,
890 )
891 })
892 .collect()
893 };
894 let _t_reg = _t0.elapsed();
895
896 let db_pass1 = {
897 let guard = self.db.salsa.read();
898 (**guard).clone()
899 };
900 let stub_cache = self.db.stub_cache.clone();
901 let prepared: Vec<mir_codebase::storage::StubSlice> = entries
902 .into_par_iter()
903 .zip(source_files.into_par_iter())
904 .map_with(db_pass1, |db, (mut entry, salsa_file)| {
905 if let Some(slice) = entry.cached.take() {
906 let slice_arc = Arc::new(slice);
907 db.parse_cache().insert(entry.hash, Arc::clone(&slice_arc));
908 return (*slice_arc).clone();
909 }
910 let defs = collect_file_definitions(&*db, salsa_file);
911 if let Some(cache) = stub_cache.as_ref() {
912 cache.put(&entry.file, &entry.hash, php_v, &defs.slice);
913 }
914 (*defs.slice).clone()
915 })
916 .collect();
917 let _t_collect = _t0.elapsed();
918 drop(prepared);
919 let _t_ingest = _t0.elapsed();
920
921 if _timing {
922 let (hits, misses) = self.stub_cache_stats();
923 eprintln!(
924 "[vendor] read={:.0}ms reg={:.0}ms collect={:.0}ms ingest={:.0}ms total={:.0}ms (cache hits={hits} misses={misses})",
925 _t_read.as_secs_f64() * 1000.0,
926 (_t_reg - _t_read).as_secs_f64() * 1000.0,
927 (_t_collect - _t_reg).as_secs_f64() * 1000.0,
928 (_t_ingest - _t_collect).as_secs_f64() * 1000.0,
929 _t_ingest.as_secs_f64() * 1000.0,
930 );
931 }
932
933 {
934 let mut guard = self.db.salsa.write();
935 guard.rebuild_workspace_symbol_index();
936 }
937
938 crate::collector::print_collector_stats();
939 }
940}
941
942pub fn analyze_source(source: &str) -> AnalysisResult {
946 let php_version = PhpVersion::LATEST;
947 let file: Arc<str> = Arc::from("<source>");
948 let mut db = MirDbStorage::default();
949 db.set_php_version(Arc::from(php_version.to_string().as_str()));
950 crate::stubs::load_stubs_for_version(&mut db, php_version);
951 let salsa_file = SourceFile::new(&db, file.clone(), Arc::from(source));
952 let file_defs = collect_file_definitions(&db, salsa_file);
953 let suppressions = crate::suppression::SuppressionMap::from_source(source);
954 let mut all_issues = Arc::unwrap_or_clone(file_defs.issues);
955 if all_issues.iter().any(|issue| {
956 matches!(issue.kind, mir_issues::IssueKind::ParseError { .. })
957 && issue.severity == mir_issues::Severity::Error
958 }) {
959 mark_suppressed(&mut all_issues, &suppressions);
960 return AnalysisResult::build(all_issues, rustc_hash::FxHashMap::default(), Vec::new());
961 }
962 let mut type_envs = rustc_hash::FxHashMap::default();
963 let mut all_symbols = Vec::new();
964 let result = php_rs_parser::parse(source);
965
966 let driver = BodyAnalyzer::new(&db, php_version);
967 all_issues.extend(driver.analyze_bodies_typed(
968 &result.program,
969 file.clone(),
970 source,
971 &result.source_map,
972 &mut type_envs,
973 &mut all_symbols,
974 ));
975 mark_suppressed(&mut all_issues, &suppressions);
976 AnalysisResult::build(all_issues, type_envs, all_symbols)
977}
978
979fn mark_suppressed(issues: &mut [Issue], suppressions: &crate::suppression::SuppressionMap) {
983 if suppressions.is_empty() {
984 return;
985 }
986 for issue in issues.iter_mut() {
987 if !issue.suppressed
988 && suppressions.is_suppressed(issue.location.line, issue.kind.name(), issue.kind.code())
989 {
990 issue.suppressed = true;
991 }
992 }
993}
994
995pub fn discover_files(root: &Path) -> Vec<PathBuf> {
997 if root.is_file() {
998 return vec![root.to_path_buf()];
999 }
1000 let mut files = Vec::new();
1001 collect_php_files(root, &mut files);
1002 files
1003}
1004
1005pub(crate) fn collect_php_files(dir: &Path, out: &mut Vec<PathBuf>) {
1006 if let Ok(entries) = std::fs::read_dir(dir) {
1007 for entry in entries.flatten() {
1008 if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) {
1009 continue;
1010 }
1011 let path = entry.path();
1012 if path.is_dir() {
1013 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1014 if matches!(
1015 name,
1016 "vendor" | ".git" | "node_modules" | ".cache" | ".pnpm-store"
1017 ) {
1018 continue;
1019 }
1020 collect_php_files(&path, out);
1021 } else if path.extension().and_then(|e| e.to_str()) == Some("php") {
1022 out.push(path);
1023 }
1024 }
1025 }
1026}
1027
1028pub(crate) fn collect_class_referenced_fqcns(class: &crate::db::ClassLike, out: &mut Vec<String>) {
1035 if let Some(p) = class.parent() {
1036 out.push(p.to_string());
1037 }
1038 for i in class.interfaces() {
1039 out.push(i.to_string());
1040 }
1041 for e in class.extends() {
1042 out.push(e.to_string());
1043 }
1044 for t in class.class_traits() {
1045 out.push(t.to_string());
1046 }
1047 for m in class.mixins() {
1048 out.push(m.to_string());
1049 }
1050 for u in class.extends_type_args() {
1051 collect_fqcns_in_union(u, out);
1052 }
1053 for (iface, args) in class.implements_type_args() {
1054 out.push(iface.to_string());
1055 for u in args {
1056 collect_fqcns_in_union(u, out);
1057 }
1058 }
1059 for (_, m) in class.own_methods().iter() {
1060 for p in m.params.iter() {
1061 if let Some(t) = &p.ty {
1062 collect_fqcns_in_union(t, out);
1063 }
1064 }
1065 if let Some(t) = &m.return_type {
1066 collect_fqcns_in_union(t, out);
1067 }
1068 for thrown in m.throws.iter() {
1069 out.push(thrown.to_string());
1070 }
1071 }
1072 if let Some(props) = class.own_properties() {
1073 for (_, p) in props.iter() {
1074 if let Some(t) = &p.ty {
1075 collect_fqcns_in_union(t, out);
1076 }
1077 }
1078 }
1079 for (_, c) in class.own_constants().iter() {
1080 collect_fqcns_in_union(&c.ty, out);
1081 }
1082}
1083
1084pub(crate) fn collect_fqcns_in_union(u: &Type, out: &mut Vec<String>) {
1085 for atom in u.types.iter() {
1086 collect_fqcns_in_atomic(atom, out);
1087 }
1088}
1089
1090fn collect_fqcns_in_simple(t: &mir_types::compact::SimpleType, out: &mut Vec<String>) {
1091 if let mir_types::compact::SimpleType::Complex(u) = t {
1092 collect_fqcns_in_union(u, out);
1093 }
1094}
1095
1096pub(crate) fn collect_fqcns_in_atomic(a: &Atomic, out: &mut Vec<String>) {
1097 match a {
1098 Atomic::TNamedObject { fqcn, type_params } => {
1099 out.push(fqcn.to_string());
1100 for tp in type_params.iter() {
1101 collect_fqcns_in_union(tp, out);
1102 }
1103 }
1104 Atomic::TStaticObject { fqcn } | Atomic::TSelf { fqcn } | Atomic::TParent { fqcn } => {
1105 out.push(fqcn.to_string());
1106 }
1107 Atomic::TLiteralEnumCase { enum_fqcn, .. } => {
1108 out.push(enum_fqcn.to_string());
1109 }
1110 Atomic::TClassString(Some(s)) => {
1111 out.push(s.to_string());
1112 }
1113 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
1114 collect_fqcns_in_union(key, out);
1115 collect_fqcns_in_union(value, out);
1116 }
1117 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
1118 collect_fqcns_in_union(value, out);
1119 }
1120 Atomic::TKeyedArray { properties, .. } => {
1121 for (_, kp) in properties.iter() {
1122 collect_fqcns_in_union(&kp.ty, out);
1123 }
1124 }
1125 Atomic::TClosure {
1126 params,
1127 return_type,
1128 this_type,
1129 } => {
1130 for p in params {
1131 if let Some(t) = &p.ty {
1132 collect_fqcns_in_simple(t, out);
1133 }
1134 }
1135 collect_fqcns_in_union(return_type, out);
1136 if let Some(t) = this_type {
1137 collect_fqcns_in_union(t, out);
1138 }
1139 }
1140 Atomic::TCallable {
1141 params,
1142 return_type,
1143 } => {
1144 if let Some(ps) = params {
1145 for p in ps {
1146 if let Some(t) = &p.ty {
1147 collect_fqcns_in_simple(t, out);
1148 }
1149 }
1150 }
1151 if let Some(rt) = return_type {
1152 collect_fqcns_in_union(rt, out);
1153 }
1154 }
1155 Atomic::TIntersection { parts } => {
1156 for p in parts.iter() {
1157 collect_fqcns_in_union(p, out);
1158 }
1159 }
1160 Atomic::TConditional {
1161 param_name: _,
1162 subject,
1163 if_true,
1164 if_false,
1165 } => {
1166 collect_fqcns_in_union(subject, out);
1167 collect_fqcns_in_union(if_true, out);
1168 collect_fqcns_in_union(if_false, out);
1169 }
1170 Atomic::TTemplateParam { as_type, .. } => {
1171 collect_fqcns_in_union(as_type, out);
1172 }
1173 _ => {}
1174 }
1175}
1176
1177fn build_reverse_deps(db: &dyn crate::db::MirDatabase) -> HashMap<String, HashSet<String>> {
1178 let mut reverse: HashMap<String, HashSet<String>> = HashMap::default();
1179
1180 let mut add_edge = |symbol: &str, dependent_file: &str| {
1181 if let Some(defining_file) = db.symbol_defining_file(symbol) {
1182 let def = defining_file.as_ref().to_string();
1183 if def != dependent_file {
1184 reverse
1185 .entry(def)
1186 .or_default()
1187 .insert(dependent_file.to_string());
1188 }
1189 }
1190 };
1191
1192 for (file, imports) in db.file_import_snapshots() {
1193 let file = file.as_ref().to_string();
1194 for fqcn in imports.values() {
1195 add_edge(fqcn.as_str(), &file);
1196 }
1197 }
1198
1199 let extract_named_objects = |union: &mir_types::Type| {
1200 union
1201 .types
1202 .iter()
1203 .filter_map(|atomic| match atomic {
1204 mir_types::atomic::Atomic::TNamedObject { fqcn, .. } => Some(*fqcn),
1205 _ => None,
1206 })
1207 .collect::<Vec<_>>()
1208 };
1209
1210 for fqcn in crate::db::workspace_classes(db).iter() {
1211 let here = crate::db::Fqcn::from_str(db, fqcn.as_ref());
1212 let Some(class) = crate::db::find_class_like(db, here) else {
1213 continue;
1214 };
1215 if class.is_interface() || class.is_trait() || class.is_enum() {
1216 continue;
1217 }
1218 let Some(file) = db
1219 .symbol_defining_file(fqcn.as_ref())
1220 .map(|f| f.as_ref().to_string())
1221 .or_else(|| class.location().map(|l| l.file.as_ref().to_string()))
1222 else {
1223 continue;
1224 };
1225
1226 if let Some(parent) = class.parent() {
1227 add_edge(parent.as_ref(), &file);
1228 }
1229 for iface in class.interfaces().iter() {
1230 add_edge(iface.as_ref(), &file);
1231 }
1232 for tr in class.class_traits().iter() {
1233 add_edge(tr.as_ref(), &file);
1234 }
1235 if let Some(props) = class.own_properties() {
1236 for (_, p) in props.iter() {
1237 if let Some(ty) = &p.ty {
1238 for named in extract_named_objects(ty) {
1239 add_edge(named.as_ref(), &file);
1240 }
1241 }
1242 }
1243 }
1244 for (_, method) in class.own_methods().iter() {
1245 for param in method.params.iter() {
1246 if let Some(ty) = ¶m.ty {
1247 for named in extract_named_objects(ty.as_ref()) {
1248 add_edge(named.as_ref(), &file);
1249 }
1250 }
1251 }
1252 if let Some(rt) = method.return_type.as_deref() {
1253 for named in extract_named_objects(rt) {
1254 add_edge(named.as_ref(), &file);
1255 }
1256 }
1257 }
1258 }
1259
1260 for fqn in crate::db::workspace_functions(db).iter() {
1261 let here = crate::db::Fqcn::from_str(db, fqn.as_ref());
1262 let Some(f) = crate::db::find_function(db, here) else {
1263 continue;
1264 };
1265 let Some(file) = db
1266 .symbol_defining_file(fqn.as_ref())
1267 .map(|f| f.as_ref().to_string())
1268 .or_else(|| f.location.as_ref().map(|l| l.file.as_ref().to_string()))
1269 else {
1270 continue;
1271 };
1272
1273 for param in f.params.iter() {
1274 if let Some(ty) = ¶m.ty {
1275 for named in extract_named_objects(ty.as_ref()) {
1276 add_edge(named.as_ref(), &file);
1277 }
1278 }
1279 }
1280 if let Some(rt) = f.return_type.as_deref() {
1281 for named in extract_named_objects(rt) {
1282 add_edge(named.as_ref(), &file);
1283 }
1284 }
1285 }
1286
1287 for (ref_file, symbol_key) in db.all_reference_location_pairs() {
1288 let file_str = ref_file.as_ref().to_string();
1289 let lookup: &str = match symbol_key.split_once("::") {
1290 Some((class, _)) => class,
1291 None => &symbol_key,
1292 };
1293 add_edge(lookup, &file_str);
1294 }
1295
1296 reverse
1297}
1298
1299fn extract_reference_locations(
1300 db: &dyn crate::db::MirDatabase,
1301 file: &Arc<str>,
1302) -> Vec<(String, u32, u16, u16)> {
1303 db.extract_file_reference_locations(file.as_ref())
1304 .into_iter()
1305 .map(|(sym, line, col_start, col_end)| (sym.to_string(), line, col_start, col_end))
1306 .collect()
1307}
1308
1309pub struct AnalysisResult {
1310 pub issues: Vec<Issue>,
1311 #[doc(hidden)]
1312 pub type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
1313 pub symbols: Vec<crate::symbol::ResolvedSymbol>,
1315 symbols_by_file: HashMap<Arc<str>, std::ops::Range<usize>>,
1318}
1319
1320impl AnalysisResult {
1321 fn build(
1322 issues: Vec<Issue>,
1323 type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
1324 mut symbols: Vec<crate::symbol::ResolvedSymbol>,
1325 ) -> Self {
1326 symbols.sort_unstable_by(|a, b| a.file.as_ref().cmp(b.file.as_ref()));
1327 let mut symbols_by_file: HashMap<Arc<str>, std::ops::Range<usize>> = HashMap::default();
1328 let mut i = 0;
1329 while i < symbols.len() {
1330 let file = Arc::clone(&symbols[i].file);
1331 let start = i;
1332 while i < symbols.len() && symbols[i].file == file {
1333 i += 1;
1334 }
1335 symbols_by_file.insert(file, start..i);
1336 }
1337 Self {
1338 issues,
1339 type_envs,
1340 symbols,
1341 symbols_by_file,
1342 }
1343 }
1344
1345 pub fn error_count(&self) -> usize {
1346 self.issues
1347 .iter()
1348 .filter(|i| i.severity == mir_issues::Severity::Error)
1349 .count()
1350 }
1351
1352 pub fn warning_count(&self) -> usize {
1353 self.issues
1354 .iter()
1355 .filter(|i| i.severity == mir_issues::Severity::Warning)
1356 .count()
1357 }
1358
1359 pub fn issues_by_file(&self) -> HashMap<Arc<str>, Vec<&Issue>> {
1360 let mut map: HashMap<Arc<str>, Vec<&Issue>> = HashMap::default();
1361 for issue in &self.issues {
1362 map.entry(issue.location.file.clone())
1363 .or_default()
1364 .push(issue);
1365 }
1366 map
1367 }
1368
1369 pub fn count_by_severity(&self) -> Vec<(mir_issues::Severity, usize)> {
1370 let mut counts: std::collections::BTreeMap<mir_issues::Severity, usize> =
1371 std::collections::BTreeMap::new();
1372 for issue in &self.issues {
1373 *counts.entry(issue.severity).or_insert(0) += 1;
1374 }
1375 counts.into_iter().collect()
1376 }
1377
1378 pub fn total_issue_count(&self) -> usize {
1379 self.issues.len()
1380 }
1381
1382 pub fn filter_issues<'a, F>(&'a self, predicate: F) -> impl Iterator<Item = &'a Issue>
1383 where
1384 F: Fn(&Issue) -> bool + 'a,
1385 {
1386 self.issues.iter().filter(move |i| predicate(i))
1387 }
1388
1389 pub fn symbol_at(
1390 &self,
1391 file: &str,
1392 byte_offset: u32,
1393 ) -> Option<&crate::symbol::ResolvedSymbol> {
1394 let range = self.symbols_by_file.get(file)?;
1395 self.symbols[range.clone()]
1396 .iter()
1397 .filter(|s| s.span.start <= byte_offset && byte_offset < s.span.end)
1398 .min_by_key(|s| s.span.end - s.span.start)
1399 }
1400}