1use super::*;
2
3impl AnalysisSession {
4 pub fn analyze_paths(&mut self, paths: &[PathBuf], opts: &BatchOptions) -> AnalysisResult {
6 let php_version = self.batch_php_version(opts);
7 let mut all_issues = Vec::new();
8 let _t0 = std::time::Instant::now();
9
10 self.load_batch_stubs(php_version);
12 self.ensure_vendor_eager_functions();
16 let _t_stubs = _t0.elapsed();
17
18 let parsed_files: Vec<ParsedProjectFile> = paths
20 .par_iter()
21 .filter_map(|path| match std::fs::read_to_string(path) {
22 Ok(src) => {
23 let file = Arc::from(path.to_string_lossy().as_ref());
24 Some(ParsedProjectFile::new(file, Arc::from(src), php_version))
25 }
26 Err(e) => {
27 eprintln!("Cannot read {}: {}", path.display(), e);
28 None
29 }
30 })
31 .collect();
32 let _t_read = _t0.elapsed();
33
34 let file_data: Vec<(Arc<str>, Arc<str>)> = parsed_files
35 .iter()
36 .map(|parsed| (parsed.file.clone(), parsed.source.clone()))
37 .collect();
38
39 let mut topology_changed = false;
52 let mut removed_files: Vec<String> = Vec::new();
53 if let Some(cache) = &self.cache {
54 let current: rustc_hash::FxHashSet<&str> =
55 file_data.iter().map(|(f, _)| f.as_ref()).collect();
56 removed_files = cache
57 .cached_files()
58 .into_iter()
59 .filter(|f| !current.contains(f.as_str()) && !std::path::Path::new(f).exists())
60 .collect();
61 for f in &removed_files {
62 cache.evict(f);
63 }
64 topology_changed = !removed_files.is_empty();
65 }
66
67 {
69 let db = &mut self.db.salsa;
70 for parsed in &parsed_files {
71 db.upsert_source_file(parsed.file.clone(), parsed.source.clone());
72 }
73 }
74 let _t_salsa_reg = _t0.elapsed();
75
76 type Pass1Entry = (FileDefinitions, [u8; 32], bool, String);
80 let file_defs: Vec<Pass1Entry> = parsed_files
81 .par_iter()
82 .map(|parsed| {
83 let content_hash = hash_source(parsed.source());
84 let has_hard_parse_errors = parsed
85 .errors()
86 .iter()
87 .any(crate::parser::is_hard_parse_error);
88 let mut all_issues: Vec<Issue> = parsed
89 .errors()
90 .iter()
91 .filter(|err| !crate::parser::is_spurious_reserved_class_error(err))
92 .map(|err| {
93 crate::parser::parse_error_to_issue(
94 err,
95 &parsed.file,
96 parsed.source(),
97 parsed.source_map(),
98 )
99 })
100 .collect();
101 let collector = crate::collector::DefinitionCollector::new_for_slice(
102 parsed.file.clone(),
103 parsed.source(),
104 parsed.source_map(),
105 );
106 let (mut slice, collector_issues) = collector.collect_slice(parsed.owned());
107 all_issues.extend(collector_issues);
108 mir_codebase::definitions::deduplicate_params_in_slice(&mut slice);
109 let defs = FileDefinitions {
110 slice: Arc::new(slice),
111 issues: Arc::new(all_issues),
112 };
113 let surface_hash = surface_fingerprint(parsed.source(), parsed.owned());
114 (defs, content_hash, has_hard_parse_errors, surface_hash)
115 })
116 .collect();
117 let _t_collect_defs = _t0.elapsed();
118
119 let surface_hashes: HashMap<Arc<str>, String> = parsed_files
122 .iter()
123 .zip(file_defs.iter())
124 .map(|(parsed, (_defs, _h, _e, surface))| (parsed.file.clone(), surface.clone()))
125 .collect();
126
127 let content_hexes: HashMap<Arc<str>, String> = parsed_files
131 .iter()
132 .zip(file_defs.iter())
133 .map(|(parsed, (_defs, hash, _e, _surface))| {
134 (
135 parsed.file.clone(),
136 blake3::Hash::from(*hash).to_hex().to_string(),
137 )
138 })
139 .collect();
140 let transient_replay = self
141 .cache
142 .is_none()
143 .then(|| {
144 self.transient_batch_replay(
145 php_version.cache_byte(),
146 opts.skip_symbols,
147 &content_hexes,
148 )
149 })
150 .flatten();
151
152 if let Some(cache) = &self.cache {
159 let content_changed: Vec<String> = file_data
160 .iter()
161 .filter_map(|(f, _src)| {
162 let valid = content_hexes.get(f).is_some_and(|h| cache.is_valid(f, h));
163 if valid {
164 None
165 } else {
166 Some(f.to_string())
167 }
168 })
169 .collect();
170 if !content_changed.is_empty() {
171 topology_changed = true;
172 }
173 let mut seeds: Vec<String> = content_changed
174 .into_iter()
175 .filter(|f| {
176 let new_surface = surface_hashes
177 .get(f.as_str())
178 .map(String::as_str)
179 .unwrap_or("");
180 !matches!(
182 cache.surface_hash(f),
183 Some(stored) if !stored.is_empty() && stored == new_surface
184 )
185 })
186 .collect();
187 seeds.extend(removed_files.iter().cloned());
188 if !seeds.is_empty() {
189 cache.evict_with_dependents(&seeds);
190 }
191 }
192
193 {
196 let db = &self.db.salsa;
197 let php_v = php_version.cache_byte();
198 for (defs, hash, has_hard_parse_errors, _surface) in &file_defs {
199 if !*has_hard_parse_errors {
200 db.prime_parse_cache(
201 *hash,
202 php_v,
203 Arc::clone(&defs.slice),
204 Arc::clone(&defs.issues),
205 );
206 }
207 }
208 }
209
210 let mut files_with_parse_errors: HashSet<Arc<str>> = HashSet::default();
211 {
212 let db = &self.db.salsa;
217 for (parsed, (defs, _hash, _hard_err, _surface)) in parsed_files.iter().zip(file_defs) {
218 for issue in defs.issues.iter() {
219 if matches!(issue.kind, mir_issues::IssueKind::ParseError { .. })
220 && issue.severity == mir_issues::Severity::Error
221 {
222 files_with_parse_errors.insert(issue.location.file.clone());
223 }
224 }
225 let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
226 let file_no = db.intern_path(&parsed.file);
227 db.set_file_class_edges(file_no, entries);
228 all_issues.extend(Arc::unwrap_or_clone(defs.issues));
229 }
230 }
231 let _t_ingest = _t0.elapsed();
232
233 {
235 let db_prewarm = self.db.snapshot_db();
236 let project_source_files: Vec<SourceFile> = parsed_files
237 .iter()
238 .filter_map(|p| self.db.lookup_source_file(&p.file))
239 .collect();
240 project_source_files
241 .into_par_iter()
242 .for_each_with(db_prewarm, |db, sf| {
243 let _ = collect_file_definitions(db as &dyn MirDatabase, sf);
244 });
245 }
246 let _t_prewarm_ms = (_t0.elapsed() - _t_ingest).as_secs_f64() * 1000.0;
247
248 self.refresh_workspace_index();
252
253 let _t_before_lazy = _t0.elapsed();
255 if let Some(psr4) = self.psr4.clone() {
256 self.lazy_load_missing_classes(psr4, php_version, &mut all_issues);
257 }
258 let _t_lazyload_ms = (_t0.elapsed() - _t_before_lazy).as_secs_f64() * 1000.0;
259
260 let analyzed_file_set: HashSet<Arc<str>> =
262 file_data.iter().map(|(f, _)| f.clone()).collect();
263
264 if let Some(plugins) = mir_plugin::snapshot() {
267 if plugins.hooks().after_codebase_populated {
268 let files: Vec<Arc<str>> = analyzed_file_set.iter().cloned().collect();
269 plugins.after_codebase_populated(&mut mir_plugin::AfterCodebasePopulatedEvent {
270 files: &files,
271 });
272 }
273 }
274 let _t_class_analyzer = std::time::Instant::now();
275 {
276 let class_db = self.db.snapshot_db();
277 let class_issues = crate::class::ClassAnalyzer::with_files(
278 &class_db,
279 analyzed_file_set.clone(),
280 &file_data,
281 )
282 .analyze_all();
283 all_issues.extend(class_issues);
284 }
285 let _t_class_analyzer_ms = _t_class_analyzer.elapsed().as_secs_f64() * 1000.0;
286
287 let _t_class_checks = _t0.elapsed();
288
289 let mut db_main = self.db.snapshot_db();
290 db_main.freeze_workspace_index();
296
297 type BodyResult = (
299 Arc<str>,
300 Vec<Issue>,
301 Vec<crate::symbol::ResolvedSymbol>,
302 Vec<RefLoc>,
303 );
304 let body_results: Vec<BodyResult> = parsed_files
305 .par_iter()
306 .filter(|parsed| !files_with_parse_errors.contains(&parsed.file))
307 .map_with((db_main, self.cache.clone()), |(db, cache), parsed| {
308 let mut driver = BodyAnalyzer::new(&*db as &dyn MirDatabase, php_version);
309 driver.collect_symbols = !opts.skip_symbols;
312 if let Some(replay) = transient_replay.as_ref() {
313 if let Some(hit) = replay.files.get(&parsed.file) {
314 let locs: Vec<RefLoc> = hit
315 .ref_locs
316 .iter()
317 .map(|(symbol, line, col_start, col_end)| RefLoc {
318 symbol_key: Arc::clone(symbol),
319 file: parsed.file.clone(),
320 line: *line,
321 col_start: *col_start,
322 col_end: *col_end,
323 })
324 .collect();
325 return (
326 parsed.file.clone(),
327 hit.issues.to_vec(),
328 hit.symbols.to_vec(),
329 locs,
330 );
331 }
332 }
333 let (issues, symbols) = if let Some(cache) = cache.as_ref() {
334 let h = content_hexes
335 .get(parsed.file.as_ref())
336 .cloned()
337 .unwrap_or_else(|| hash_content(parsed.source()));
338 if let Some((cached_issues, ref_locs)) = cache.get(&parsed.file, &h) {
339 let locs: Vec<RefLoc> = ref_locs
344 .iter()
345 .map(|(symbol, line, col_start, col_end)| RefLoc {
346 symbol_key: Arc::clone(symbol),
347 file: parsed.file.clone(),
348 line: *line,
349 col_start: *col_start,
350 col_end: *col_end,
351 })
352 .collect();
353 return (
354 parsed.file.clone(),
355 cached_issues.to_vec(),
356 Vec::new(),
357 locs,
358 );
359 }
360 let (issues, symbols) = driver.analyze_bodies(
361 parsed.owned(),
362 parsed.file.clone(),
363 parsed.source(),
364 parsed.source_map(),
365 );
366 let pending = db.take_pending_ref_locs();
367 let cache_locs: Arc<[crate::cache::CachedRefLoc]> = pending
368 .iter()
369 .map(|r| (Arc::clone(&r.symbol_key), r.line, r.col_start, r.col_end))
370 .collect();
371 let surface = surface_hashes
372 .get(parsed.file.as_ref())
373 .cloned()
374 .unwrap_or_default();
375 cache.put(
376 &parsed.file,
377 h,
378 surface,
379 issues.as_slice().into(),
380 cache_locs,
381 );
382 if let Some(cb) = &opts.on_file_done {
383 cb();
384 }
385 let symbols = if opts.skip_symbols {
386 Vec::new()
387 } else {
388 symbols
389 };
390 return (parsed.file.clone(), issues, symbols, pending);
391 } else {
392 driver.analyze_bodies(
393 parsed.owned(),
394 parsed.file.clone(),
395 parsed.source(),
396 parsed.source_map(),
397 )
398 };
399 let pending = db.take_pending_ref_locs();
400 if let Some(cb) = &opts.on_file_done {
401 cb();
402 }
403 let symbols = if opts.skip_symbols {
406 Vec::new()
407 } else {
408 symbols
409 };
410 (parsed.file.clone(), issues, symbols, pending)
411 })
412 .collect();
413
414 let _t_body_analysis = _t0.elapsed();
415
416 let mut body_results = body_results;
420 let mut class_like_suppressions: Vec<
421 crate::body_analysis::class_like_plugins::ClassSuppression,
422 > = Vec::new();
423 if mir_plugin::snapshot().is_some_and(|p| p.hooks().after_class_like_analysis) {
424 let outputs: Vec<(
425 Arc<str>,
426 crate::body_analysis::class_like_plugins::ClassLikePluginOutput,
427 )> = parsed_files
428 .par_iter()
429 .filter(|p| !files_with_parse_errors.contains(&p.file))
430 .map_with(self.db.snapshot_db(), |db, parsed| {
431 let driver = BodyAnalyzer::new(&*db as &dyn MirDatabase, php_version);
432 let output = driver.run_after_class_like_plugins(
433 parsed.owned(),
434 &parsed.file,
435 parsed.source(),
436 parsed.source_map(),
437 );
438 (parsed.file.clone(), output)
439 })
440 .collect();
441 let index_of: HashMap<Arc<str>, usize> = body_results
442 .iter()
443 .enumerate()
444 .map(|(i, r)| (r.0.clone(), i))
445 .collect();
446 for (file, output) in outputs {
447 class_like_suppressions.extend(output.suppressions);
448 if let Some(&i) = index_of.get(&file) {
449 body_results[i].1.extend(output.issues);
450 body_results[i].3.extend(output.ref_locs);
451 } else {
452 all_issues.extend(output.issues);
453 }
454 }
455 }
456
457 if self.cache.is_none() {
458 let replay_files: HashMap<Arc<str>, crate::session::BatchReplayFile> = body_results
459 .iter()
460 .map(|(file, issues, symbols, ref_locs)| {
461 let cached_ref_locs: Arc<[crate::cache::CachedRefLoc]> = ref_locs
462 .iter()
463 .map(|r| (Arc::clone(&r.symbol_key), r.line, r.col_start, r.col_end))
464 .collect();
465 (
466 file.clone(),
467 crate::session::BatchReplayFile {
468 issues: issues.as_slice().into(),
469 ref_locs: cached_ref_locs,
470 symbols: symbols.as_slice().into(),
471 },
472 )
473 })
474 .collect();
475 self.store_transient_batch_replay(
476 php_version.cache_byte(),
477 opts.skip_symbols,
478 &content_hexes,
479 replay_files,
480 );
481 }
482
483 let mut all_symbols = Vec::new();
487 {
488 let db = &self.db.salsa;
489 for (file, issues, symbols, ref_locs) in body_results {
490 all_issues.extend(issues);
491 all_symbols.extend(symbols);
492 let file_no = db.intern_path(&file);
493 db.set_file_reference_locations(file_no, ref_locs);
494 }
495 }
496
497 if let Some(psr4) = self.psr4.clone() {
499 self.lazy_load_from_body_issues(
500 psr4,
501 php_version,
502 &file_data,
503 &files_with_parse_errors,
504 &mut all_issues,
505 &mut all_symbols,
506 opts.skip_symbols,
507 );
508 }
509
510 if topology_changed {
518 if let Some(cache) = &self.cache {
519 let db_snapshot = self.db.snapshot_db();
520 let rev = build_reverse_deps(&db_snapshot);
521 cache.set_reverse_deps(rev);
522 }
523 }
524
525 if let Some(cache) = &self.cache {
527 cache.flush();
528 }
529
530 if opts.should_run_dead_code() {
532 let salsa = self.snapshot_db();
533 let _t_dead_code = std::time::Instant::now();
534 let dead_code_issues =
535 crate::dead_code::DeadCodeAnalyzer::with_files(&salsa, analyzed_file_set.clone())
536 .analyze();
537 all_issues.extend(dead_code_issues);
538 if std::env::var("MIR_TIMING").is_ok() {
539 eprintln!(
540 "[timing] dead_code_analyzer={:.0}ms",
541 _t_dead_code.elapsed().as_secs_f64() * 1000.0
542 );
543 }
544 }
545
546 let _t_total = _t0.elapsed();
547 if std::env::var("MIR_TIMING").is_ok() {
548 eprintln!(
549 "[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",
550 _t_stubs.as_secs_f64() * 1000.0,
551 (_t_read - _t_stubs).as_secs_f64() * 1000.0,
552 (_t_salsa_reg - _t_read).as_secs_f64() * 1000.0,
553 (_t_collect_defs - _t_salsa_reg).as_secs_f64() * 1000.0,
554 (_t_ingest - _t_collect_defs).as_secs_f64() * 1000.0,
555 (_t_class_checks - _t_ingest).as_secs_f64() * 1000.0,
556 _t_prewarm_ms,
557 _t_lazyload_ms,
558 _t_class_analyzer_ms,
559 (_t_body_analysis - _t_class_checks).as_secs_f64() * 1000.0,
560 _t_total.as_secs_f64() * 1000.0,
561 );
562 }
563
564 if let Some(plugins) = mir_plugin::snapshot() {
567 if plugins.hooks().before_add_issue {
568 all_issues.retain(|i| plugins.before_add_issue(i));
569 }
570 }
571
572 opts.apply(&mut all_issues);
573 for issue in all_issues.iter_mut() {
574 if !issue.suppressed && class_like_suppressions.iter().any(|s| s.applies_to(issue)) {
575 issue.suppressed = true;
576 }
577 }
578 let analyzed_files_vec: Vec<Arc<str>> = analyzed_file_set.iter().cloned().collect();
579 self.apply_suppressions_and_emit_unused(&mut all_issues, &analyzed_files_vec);
580 if let Some(dump) = crate::metrics::dump() {
581 eprintln!("{dump}");
582 }
583
584 self.db.salsa.rebuild_workspace_symbol_index();
586
587 AnalysisResult::build(all_issues, rustc_hash::FxHashMap::default(), all_symbols)
588 }
589 pub fn re_analyze_file(
595 &mut self,
596 file_path: &str,
597 new_content: &str,
598 opts: &BatchOptions,
599 ) -> AnalysisResult {
600 self.index.clear_transient_batch_replay();
601 let php_version = self.batch_php_version(opts);
602
603 self.settle_workspace_index();
608
609 if let Some(cache) = &self.cache {
611 let h = hash_content(new_content);
612 if let Some((cached_issues, ref_locs)) = cache.get(file_path, &h) {
613 let mut issues = cached_issues.to_vec();
614 let file: Arc<str> = Arc::from(file_path);
615 let locs: Vec<RefLoc> = ref_locs
619 .iter()
620 .map(|(symbol, line, col_start, col_end)| RefLoc {
621 symbol_key: Arc::clone(symbol),
622 file: file.clone(),
623 line: *line,
624 col_start: *col_start,
625 col_end: *col_end,
626 })
627 .collect();
628 self.index.retire_references(&self.db.salsa, file_path);
631 let db = &self.db.salsa;
632 let file_no = db.intern_path(&file);
633 db.set_file_reference_locations(file_no, locs);
634 self.index.clear_dependency_graph_cache();
635 opts.apply(&mut issues);
636 self.apply_suppressions_and_emit_unused(&mut issues, std::slice::from_ref(&file));
637 return AnalysisResult::build(issues, HashMap::default(), Vec::new());
638 }
639 }
640
641 let file: Arc<str> = Arc::from(file_path);
642
643 self.index.retire_references(&self.db.salsa, file_path);
644
645 let collected =
646 self.db
647 .collect_and_ingest_file_with_parsed(file.clone(), new_content, php_version);
648 let file_defs = collected.file_defs;
649
650 let mut all_issues: Vec<Issue> = Arc::unwrap_or_clone(file_defs.issues.clone());
651
652 {
653 let db = &mut self.db.salsa;
654 if db.workspace_symbol_index_singleton().is_some() {
655 if let Some(sf) = db.lookup_source_file(file.as_ref()) {
656 if db.file_declarations_changed(sf) {
657 db.rebuild_workspace_symbol_index();
658 }
659 }
660 }
661 }
662
663 let (symbols, surface_hash) = {
664 let db = &mut self.db.salsa;
665 let parsed = collected.parsed.unwrap_or_else(|| {
666 php_rs_parser::parse_versioned(new_content, php_version.parser_version())
667 });
668 let surface_hash = surface_fingerprint(new_content, &parsed.program);
669
670 let has_hard_errors = parsed.errors.iter().any(crate::parser::is_hard_parse_error);
671 let symbols = if !has_hard_errors {
672 let db_ref: &dyn MirDatabase = &*db;
673 let mut driver = BodyAnalyzer::new(db_ref, php_version);
674 driver.collect_symbols = !opts.skip_symbols;
675 let (body_issues, symbols) = driver.analyze_bodies(
676 &parsed.program,
677 file.clone(),
678 new_content,
679 &parsed.source_map,
680 );
681 all_issues.extend(body_issues);
682 let pending = db.take_pending_ref_locs();
683 let file_no = db.intern_path(&file);
684 db.set_file_reference_locations(file_no, pending);
685 if opts.skip_symbols {
686 Vec::new()
687 } else {
688 symbols
689 }
690 } else {
691 Vec::new()
692 };
693 (symbols, surface_hash)
694 };
695
696 mark_suppressed(
704 &mut all_issues,
705 &crate::suppression::SuppressionMap::from_source(new_content),
706 );
707
708 if let Some(cache) = &self.cache {
709 let h = hash_content(new_content);
710 let surface_changed = match cache.surface_hash(file_path) {
714 Some(stored) if !stored.is_empty() => stored != surface_hash,
715 _ => true,
716 };
717 if surface_changed {
718 cache.evict_with_dependents(&[file_path.to_string()]);
719 }
720 let db = self.snapshot_db();
721 let ref_locs = extract_reference_locations(&db, &file);
722 cache.put(
723 file_path,
724 h,
725 surface_hash,
726 all_issues.as_slice().into(),
727 ref_locs,
728 );
729 }
730
731 opts.apply(&mut all_issues);
732 self.apply_suppressions_and_emit_unused(&mut all_issues, std::slice::from_ref(&file));
740 AnalysisResult::build(all_issues, HashMap::default(), symbols)
741 }
742
743 pub fn collect_definitions(&mut self, paths: &[PathBuf]) {
752 self.index.clear_transient_batch_replay();
753 let _timing = std::env::var("MIR_TIMING").is_ok();
754 let _t0 = std::time::Instant::now();
755
756 let php_v = self.php_version.cache_byte();
757
758 struct FileEntry {
759 file: Arc<str>,
760 src: Arc<str>,
761 hash: [u8; 32],
762 cached: Option<mir_codebase::definitions::StubSlice>,
763 }
764 let stub_cache = self.db.stub_cache.clone();
765 let entries: Vec<FileEntry> = paths
766 .par_iter()
767 .filter_map(|path| {
768 let src = std::fs::read_to_string(path).ok()?;
769 let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
770 let src: Arc<str> = Arc::from(src);
771 let hash = hash_source(&src);
772 let cached = stub_cache.as_ref().and_then(|c| {
773 let (mut slice, _issues) = c.get(&file, &hash, php_v)?;
774 prepare_for_ingest(&mut slice);
775 Some(slice)
776 });
777 Some(FileEntry {
778 file,
779 src,
780 hash,
781 cached,
782 })
783 })
784 .collect();
785 let _t_read = _t0.elapsed();
786
787 let source_files: Vec<SourceFile> = {
788 let db = &mut self.db.salsa;
789 entries
790 .iter()
791 .map(|e| {
792 db.upsert_source_file_with_durability(
793 e.file.clone(),
794 e.src.clone(),
795 salsa::Durability::HIGH,
796 )
797 })
798 .collect()
799 };
800 let _t_reg = _t0.elapsed();
801
802 let db_pass1 = self.db.snapshot_db();
803 let stub_cache = self.db.stub_cache.clone();
804 let prepared: Vec<(Arc<str>, mir_codebase::definitions::StubSlice)> = entries
805 .into_par_iter()
806 .zip(source_files.into_par_iter())
807 .map_with(db_pass1, |db, (mut entry, salsa_file)| {
808 if let Some(slice) = entry.cached.take() {
809 let slice_arc = Arc::new(slice);
810 db.parse_cache().insert(
811 entry.hash,
812 php_v,
813 Arc::clone(&slice_arc),
814 Arc::new(Vec::new()),
815 );
816 return (entry.file.clone(), (*slice_arc).clone());
817 }
818 let defs = collect_file_definitions(&*db, salsa_file);
819 if let Some(cache) = stub_cache.as_ref() {
820 cache.put(&entry.file, &entry.hash, php_v, &defs.slice, &defs.issues);
821 }
822 (entry.file.clone(), (*defs.slice).clone())
823 })
824 .collect();
825 let _t_collect = _t0.elapsed();
826 {
832 let db = &self.db.salsa;
833 for (file, slice) in &prepared {
834 let entries = crate::db::subtype_index::entries_from_slice(slice);
835 let file_no = db.intern_path(file);
836 db.set_file_class_edges(file_no, entries);
837 }
838 }
839 drop(prepared);
840 let _t_ingest = _t0.elapsed();
841
842 if _timing {
843 let (hits, misses) = self.stub_cache_stats();
844 eprintln!(
845 "[vendor] read={:.0}ms reg={:.0}ms collect={:.0}ms ingest={:.0}ms total={:.0}ms (cache hits={hits} misses={misses})",
846 _t_read.as_secs_f64() * 1000.0,
847 (_t_reg - _t_read).as_secs_f64() * 1000.0,
848 (_t_collect - _t_reg).as_secs_f64() * 1000.0,
849 (_t_ingest - _t_collect).as_secs_f64() * 1000.0,
850 _t_ingest.as_secs_f64() * 1000.0,
851 );
852 }
853
854 self.db.salsa.rebuild_workspace_symbol_index();
855
856 crate::collector::print_collector_stats();
857 }
858}
859
860#[cfg(test)]
861mod tests {
862 use rustc_hash::FxHashMap as HashMap;
863 use std::fs;
864 use std::sync::Arc;
865
866 use crate::db::Work;
867 use crate::{AnalysisSession, BatchOptions, PhpVersion};
868
869 fn write_php(dir: &tempfile::TempDir, name: &str, contents: &str) -> std::path::PathBuf {
870 let path = dir.path().join(name);
871 fs::write(&path, contents).expect("write fixture");
872 path
873 }
874
875 #[test]
876 fn identical_batch_rerun_replays_without_symbols() {
877 let dir = tempfile::tempdir().expect("tempdir");
878 let file = write_php(
879 &dir,
880 "Example.php",
881 "<?php\nclass Example {\n public function answer(): int { return 42; }\n}\n(new Example())->answer();\n",
882 );
883 let mut session = AnalysisSession::new(PhpVersion::LATEST);
884 let opts = BatchOptions::new().without_symbols();
885 let hash = crate::cache::hash_content(
886 "<?php\nclass Example {\n public function answer(): int { return 42; }\n}\n(new Example())->answer();\n",
887 );
888 let hashes = HashMap::from_iter([(Arc::from(file.to_string_lossy().as_ref()), hash)]);
889
890 let first = session.analyze_paths(std::slice::from_ref(&file), &opts);
891 assert!(first.issues.is_empty());
892 assert!(
893 session
894 .transient_batch_replay(PhpVersion::LATEST.cache_byte(), true, &hashes)
895 .is_some(),
896 "expected first run to populate transient replay"
897 );
898
899 let walks = session.work_count(Work::WholeFileWalk);
900 let symbols = session.work_count(Work::SymbolAllocated);
901 let second = session.analyze_paths(std::slice::from_ref(&file), &opts);
902 assert!(second.issues.is_empty());
903 assert!(second.symbols.is_empty());
904
905 assert_eq!(
906 session.work_count(Work::WholeFileWalk) - walks,
907 0,
908 "expected replay to skip body walks"
909 );
910 assert_eq!(
911 session.work_count(Work::SymbolAllocated) - symbols,
912 0,
913 "expected diagnostics-only replay to avoid symbol allocation"
914 );
915 }
916
917 #[test]
918 fn identical_batch_rerun_replays_with_symbols() {
919 let dir = tempfile::tempdir().expect("tempdir");
920 let file = write_php(
921 &dir,
922 "Example.php",
923 "<?php\nclass Example {\n public function answer(): int { return 42; }\n}\n(new Example())->answer();\n",
924 );
925 let mut session = AnalysisSession::new(PhpVersion::LATEST);
926 let opts = BatchOptions::new();
927 let hash = crate::cache::hash_content(
928 "<?php\nclass Example {\n public function answer(): int { return 42; }\n}\n(new Example())->answer();\n",
929 );
930 let hashes = HashMap::from_iter([(Arc::from(file.to_string_lossy().as_ref()), hash)]);
931
932 let first = session.analyze_paths(std::slice::from_ref(&file), &opts);
933 assert!(
934 !first.symbols.is_empty(),
935 "expected first run to collect symbols"
936 );
937 assert!(
938 session
939 .transient_batch_replay(PhpVersion::LATEST.cache_byte(), false, &hashes)
940 .is_some(),
941 "expected first run to populate transient replay"
942 );
943
944 let walks = session.work_count(Work::WholeFileWalk);
945 let second = session.analyze_paths(std::slice::from_ref(&file), &opts);
946 assert_eq!(second.symbols.len(), first.symbols.len());
947
948 assert_eq!(
949 session.work_count(Work::WholeFileWalk) - walks,
950 0,
951 "expected replay to skip body walks"
952 );
953 }
954
955 #[test]
956 fn session_mutation_clears_batch_replay() {
957 let dir = tempfile::tempdir().expect("tempdir");
958 let file = write_php(
959 &dir,
960 "Example.php",
961 "<?php\nclass Example {\n public function answer(): int { return 42; }\n}\n(new Example())->answer();\n",
962 );
963 let file_arc: Arc<str> = Arc::from(file.to_string_lossy().as_ref());
964 let mut session = AnalysisSession::new(PhpVersion::LATEST);
965 let opts = BatchOptions::new().without_symbols();
966
967 let _ = session.analyze_paths(std::slice::from_ref(&file), &opts);
968 session.ingest_file(
969 Arc::clone(&file_arc),
970 Arc::from(
971 "<?php\nclass Example {\n public function answer(): string { return 'forty two'; }\n}\n(new Example())->answer();\n",
972 ),
973 );
974
975 let walks = session.work_count(Work::WholeFileWalk);
976 let result = session.analyze_paths(std::slice::from_ref(&file), &opts);
977 assert!(result.issues.is_empty());
978
979 assert_eq!(
980 session.work_count(Work::WholeFileWalk) - walks,
981 1,
982 "expected session mutation to invalidate replay"
983 );
984 }
985}