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 refresh_workspace_index(&self) {
210 let mut guard = self.db.salsa.write();
211 guard.rebuild_workspace_symbol_index();
212 }
213
214 fn load_batch_stubs(&self, php_version: PhpVersion) {
218 {
221 let version_str = Arc::from(php_version.to_string().as_str());
222 self.db.salsa.write().set_php_version(version_str);
223 }
224
225 let paths: Vec<&'static str> = crate::stubs::stub_files().iter().map(|&(p, _)| p).collect();
227 self.db.ingest_stub_paths(&paths, php_version);
228
229 self.db
231 .ingest_user_stubs(&self.user_stub_files, &self.user_stub_dirs);
232
233 let mut guard = self.db.salsa.write();
236 if guard.current_resolver().is_none() {
237 let resolver: Arc<dyn crate::ClassResolver> = Arc::new(crate::StubClassResolver);
238 guard.set_resolver(Some(resolver));
239 }
240 }
241
242 pub fn analyze_paths(&self, paths: &[PathBuf], opts: &BatchOptions) -> AnalysisResult {
244 let php_version = self.batch_php_version(opts);
245 let mut all_issues = Vec::new();
246 let _t0 = std::time::Instant::now();
247
248 self.load_batch_stubs(php_version);
250 let _t_stubs = _t0.elapsed();
251
252 let parsed_files: Vec<ParsedProjectFile> = paths
254 .par_iter()
255 .filter_map(|path| match std::fs::read_to_string(path) {
256 Ok(src) => {
257 let file = Arc::from(path.to_string_lossy().as_ref());
258 Some(ParsedProjectFile::new(file, Arc::from(src)))
259 }
260 Err(e) => {
261 eprintln!("Cannot read {}: {}", path.display(), e);
262 None
263 }
264 })
265 .collect();
266 let _t_read = _t0.elapsed();
267
268 let file_data: Vec<(Arc<str>, Arc<str>)> = parsed_files
269 .iter()
270 .map(|parsed| (parsed.file.clone(), parsed.source.clone()))
271 .collect();
272
273 if let Some(cache) = &self.cache {
275 let mut invalidated: Vec<String> = file_data
276 .par_iter()
277 .filter_map(|(f, src)| {
278 let h = hash_content(src.as_ref());
279 if cache.get(f, &h).is_none() {
280 Some(f.to_string())
281 } else {
282 None
283 }
284 })
285 .collect();
286
287 let current: std::collections::HashSet<&str> =
293 file_data.iter().map(|(f, _)| f.as_ref()).collect();
294 let removed: Vec<String> = cache
295 .cached_files()
296 .into_iter()
297 .filter(|f| !current.contains(f.as_str()) && !std::path::Path::new(f).exists())
298 .collect();
299 for f in &removed {
300 cache.evict(f);
301 }
302 invalidated.extend(removed);
303
304 if !invalidated.is_empty() {
305 cache.evict_with_dependents(&invalidated);
306 }
307 }
308
309 {
311 let mut guard = self.db.salsa.write();
312 for parsed in &parsed_files {
313 guard.upsert_source_file(parsed.file.clone(), parsed.source.clone());
314 }
315 }
316 let _t_salsa_reg = _t0.elapsed();
317
318 type Pass1Entry = (FileDefinitions, [u8; 32], bool);
322 let file_defs: Vec<Pass1Entry> = parsed_files
323 .par_iter()
324 .map(|parsed| {
325 let content_hash = hash_source(parsed.source());
326 let has_hard_parse_errors = parsed
327 .errors()
328 .iter()
329 .any(crate::parser::is_hard_parse_error);
330 let mut all_issues: Vec<Issue> = parsed
331 .errors()
332 .iter()
333 .map(|err| {
334 crate::parser::parse_error_to_issue(
335 err,
336 &parsed.file,
337 parsed.source(),
338 parsed.source_map(),
339 )
340 })
341 .collect();
342 let collector = crate::collector::DefinitionCollector::new_for_slice(
343 parsed.file.clone(),
344 parsed.source(),
345 parsed.source_map(),
346 );
347 let (mut slice, collector_issues) = collector.collect_slice(parsed.owned());
348 all_issues.extend(collector_issues);
349 mir_codebase::storage::deduplicate_params_in_slice(&mut slice);
350 let defs = FileDefinitions {
351 slice: Arc::new(slice),
352 issues: Arc::new(all_issues),
353 };
354 (defs, content_hash, has_hard_parse_errors)
355 })
356 .collect();
357 let _t_collect_defs = _t0.elapsed();
358
359 {
362 let guard = self.db.salsa.read();
363 for (defs, hash, has_hard_parse_errors) in &file_defs {
364 if !*has_hard_parse_errors {
365 guard.prime_parse_cache(*hash, Arc::clone(&defs.slice));
366 }
367 }
368 }
369
370 let mut files_with_parse_errors: HashSet<Arc<str>> = HashSet::default();
371 for (defs, _hash, _hard_err) in file_defs {
372 for issue in defs.issues.iter() {
373 if matches!(issue.kind, mir_issues::IssueKind::ParseError { .. })
374 && issue.severity == mir_issues::Severity::Error
375 {
376 files_with_parse_errors.insert(issue.location.file.clone());
377 }
378 }
379 all_issues.extend(Arc::unwrap_or_clone(defs.issues));
380 }
381 let _t_ingest = _t0.elapsed();
382
383 {
385 let db_prewarm = {
386 let guard = self.db.salsa.read();
387 (**guard).clone()
388 };
389 let project_source_files: Vec<SourceFile> = {
390 let guard = self.db.salsa.read();
391 parsed_files
392 .iter()
393 .filter_map(|p| (**guard).lookup_source_file(&p.file))
394 .collect()
395 };
396 project_source_files
397 .into_par_iter()
398 .for_each_with(db_prewarm, |db, sf| {
399 let _ = collect_file_definitions(db as &dyn MirDatabase, sf);
400 });
401 }
402 let _t_prewarm_ms = (_t0.elapsed() - _t_ingest).as_secs_f64() * 1000.0;
403
404 self.refresh_workspace_index();
410
411 let _t_before_lazy = _t0.elapsed();
413 if let Some(psr4) = self.psr4.clone() {
414 self.lazy_load_missing_classes(psr4, php_version, &mut all_issues);
415 }
416 let _t_lazyload_ms = (_t0.elapsed() - _t_before_lazy).as_secs_f64() * 1000.0;
417
418 let analyzed_file_set: HashSet<Arc<str>> =
420 file_data.iter().map(|(f, _)| f.clone()).collect();
421 let _t_class_analyzer = std::time::Instant::now();
422 {
423 let class_db = {
424 let guard = self.db.salsa.read();
425 (**guard).clone()
426 };
427 let class_issues = crate::class::ClassAnalyzer::with_files(
428 &class_db,
429 analyzed_file_set.clone(),
430 &file_data,
431 )
432 .analyze_all();
433 all_issues.extend(class_issues);
434 }
435 let _t_class_analyzer_ms = _t_class_analyzer.elapsed().as_secs_f64() * 1000.0;
436
437 let _t_class_checks = _t0.elapsed();
438
439 let mut db_main = {
440 let guard = self.db.salsa.read();
441 (**guard).clone()
442 };
443 db_main.freeze_workspace_index();
449
450 let body_results: Vec<(Vec<Issue>, Vec<crate::symbol::ResolvedSymbol>, Vec<RefLoc>)> =
452 parsed_files
453 .par_iter()
454 .filter(|parsed| !files_with_parse_errors.contains(&parsed.file))
455 .map_with(db_main, |db, parsed| {
456 let driver = BodyAnalyzer::new(&*db as &dyn MirDatabase, php_version);
457 let (issues, symbols) = if let Some(cache) = &self.cache {
458 let h = hash_content(parsed.source());
459 if let Some((cached_issues, ref_locs)) = cache.get(&parsed.file, &h) {
460 db.replay_reference_locations(parsed.file.clone(), &ref_locs);
461 (cached_issues, Vec::new())
462 } else {
463 let (issues, symbols) = driver.analyze_bodies(
464 parsed.owned(),
465 parsed.file.clone(),
466 parsed.source(),
467 parsed.source_map(),
468 );
469 let pending = db.take_pending_ref_locs();
470 let cache_locs = pending
471 .iter()
472 .map(|r| (r.symbol_key.to_string(), r.line, r.col_start, r.col_end))
473 .collect();
474 cache.put(&parsed.file, h, issues.clone(), cache_locs);
475 if let Some(cb) = &opts.on_file_done {
476 cb();
477 }
478 return (issues, symbols, pending);
479 }
480 } else {
481 driver.analyze_bodies(
482 parsed.owned(),
483 parsed.file.clone(),
484 parsed.source(),
485 parsed.source_map(),
486 )
487 };
488 let pending = db.take_pending_ref_locs();
489 if let Some(cb) = &opts.on_file_done {
490 cb();
491 }
492 (issues, symbols, pending)
493 })
494 .collect();
495
496 let _t_body_analysis = _t0.elapsed();
497
498 let mut all_ref_locs: Vec<RefLoc> = Vec::new();
500 let mut all_symbols = Vec::new();
501 for (issues, symbols, ref_locs) in body_results {
502 all_issues.extend(issues);
503 all_symbols.extend(symbols);
504 all_ref_locs.extend(ref_locs);
505 }
506 {
507 let guard = self.db.salsa.read();
508 guard.commit_reference_locations_batch(all_ref_locs);
509 }
510
511 if let Some(psr4) = self.psr4.clone() {
513 self.lazy_load_from_body_issues(
514 psr4,
515 php_version,
516 &file_data,
517 &files_with_parse_errors,
518 &mut all_issues,
519 &mut all_symbols,
520 );
521 }
522
523 if let Some(cache) = &self.cache {
531 let db_snapshot = {
532 let guard = self.db.salsa.read();
533 (**guard).clone()
534 };
535 let rev = build_reverse_deps(&db_snapshot);
536 cache.set_reverse_deps(rev);
537 }
538
539 if let Some(cache) = &self.cache {
541 cache.flush();
542 }
543
544 if opts.should_run_dead_code() {
546 let salsa = self.snapshot_db();
547 let _t_dead_code = std::time::Instant::now();
548 let dead_code_issues =
549 crate::dead_code::DeadCodeAnalyzer::with_files(&salsa, analyzed_file_set.clone())
550 .analyze();
551 all_issues.extend(dead_code_issues);
552 if std::env::var("MIR_TIMING").is_ok() {
553 eprintln!(
554 "[timing] dead_code_analyzer={:.0}ms",
555 _t_dead_code.elapsed().as_secs_f64() * 1000.0
556 );
557 }
558 }
559
560 let _t_total = _t0.elapsed();
561 if std::env::var("MIR_TIMING").is_ok() {
562 eprintln!(
563 "[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",
564 _t_stubs.as_secs_f64() * 1000.0,
565 (_t_read - _t_stubs).as_secs_f64() * 1000.0,
566 (_t_salsa_reg - _t_read).as_secs_f64() * 1000.0,
567 (_t_collect_defs - _t_salsa_reg).as_secs_f64() * 1000.0,
568 (_t_ingest - _t_collect_defs).as_secs_f64() * 1000.0,
569 (_t_class_checks - _t_ingest).as_secs_f64() * 1000.0,
570 _t_prewarm_ms,
571 _t_lazyload_ms,
572 _t_class_analyzer_ms,
573 (_t_body_analysis - _t_class_checks).as_secs_f64() * 1000.0,
574 _t_total.as_secs_f64() * 1000.0,
575 );
576 }
577
578 opts.apply(&mut all_issues);
579 self.apply_inline_suppressions(&mut all_issues);
580 if let Some(dump) = crate::metrics::dump() {
581 eprintln!("{dump}");
582 }
583
584 {
586 let mut guard = self.db.salsa.write();
587 guard.rebuild_workspace_symbol_index();
588 }
589
590 AnalysisResult::build(all_issues, rustc_hash::FxHashMap::default(), all_symbols)
591 }
592
593 fn lazy_load_missing_classes(
594 &self,
595 psr4: Arc<crate::composer::Psr4Map>,
596 php_version: PhpVersion,
597 all_issues: &mut Vec<Issue>,
598 ) {
599 let max_depth = 10;
600 let mut loaded: HashSet<String> = HashSet::default();
601 let mut scanned: HashSet<Arc<str>> = HashSet::default();
602
603 for _ in 0..max_depth {
604 let mut to_load: Vec<(String, PathBuf)> = Vec::new();
605
606 let mut try_queue = |fqcn: &str| {
607 if !self.type_exists(fqcn) && !loaded.contains(fqcn) {
608 if let Some(path) = psr4.resolve(fqcn) {
609 to_load.push((fqcn.to_string(), path));
610 }
611 }
612 };
613
614 let mut candidates: Vec<String> = Vec::new();
615 let import_candidates = {
616 let db_owned = self.snapshot_db();
617 let db = &db_owned;
618 for fqcn in crate::db::workspace_classes(db).iter() {
619 if scanned.contains(fqcn.as_ref()) {
620 continue;
621 }
622 let here = crate::db::Fqcn::from_str(db, fqcn.as_ref());
623 let Some(class) = crate::db::find_class_like(db, here) else {
624 continue;
625 };
626 scanned.insert(fqcn.clone());
627 collect_class_referenced_fqcns(&class, &mut candidates);
628 }
629 db.file_import_snapshots()
630 .into_iter()
631 .flat_map(|(_, imports)| {
632 imports
633 .values()
634 .map(|sym| sym.as_str().to_string())
635 .collect::<Vec<_>>()
636 })
637 .collect::<Vec<_>>()
638 };
639 for fqcn in candidates {
640 try_queue(&fqcn);
641 }
642 for fqcn in import_candidates {
643 try_queue(&fqcn);
644 }
645
646 if to_load.is_empty() {
647 break;
648 }
649
650 for (fqcn, _) in &to_load {
654 loaded.insert(fqcn.clone());
655 }
656
657 let per_file_issues: Vec<Vec<Issue>> = to_load
665 .par_iter()
666 .map(|(_, path)| -> Vec<Issue> {
667 let Ok(src) = std::fs::read_to_string(path) else {
668 return Vec::new();
669 };
670 let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
671 let is_vendor = file.contains("/vendor/") || file.contains("\\vendor\\");
672 let defs = self.collect_and_ingest_source(file, &src, php_version);
673 if is_vendor {
674 Vec::new()
675 } else {
676 Arc::unwrap_or_clone(defs.issues)
677 }
678 })
679 .collect();
680 for mut issues in per_file_issues {
681 all_issues.append(&mut issues);
682 }
683
684 self.refresh_workspace_index();
687 }
688 }
689
690 fn lazy_load_from_body_issues(
691 &self,
692 psr4: Arc<crate::composer::Psr4Map>,
693 php_version: PhpVersion,
694 file_data: &[(Arc<str>, Arc<str>)],
695 files_with_parse_errors: &HashSet<Arc<str>>,
696 all_issues: &mut Vec<Issue>,
697 all_symbols: &mut Vec<crate::symbol::ResolvedSymbol>,
698 ) {
699 use mir_issues::IssueKind;
700
701 let max_depth = 5;
702 let mut loaded: HashSet<String> = HashSet::default();
703
704 for _ in 0..max_depth {
705 let mut to_load: HashMap<String, PathBuf> = HashMap::default();
706
707 for issue in all_issues.iter() {
708 if let IssueKind::UndefinedClass { name } = &issue.kind {
709 if !self.type_exists(name) && !loaded.contains(name) {
710 if let Some(path) = psr4.resolve(name) {
711 to_load.entry(name.clone()).or_insert(path);
712 }
713 }
714 }
715 }
716
717 if to_load.is_empty() {
718 break;
719 }
720
721 loaded.extend(to_load.keys().cloned());
722
723 for path in to_load.values() {
724 if let Ok(src) = std::fs::read_to_string(path) {
725 let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
726 let _ = self.collect_and_ingest_source(file, &src, php_version);
727 }
728 }
729
730 self.refresh_workspace_index();
733
734 self.lazy_load_missing_classes(psr4.clone(), php_version, all_issues);
735
736 let files_to_reanalyze: HashSet<Arc<str>> = all_issues
737 .iter()
738 .filter_map(|i| {
739 if let IssueKind::UndefinedClass { name } = &i.kind {
740 if self.type_exists(name) {
741 return Some(i.location.file.clone());
742 }
743 }
744 None
745 })
746 .collect();
747
748 if files_to_reanalyze.is_empty() {
749 break;
750 }
751
752 all_issues.retain(|i| !files_to_reanalyze.contains(&i.location.file));
753 all_symbols.retain(|s| !files_to_reanalyze.contains(&s.file));
754
755 let db_full = {
756 let guard = self.db.salsa.read();
757 (**guard).clone()
758 };
759
760 let reanalysis: Vec<(Vec<Issue>, Vec<crate::symbol::ResolvedSymbol>, Vec<RefLoc>)> =
761 file_data
762 .par_iter()
763 .filter(|(f, _)| {
764 !files_with_parse_errors.contains(f) && files_to_reanalyze.contains(f)
765 })
766 .map_with(db_full, |db, (file, src)| {
767 let driver = BodyAnalyzer::new(&*db as &dyn MirDatabase, php_version);
768 let parsed = php_rs_parser::parse(src);
769 let (issues, symbols) = driver.analyze_bodies(
770 &parsed.program,
771 file.clone(),
772 src,
773 &parsed.source_map,
774 );
775 let pending = db.take_pending_ref_locs();
776 (issues, symbols, pending)
777 })
778 .collect();
779
780 let mut reanalysis_ref_locs: Vec<RefLoc> = Vec::new();
781 for (issues, symbols, ref_locs) in reanalysis {
782 all_issues.extend(issues);
783 all_symbols.extend(symbols);
784 reanalysis_ref_locs.extend(ref_locs);
785 }
786 {
787 let guard = self.db.salsa.read();
788 guard.commit_reference_locations_batch(reanalysis_ref_locs);
789 }
790 }
791 }
792
793 pub fn re_analyze_file(
799 &self,
800 file_path: &str,
801 new_content: &str,
802 opts: &BatchOptions,
803 ) -> AnalysisResult {
804 let php_version = self.batch_php_version(opts);
805
806 if let Some(cache) = &self.cache {
808 let h = hash_content(new_content);
809 if let Some((mut issues, ref_locs)) = cache.get(file_path, &h) {
810 let file: Arc<str> = Arc::from(file_path);
811 let guard = self.db.salsa.read();
812 guard.replay_reference_locations(file, &ref_locs);
813 guard.commit_pending_to_maps();
814 drop(guard);
815 opts.apply(&mut issues);
816 self.apply_inline_suppressions(&mut issues);
817 return AnalysisResult::build(issues, HashMap::default(), Vec::new());
818 }
819 }
820
821 let file: Arc<str> = Arc::from(file_path);
822
823 {
824 let mut guard = self.db.salsa.write();
825 guard.remove_file_definitions(file_path);
826 }
827
828 let file_defs = {
829 let mut guard = self.db.salsa.write();
830 let salsa_file = guard.upsert_source_file(file.clone(), Arc::from(new_content));
831 collect_file_definitions(&**guard, salsa_file)
832 };
833
834 let mut all_issues: Vec<Issue> = Arc::unwrap_or_clone(file_defs.issues.clone());
835
836 {
837 let mut guard = self.db.salsa.write();
838 if guard.workspace_symbol_index_singleton().is_some() {
839 if let Some(sf) = guard.lookup_source_file(file.as_ref()) {
840 if guard.file_declarations_changed(sf) {
841 guard.rebuild_workspace_symbol_index();
842 }
843 }
844 }
845 }
846
847 let symbols = {
848 let guard = self.db.salsa.write();
849
850 let parsed = php_rs_parser::parse(new_content);
851
852 let has_hard_errors = parsed.errors.iter().any(crate::parser::is_hard_parse_error);
853 if !has_hard_errors {
854 let db_ref: &dyn MirDatabase = &**guard;
855 let driver = BodyAnalyzer::new(db_ref, php_version);
856 let (body_issues, symbols) = driver.analyze_bodies(
857 &parsed.program,
858 file.clone(),
859 new_content,
860 &parsed.source_map,
861 );
862 all_issues.extend(body_issues);
863 guard.commit_pending_to_maps();
864 symbols
865 } else {
866 Vec::new()
867 }
868 };
869
870 mark_suppressed(
878 &mut all_issues,
879 &crate::suppression::SuppressionMap::from_source(new_content),
880 );
881
882 if let Some(cache) = &self.cache {
883 let h = hash_content(new_content);
884 cache.evict_with_dependents(&[file_path.to_string()]);
885 let db = self.snapshot_db();
886 let ref_locs = extract_reference_locations(&db, &file);
887 cache.put(file_path, h, all_issues.clone(), ref_locs);
888 }
889
890 opts.apply(&mut all_issues);
891 AnalysisResult::build(all_issues, HashMap::default(), symbols)
892 }
893
894 pub fn collect_definitions(&self, paths: &[PathBuf]) {
903 let _timing = std::env::var("MIR_TIMING").is_ok();
904 let _t0 = std::time::Instant::now();
905
906 let php_v = self.php_version.cache_byte();
907
908 struct FileEntry {
909 file: Arc<str>,
910 src: Arc<str>,
911 hash: [u8; 32],
912 cached: Option<mir_codebase::storage::StubSlice>,
913 }
914 let entries: Vec<FileEntry> = paths
915 .par_iter()
916 .filter_map(|path| {
917 let src = std::fs::read_to_string(path).ok()?;
918 let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
919 let src: Arc<str> = Arc::from(src);
920 let hash = hash_source(&src);
921 let cached = self.db.stub_cache.as_ref().and_then(|c| {
922 let mut slice = c.get(&file, &hash, php_v)?;
923 prepare_for_ingest(&mut slice);
924 Some(slice)
925 });
926 Some(FileEntry {
927 file,
928 src,
929 hash,
930 cached,
931 })
932 })
933 .collect();
934 let _t_read = _t0.elapsed();
935
936 let source_files: Vec<SourceFile> = {
937 let mut guard = self.db.salsa.write();
938 entries
939 .iter()
940 .map(|e| {
941 guard.upsert_source_file_with_durability(
942 e.file.clone(),
943 e.src.clone(),
944 salsa::Durability::HIGH,
945 )
946 })
947 .collect()
948 };
949 let _t_reg = _t0.elapsed();
950
951 let db_pass1 = {
952 let guard = self.db.salsa.read();
953 (**guard).clone()
954 };
955 let stub_cache = self.db.stub_cache.clone();
956 let prepared: Vec<mir_codebase::storage::StubSlice> = entries
957 .into_par_iter()
958 .zip(source_files.into_par_iter())
959 .map_with(db_pass1, |db, (mut entry, salsa_file)| {
960 if let Some(slice) = entry.cached.take() {
961 let slice_arc = Arc::new(slice);
962 db.parse_cache().insert(entry.hash, Arc::clone(&slice_arc));
963 return (*slice_arc).clone();
964 }
965 let defs = collect_file_definitions(&*db, salsa_file);
966 if let Some(cache) = stub_cache.as_ref() {
967 cache.put(&entry.file, &entry.hash, php_v, &defs.slice);
968 }
969 (*defs.slice).clone()
970 })
971 .collect();
972 let _t_collect = _t0.elapsed();
973 drop(prepared);
974 let _t_ingest = _t0.elapsed();
975
976 if _timing {
977 let (hits, misses) = self.stub_cache_stats();
978 eprintln!(
979 "[vendor] read={:.0}ms reg={:.0}ms collect={:.0}ms ingest={:.0}ms total={:.0}ms (cache hits={hits} misses={misses})",
980 _t_read.as_secs_f64() * 1000.0,
981 (_t_reg - _t_read).as_secs_f64() * 1000.0,
982 (_t_collect - _t_reg).as_secs_f64() * 1000.0,
983 (_t_ingest - _t_collect).as_secs_f64() * 1000.0,
984 _t_ingest.as_secs_f64() * 1000.0,
985 );
986 }
987
988 {
989 let mut guard = self.db.salsa.write();
990 guard.rebuild_workspace_symbol_index();
991 }
992
993 crate::collector::print_collector_stats();
994 }
995}
996
997pub fn analyze_source(source: &str) -> AnalysisResult {
1001 let php_version = PhpVersion::LATEST;
1002 let file: Arc<str> = Arc::from("<source>");
1003 let mut db = MirDbStorage::default();
1004 db.set_php_version(Arc::from(php_version.to_string().as_str()));
1005 crate::stubs::load_stubs_for_version(&mut db, php_version);
1006 let salsa_file = SourceFile::new(&db, file.clone(), Arc::from(source));
1007 let file_defs = collect_file_definitions(&db, salsa_file);
1008 let suppressions = crate::suppression::SuppressionMap::from_source(source);
1009 let mut all_issues = Arc::unwrap_or_clone(file_defs.issues);
1010 if all_issues.iter().any(|issue| {
1011 matches!(issue.kind, mir_issues::IssueKind::ParseError { .. })
1012 && issue.severity == mir_issues::Severity::Error
1013 }) {
1014 mark_suppressed(&mut all_issues, &suppressions);
1015 return AnalysisResult::build(all_issues, rustc_hash::FxHashMap::default(), Vec::new());
1016 }
1017 let mut type_envs = rustc_hash::FxHashMap::default();
1018 let mut all_symbols = Vec::new();
1019 let result = php_rs_parser::parse(source);
1020
1021 let driver = BodyAnalyzer::new(&db, php_version);
1022 all_issues.extend(driver.analyze_bodies_typed(
1023 &result.program,
1024 file.clone(),
1025 source,
1026 &result.source_map,
1027 &mut type_envs,
1028 &mut all_symbols,
1029 ));
1030 mark_suppressed(&mut all_issues, &suppressions);
1031 AnalysisResult::build(all_issues, type_envs, all_symbols)
1032}
1033
1034fn mark_suppressed(issues: &mut [Issue], suppressions: &crate::suppression::SuppressionMap) {
1038 if suppressions.is_empty() {
1039 return;
1040 }
1041 for issue in issues.iter_mut() {
1042 if !issue.suppressed
1043 && suppressions.is_suppressed(issue.location.line, issue.kind.name(), issue.kind.code())
1044 {
1045 issue.suppressed = true;
1046 }
1047 }
1048}
1049
1050pub fn discover_files(root: &Path) -> Vec<PathBuf> {
1052 if root.is_file() {
1053 return vec![root.to_path_buf()];
1054 }
1055 let mut files = Vec::new();
1056 collect_php_files(root, &mut files);
1057 files
1058}
1059
1060pub(crate) fn collect_php_files(dir: &Path, out: &mut Vec<PathBuf>) {
1061 if let Ok(entries) = std::fs::read_dir(dir) {
1062 for entry in entries.flatten() {
1063 if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false) {
1064 continue;
1065 }
1066 let path = entry.path();
1067 if path.is_dir() {
1068 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1069 if matches!(
1070 name,
1071 "vendor" | ".git" | "node_modules" | ".cache" | ".pnpm-store"
1072 ) {
1073 continue;
1074 }
1075 collect_php_files(&path, out);
1076 } else if path.extension().and_then(|e| e.to_str()) == Some("php") {
1077 out.push(path);
1078 }
1079 }
1080 }
1081}
1082
1083pub(crate) fn collect_class_referenced_fqcns(class: &crate::db::ClassLike, out: &mut Vec<String>) {
1090 if let Some(p) = class.parent() {
1091 out.push(p.to_string());
1092 }
1093 for i in class.interfaces() {
1094 out.push(i.to_string());
1095 }
1096 for e in class.extends() {
1097 out.push(e.to_string());
1098 }
1099 for t in class.class_traits() {
1100 out.push(t.to_string());
1101 }
1102 for m in class.mixins() {
1103 out.push(m.to_string());
1104 }
1105 for u in class.extends_type_args() {
1106 collect_fqcns_in_union(u, out);
1107 }
1108 for (iface, args) in class.implements_type_args() {
1109 out.push(iface.to_string());
1110 for u in args {
1111 collect_fqcns_in_union(u, out);
1112 }
1113 }
1114 for (_, m) in class.own_methods().iter() {
1115 for p in m.params.iter() {
1116 if let Some(t) = &p.ty {
1117 collect_fqcns_in_union(t, out);
1118 }
1119 }
1120 if let Some(t) = &m.return_type {
1121 collect_fqcns_in_union(t, out);
1122 }
1123 for thrown in m.throws.iter() {
1124 out.push(thrown.to_string());
1125 }
1126 }
1127 if let Some(props) = class.own_properties() {
1128 for (_, p) in props.iter() {
1129 if let Some(t) = &p.ty {
1130 collect_fqcns_in_union(t, out);
1131 }
1132 }
1133 }
1134 for (_, c) in class.own_constants().iter() {
1135 collect_fqcns_in_union(&c.ty, out);
1136 }
1137}
1138
1139pub(crate) fn collect_fqcns_in_union(u: &Type, out: &mut Vec<String>) {
1140 for atom in u.types.iter() {
1141 collect_fqcns_in_atomic(atom, out);
1142 }
1143}
1144
1145fn collect_fqcns_in_simple(t: &mir_types::compact::SimpleType, out: &mut Vec<String>) {
1146 if let mir_types::compact::SimpleType::Complex(u) = t {
1147 collect_fqcns_in_union(u, out);
1148 }
1149}
1150
1151pub(crate) fn collect_fqcns_in_atomic(a: &Atomic, out: &mut Vec<String>) {
1152 match a {
1153 Atomic::TNamedObject { fqcn, type_params } => {
1154 out.push(fqcn.to_string());
1155 for tp in type_params.iter() {
1156 collect_fqcns_in_union(tp, out);
1157 }
1158 }
1159 Atomic::TStaticObject { fqcn } | Atomic::TSelf { fqcn } | Atomic::TParent { fqcn } => {
1160 out.push(fqcn.to_string());
1161 }
1162 Atomic::TLiteralEnumCase { enum_fqcn, .. } => {
1163 out.push(enum_fqcn.to_string());
1164 }
1165 Atomic::TClassString(Some(s)) => {
1166 out.push(s.to_string());
1167 }
1168 Atomic::TArray { key, value } | Atomic::TNonEmptyArray { key, value } => {
1169 collect_fqcns_in_union(key, out);
1170 collect_fqcns_in_union(value, out);
1171 }
1172 Atomic::TList { value } | Atomic::TNonEmptyList { value } => {
1173 collect_fqcns_in_union(value, out);
1174 }
1175 Atomic::TKeyedArray { properties, .. } => {
1176 for (_, kp) in properties.iter() {
1177 collect_fqcns_in_union(&kp.ty, out);
1178 }
1179 }
1180 Atomic::TClosure {
1181 params,
1182 return_type,
1183 this_type,
1184 } => {
1185 for p in params {
1186 if let Some(t) = &p.ty {
1187 collect_fqcns_in_simple(t, out);
1188 }
1189 }
1190 collect_fqcns_in_union(return_type, out);
1191 if let Some(t) = this_type {
1192 collect_fqcns_in_union(t, out);
1193 }
1194 }
1195 Atomic::TCallable {
1196 params,
1197 return_type,
1198 } => {
1199 if let Some(ps) = params {
1200 for p in ps {
1201 if let Some(t) = &p.ty {
1202 collect_fqcns_in_simple(t, out);
1203 }
1204 }
1205 }
1206 if let Some(rt) = return_type {
1207 collect_fqcns_in_union(rt, out);
1208 }
1209 }
1210 Atomic::TIntersection { parts } => {
1211 for p in parts.iter() {
1212 collect_fqcns_in_union(p, out);
1213 }
1214 }
1215 Atomic::TConditional {
1216 param_name: _,
1217 subject,
1218 if_true,
1219 if_false,
1220 } => {
1221 collect_fqcns_in_union(subject, out);
1222 collect_fqcns_in_union(if_true, out);
1223 collect_fqcns_in_union(if_false, out);
1224 }
1225 Atomic::TTemplateParam { as_type, .. } => {
1226 collect_fqcns_in_union(as_type, out);
1227 }
1228 _ => {}
1229 }
1230}
1231
1232fn build_reverse_deps(db: &dyn crate::db::MirDatabase) -> HashMap<String, HashSet<String>> {
1233 let mut reverse: HashMap<String, HashSet<String>> = HashMap::default();
1234
1235 let mut add_edge = |symbol: &str, dependent_file: &str| {
1236 if let Some(defining_file) = db.symbol_defining_file(symbol) {
1237 let def = defining_file.as_ref().to_string();
1238 if def != dependent_file {
1239 reverse
1240 .entry(def)
1241 .or_default()
1242 .insert(dependent_file.to_string());
1243 }
1244 }
1245 };
1246
1247 for (file, imports) in db.file_import_snapshots() {
1248 let file = file.as_ref().to_string();
1249 for fqcn in imports.values() {
1250 add_edge(fqcn.as_str(), &file);
1251 }
1252 }
1253
1254 let extract_named_objects = |union: &mir_types::Type| {
1255 union
1256 .types
1257 .iter()
1258 .filter_map(|atomic| match atomic {
1259 mir_types::atomic::Atomic::TNamedObject { fqcn, .. } => Some(*fqcn),
1260 _ => None,
1261 })
1262 .collect::<Vec<_>>()
1263 };
1264
1265 for fqcn in crate::db::workspace_classes(db).iter() {
1266 let here = crate::db::Fqcn::from_str(db, fqcn.as_ref());
1267 let Some(class) = crate::db::find_class_like(db, here) else {
1268 continue;
1269 };
1270 if class.is_interface() || class.is_trait() || class.is_enum() {
1271 continue;
1272 }
1273 let Some(file) = db
1274 .symbol_defining_file(fqcn.as_ref())
1275 .map(|f| f.as_ref().to_string())
1276 .or_else(|| class.location().map(|l| l.file.as_ref().to_string()))
1277 else {
1278 continue;
1279 };
1280
1281 if let Some(parent) = class.parent() {
1282 add_edge(parent.as_ref(), &file);
1283 }
1284 for iface in class.interfaces().iter() {
1285 add_edge(iface.as_ref(), &file);
1286 }
1287 for tr in class.class_traits().iter() {
1288 add_edge(tr.as_ref(), &file);
1289 }
1290 if let Some(props) = class.own_properties() {
1291 for (_, p) in props.iter() {
1292 if let Some(ty) = &p.ty {
1293 for named in extract_named_objects(ty) {
1294 add_edge(named.as_ref(), &file);
1295 }
1296 }
1297 }
1298 }
1299 for (_, method) in class.own_methods().iter() {
1300 for param in method.params.iter() {
1301 if let Some(ty) = ¶m.ty {
1302 for named in extract_named_objects(ty.as_ref()) {
1303 add_edge(named.as_ref(), &file);
1304 }
1305 }
1306 }
1307 if let Some(rt) = method.return_type.as_deref() {
1308 for named in extract_named_objects(rt) {
1309 add_edge(named.as_ref(), &file);
1310 }
1311 }
1312 }
1313 }
1314
1315 for fqn in crate::db::workspace_functions(db).iter() {
1316 let here = crate::db::Fqcn::from_str(db, fqn.as_ref());
1317 let Some(f) = crate::db::find_function(db, here) else {
1318 continue;
1319 };
1320 let Some(file) = db
1321 .symbol_defining_file(fqn.as_ref())
1322 .map(|f| f.as_ref().to_string())
1323 .or_else(|| f.location.as_ref().map(|l| l.file.as_ref().to_string()))
1324 else {
1325 continue;
1326 };
1327
1328 for param in f.params.iter() {
1329 if let Some(ty) = ¶m.ty {
1330 for named in extract_named_objects(ty.as_ref()) {
1331 add_edge(named.as_ref(), &file);
1332 }
1333 }
1334 }
1335 if let Some(rt) = f.return_type.as_deref() {
1336 for named in extract_named_objects(rt) {
1337 add_edge(named.as_ref(), &file);
1338 }
1339 }
1340 }
1341
1342 for (ref_file, symbol_key) in db.all_reference_location_pairs() {
1343 let file_str = ref_file.as_ref().to_string();
1344 let lookup: &str = match symbol_key.split_once("::") {
1345 Some((class, _)) => class,
1346 None => &symbol_key,
1347 };
1348 add_edge(lookup, &file_str);
1349 }
1350
1351 reverse
1352}
1353
1354fn extract_reference_locations(
1355 db: &dyn crate::db::MirDatabase,
1356 file: &Arc<str>,
1357) -> Vec<(String, u32, u16, u16)> {
1358 db.extract_file_reference_locations(file.as_ref())
1359 .into_iter()
1360 .map(|(sym, line, col_start, col_end)| (sym.to_string(), line, col_start, col_end))
1361 .collect()
1362}
1363
1364pub struct AnalysisResult {
1365 pub issues: Vec<Issue>,
1366 #[doc(hidden)]
1367 pub type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
1368 pub symbols: Vec<crate::symbol::ResolvedSymbol>,
1370 symbols_by_file: HashMap<Arc<str>, std::ops::Range<usize>>,
1373}
1374
1375impl AnalysisResult {
1376 fn build(
1377 issues: Vec<Issue>,
1378 type_envs: rustc_hash::FxHashMap<crate::type_env::ScopeId, crate::type_env::TypeEnv>,
1379 mut symbols: Vec<crate::symbol::ResolvedSymbol>,
1380 ) -> Self {
1381 symbols.sort_unstable_by(|a, b| a.file.as_ref().cmp(b.file.as_ref()));
1382 let mut symbols_by_file: HashMap<Arc<str>, std::ops::Range<usize>> = HashMap::default();
1383 let mut i = 0;
1384 while i < symbols.len() {
1385 let file = Arc::clone(&symbols[i].file);
1386 let start = i;
1387 while i < symbols.len() && symbols[i].file == file {
1388 i += 1;
1389 }
1390 symbols_by_file.insert(file, start..i);
1391 }
1392 Self {
1393 issues,
1394 type_envs,
1395 symbols,
1396 symbols_by_file,
1397 }
1398 }
1399
1400 pub fn error_count(&self) -> usize {
1401 self.issues
1402 .iter()
1403 .filter(|i| i.severity == mir_issues::Severity::Error)
1404 .count()
1405 }
1406
1407 pub fn warning_count(&self) -> usize {
1408 self.issues
1409 .iter()
1410 .filter(|i| i.severity == mir_issues::Severity::Warning)
1411 .count()
1412 }
1413
1414 pub fn issues_by_file(&self) -> HashMap<Arc<str>, Vec<&Issue>> {
1415 let mut map: HashMap<Arc<str>, Vec<&Issue>> = HashMap::default();
1416 for issue in &self.issues {
1417 map.entry(issue.location.file.clone())
1418 .or_default()
1419 .push(issue);
1420 }
1421 map
1422 }
1423
1424 pub fn count_by_severity(&self) -> Vec<(mir_issues::Severity, usize)> {
1425 let mut counts: std::collections::BTreeMap<mir_issues::Severity, usize> =
1426 std::collections::BTreeMap::new();
1427 for issue in &self.issues {
1428 *counts.entry(issue.severity).or_insert(0) += 1;
1429 }
1430 counts.into_iter().collect()
1431 }
1432
1433 pub fn total_issue_count(&self) -> usize {
1434 self.issues.len()
1435 }
1436
1437 pub fn filter_issues<'a, F>(&'a self, predicate: F) -> impl Iterator<Item = &'a Issue>
1438 where
1439 F: Fn(&Issue) -> bool + 'a,
1440 {
1441 self.issues.iter().filter(move |i| predicate(i))
1442 }
1443
1444 pub fn symbol_at(
1445 &self,
1446 file: &str,
1447 byte_offset: u32,
1448 ) -> Option<&crate::symbol::ResolvedSymbol> {
1449 let range = self.symbols_by_file.get(file)?;
1450 self.symbols[range.clone()]
1451 .iter()
1452 .filter(|s| s.span.start <= byte_offset && byte_offset < s.span.end)
1453 .min_by_key(|s| s.span.end - s.span.start)
1454 }
1455}