1use super::*;
2
3impl AnalysisSession {
4 pub fn analyze_paths(&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)))
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 mut guard = self.db.salsa.write();
70 for parsed in &parsed_files {
71 guard.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 guard = self.db.salsa.read();
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 guard.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 guard = self.db.salsa.read();
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 = guard.locked_ref_index().intern_path(&parsed.file);
227 guard.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 = {
236 let guard = self.db.salsa.read();
237 (**guard).clone()
238 };
239 let project_source_files: Vec<SourceFile> = {
240 let guard = self.db.salsa.read();
241 parsed_files
242 .iter()
243 .filter_map(|p| (**guard).lookup_source_file(&p.file))
244 .collect()
245 };
246 project_source_files
247 .into_par_iter()
248 .for_each_with(db_prewarm, |db, sf| {
249 let _ = collect_file_definitions(db as &dyn MirDatabase, sf);
250 });
251 }
252 let _t_prewarm_ms = (_t0.elapsed() - _t_ingest).as_secs_f64() * 1000.0;
253
254 self.refresh_workspace_index();
260
261 let _t_before_lazy = _t0.elapsed();
263 if let Some(psr4) = self.psr4.clone() {
264 self.lazy_load_missing_classes(psr4, php_version, &mut all_issues);
265 }
266 let _t_lazyload_ms = (_t0.elapsed() - _t_before_lazy).as_secs_f64() * 1000.0;
267
268 let analyzed_file_set: HashSet<Arc<str>> =
270 file_data.iter().map(|(f, _)| f.clone()).collect();
271
272 if let Some(plugins) = mir_plugin::snapshot() {
275 if plugins.hooks().after_codebase_populated {
276 let files: Vec<Arc<str>> = analyzed_file_set.iter().cloned().collect();
277 plugins.after_codebase_populated(&mut mir_plugin::AfterCodebasePopulatedEvent {
278 files: &files,
279 });
280 }
281 }
282 let _t_class_analyzer = std::time::Instant::now();
283 {
284 let class_db = {
285 let guard = self.db.salsa.read();
286 (**guard).clone()
287 };
288 let class_issues = crate::class::ClassAnalyzer::with_files(
289 &class_db,
290 analyzed_file_set.clone(),
291 &file_data,
292 )
293 .analyze_all();
294 all_issues.extend(class_issues);
295 }
296 let _t_class_analyzer_ms = _t_class_analyzer.elapsed().as_secs_f64() * 1000.0;
297
298 let _t_class_checks = _t0.elapsed();
299
300 let mut db_main = {
301 let guard = self.db.salsa.read();
302 (**guard).clone()
303 };
304 db_main.freeze_workspace_index();
310
311 type BodyResult = (
313 Arc<str>,
314 Vec<Issue>,
315 Vec<crate::symbol::ResolvedSymbol>,
316 Vec<RefLoc>,
317 );
318 let body_results: Vec<BodyResult> = parsed_files
319 .par_iter()
320 .filter(|parsed| !files_with_parse_errors.contains(&parsed.file))
321 .map_with(db_main, |db, parsed| {
322 let mut driver = BodyAnalyzer::new(&*db as &dyn MirDatabase, php_version);
323 driver.collect_symbols = !opts.skip_symbols;
326 if let Some(replay) = transient_replay.as_ref() {
327 if let Some(hit) = replay.files.get(&parsed.file) {
328 let locs: Vec<RefLoc> = hit
329 .ref_locs
330 .iter()
331 .map(|(symbol, line, col_start, col_end)| RefLoc {
332 symbol_key: Arc::clone(symbol),
333 file: parsed.file.clone(),
334 line: *line,
335 col_start: *col_start,
336 col_end: *col_end,
337 })
338 .collect();
339 return (
340 parsed.file.clone(),
341 hit.issues.to_vec(),
342 hit.symbols.to_vec(),
343 locs,
344 );
345 }
346 }
347 let (issues, symbols) = if let Some(cache) = &self.cache {
348 let h = content_hexes
349 .get(parsed.file.as_ref())
350 .cloned()
351 .unwrap_or_else(|| hash_content(parsed.source()));
352 if let Some((cached_issues, ref_locs)) = cache.get(&parsed.file, &h) {
353 let locs: Vec<RefLoc> = ref_locs
358 .iter()
359 .map(|(symbol, line, col_start, col_end)| RefLoc {
360 symbol_key: Arc::clone(symbol),
361 file: parsed.file.clone(),
362 line: *line,
363 col_start: *col_start,
364 col_end: *col_end,
365 })
366 .collect();
367 return (
368 parsed.file.clone(),
369 cached_issues.to_vec(),
370 Vec::new(),
371 locs,
372 );
373 }
374 let (issues, symbols) = driver.analyze_bodies(
375 parsed.owned(),
376 parsed.file.clone(),
377 parsed.source(),
378 parsed.source_map(),
379 );
380 let pending = db.take_pending_ref_locs();
381 let cache_locs: Arc<[crate::cache::CachedRefLoc]> = pending
382 .iter()
383 .map(|r| (Arc::clone(&r.symbol_key), r.line, r.col_start, r.col_end))
384 .collect();
385 let surface = surface_hashes
386 .get(parsed.file.as_ref())
387 .cloned()
388 .unwrap_or_default();
389 cache.put(
390 &parsed.file,
391 h,
392 surface,
393 issues.as_slice().into(),
394 cache_locs,
395 );
396 if let Some(cb) = &opts.on_file_done {
397 cb();
398 }
399 let symbols = if opts.skip_symbols {
400 Vec::new()
401 } else {
402 symbols
403 };
404 return (parsed.file.clone(), issues, symbols, pending);
405 } else {
406 driver.analyze_bodies(
407 parsed.owned(),
408 parsed.file.clone(),
409 parsed.source(),
410 parsed.source_map(),
411 )
412 };
413 let pending = db.take_pending_ref_locs();
414 if let Some(cb) = &opts.on_file_done {
415 cb();
416 }
417 let symbols = if opts.skip_symbols {
420 Vec::new()
421 } else {
422 symbols
423 };
424 (parsed.file.clone(), issues, symbols, pending)
425 })
426 .collect();
427
428 let _t_body_analysis = _t0.elapsed();
429
430 if self.cache.is_none() {
431 let replay_files: HashMap<Arc<str>, crate::session::BatchReplayFile> = body_results
432 .iter()
433 .map(|(file, issues, symbols, ref_locs)| {
434 let cached_ref_locs: Arc<[crate::cache::CachedRefLoc]> = ref_locs
435 .iter()
436 .map(|r| (Arc::clone(&r.symbol_key), r.line, r.col_start, r.col_end))
437 .collect();
438 (
439 file.clone(),
440 crate::session::BatchReplayFile {
441 issues: issues.as_slice().into(),
442 ref_locs: cached_ref_locs,
443 symbols: symbols.as_slice().into(),
444 },
445 )
446 })
447 .collect();
448 self.store_transient_batch_replay(
449 php_version.cache_byte(),
450 opts.skip_symbols,
451 &content_hexes,
452 replay_files,
453 );
454 }
455
456 let mut all_symbols = Vec::new();
460 {
461 let guard = self.db.salsa.read();
462 for (file, issues, symbols, ref_locs) in body_results {
463 all_issues.extend(issues);
464 all_symbols.extend(symbols);
465 let file_no = guard.locked_ref_index().intern_path(&file);
466 guard.set_file_reference_locations(file_no, ref_locs);
467 }
468 }
469
470 if let Some(psr4) = self.psr4.clone() {
472 self.lazy_load_from_body_issues(
473 psr4,
474 php_version,
475 &file_data,
476 &files_with_parse_errors,
477 &mut all_issues,
478 &mut all_symbols,
479 opts.skip_symbols,
480 );
481 }
482
483 if topology_changed {
491 if let Some(cache) = &self.cache {
492 let db_snapshot = {
493 let guard = self.db.salsa.read();
494 (**guard).clone()
495 };
496 let rev = build_reverse_deps(&db_snapshot);
497 cache.set_reverse_deps(rev);
498 }
499 }
500
501 if let Some(cache) = &self.cache {
503 cache.flush();
504 }
505
506 if opts.should_run_dead_code() {
508 let salsa = self.snapshot_db();
509 let _t_dead_code = std::time::Instant::now();
510 let dead_code_issues =
511 crate::dead_code::DeadCodeAnalyzer::with_files(&salsa, analyzed_file_set.clone())
512 .analyze();
513 all_issues.extend(dead_code_issues);
514 if std::env::var("MIR_TIMING").is_ok() {
515 eprintln!(
516 "[timing] dead_code_analyzer={:.0}ms",
517 _t_dead_code.elapsed().as_secs_f64() * 1000.0
518 );
519 }
520 }
521
522 let _t_total = _t0.elapsed();
523 if std::env::var("MIR_TIMING").is_ok() {
524 eprintln!(
525 "[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",
526 _t_stubs.as_secs_f64() * 1000.0,
527 (_t_read - _t_stubs).as_secs_f64() * 1000.0,
528 (_t_salsa_reg - _t_read).as_secs_f64() * 1000.0,
529 (_t_collect_defs - _t_salsa_reg).as_secs_f64() * 1000.0,
530 (_t_ingest - _t_collect_defs).as_secs_f64() * 1000.0,
531 (_t_class_checks - _t_ingest).as_secs_f64() * 1000.0,
532 _t_prewarm_ms,
533 _t_lazyload_ms,
534 _t_class_analyzer_ms,
535 (_t_body_analysis - _t_class_checks).as_secs_f64() * 1000.0,
536 _t_total.as_secs_f64() * 1000.0,
537 );
538 }
539
540 if let Some(plugins) = mir_plugin::snapshot() {
543 if plugins.hooks().before_add_issue {
544 all_issues.retain(|i| plugins.before_add_issue(i));
545 }
546 }
547
548 opts.apply(&mut all_issues);
549 let analyzed_files_vec: Vec<Arc<str>> = analyzed_file_set.iter().cloned().collect();
550 self.apply_suppressions_and_emit_unused(&mut all_issues, &analyzed_files_vec);
551 if let Some(dump) = crate::metrics::dump() {
552 eprintln!("{dump}");
553 }
554
555 {
557 let mut guard = self.db.salsa.write();
558 guard.rebuild_workspace_symbol_index();
559 }
560
561 AnalysisResult::build(all_issues, rustc_hash::FxHashMap::default(), all_symbols)
562 }
563 pub fn re_analyze_file(
569 &self,
570 file_path: &str,
571 new_content: &str,
572 opts: &BatchOptions,
573 ) -> AnalysisResult {
574 self.clear_transient_batch_replay();
575 let php_version = self.batch_php_version(opts);
576
577 self.settle_workspace_index();
582
583 if let Some(cache) = &self.cache {
585 let h = hash_content(new_content);
586 if let Some((cached_issues, ref_locs)) = cache.get(file_path, &h) {
587 let mut issues = cached_issues.to_vec();
588 let file: Arc<str> = Arc::from(file_path);
589 let locs: Vec<RefLoc> = ref_locs
593 .iter()
594 .map(|(symbol, line, col_start, col_end)| RefLoc {
595 symbol_key: Arc::clone(symbol),
596 file: file.clone(),
597 line: *line,
598 col_start: *col_start,
599 col_end: *col_end,
600 })
601 .collect();
602 let guard = self.db.salsa.read();
603 let file_no = guard.locked_ref_index().intern_path(&file);
604 guard.set_file_reference_locations(file_no, locs);
605 drop(guard);
606 opts.apply(&mut issues);
607 self.apply_suppressions_and_emit_unused(&mut issues, std::slice::from_ref(&file));
608 return AnalysisResult::build(issues, HashMap::default(), Vec::new());
609 }
610 }
611
612 let file: Arc<str> = Arc::from(file_path);
613
614 {
615 let mut guard = self.db.salsa.write();
616 guard.remove_file_definitions(file_path);
617 }
618
619 let collected =
620 self.db
621 .collect_and_ingest_file_with_parsed(file.clone(), new_content, php_version);
622 let file_defs = collected.file_defs;
623
624 let mut all_issues: Vec<Issue> = Arc::unwrap_or_clone(file_defs.issues.clone());
625
626 {
627 let mut guard = self.db.salsa.write();
628 if guard.workspace_symbol_index_singleton().is_some() {
629 if let Some(sf) = guard.lookup_source_file(file.as_ref()) {
630 if guard.file_declarations_changed(sf) {
631 guard.rebuild_workspace_symbol_index();
632 }
633 }
634 }
635 }
636
637 let (symbols, surface_hash) = {
638 let guard = self.db.salsa.write();
639 let parsed = collected
640 .parsed
641 .unwrap_or_else(|| php_rs_parser::parse(new_content));
642 let surface_hash = surface_fingerprint(new_content, &parsed.program);
643
644 let has_hard_errors = parsed.errors.iter().any(crate::parser::is_hard_parse_error);
645 let symbols = if !has_hard_errors {
646 let db_ref: &dyn MirDatabase = &**guard;
647 let mut driver = BodyAnalyzer::new(db_ref, php_version);
648 driver.collect_symbols = !opts.skip_symbols;
649 let (body_issues, symbols) = driver.analyze_bodies(
650 &parsed.program,
651 file.clone(),
652 new_content,
653 &parsed.source_map,
654 );
655 all_issues.extend(body_issues);
656 let pending = guard.take_pending_ref_locs();
657 let file_no = guard.locked_ref_index().intern_path(&file);
658 guard.set_file_reference_locations(file_no, pending);
659 if opts.skip_symbols {
660 Vec::new()
661 } else {
662 symbols
663 }
664 } else {
665 Vec::new()
666 };
667 (symbols, surface_hash)
668 };
669
670 mark_suppressed(
678 &mut all_issues,
679 &crate::suppression::SuppressionMap::from_source(new_content),
680 );
681
682 if let Some(cache) = &self.cache {
683 let h = hash_content(new_content);
684 let surface_changed = match cache.surface_hash(file_path) {
688 Some(stored) if !stored.is_empty() => stored != surface_hash,
689 _ => true,
690 };
691 if surface_changed {
692 cache.evict_with_dependents(&[file_path.to_string()]);
693 }
694 let db = self.snapshot_db();
695 let ref_locs = extract_reference_locations(&db, &file);
696 cache.put(
697 file_path,
698 h,
699 surface_hash,
700 all_issues.as_slice().into(),
701 ref_locs,
702 );
703 }
704
705 opts.apply(&mut all_issues);
706 self.apply_suppressions_and_emit_unused(&mut all_issues, std::slice::from_ref(&file));
714 AnalysisResult::build(all_issues, HashMap::default(), symbols)
715 }
716
717 pub fn collect_definitions(&self, paths: &[PathBuf]) {
726 self.clear_transient_batch_replay();
727 let _timing = std::env::var("MIR_TIMING").is_ok();
728 let _t0 = std::time::Instant::now();
729
730 let php_v = self.php_version.cache_byte();
731
732 struct FileEntry {
733 file: Arc<str>,
734 src: Arc<str>,
735 hash: [u8; 32],
736 cached: Option<mir_codebase::definitions::StubSlice>,
737 }
738 let entries: Vec<FileEntry> = paths
739 .par_iter()
740 .filter_map(|path| {
741 let src = std::fs::read_to_string(path).ok()?;
742 let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
743 let src: Arc<str> = Arc::from(src);
744 let hash = hash_source(&src);
745 let cached = self.db.stub_cache.as_ref().and_then(|c| {
746 let (mut slice, _issues) = c.get(&file, &hash, php_v)?;
747 prepare_for_ingest(&mut slice);
748 Some(slice)
749 });
750 Some(FileEntry {
751 file,
752 src,
753 hash,
754 cached,
755 })
756 })
757 .collect();
758 let _t_read = _t0.elapsed();
759
760 let source_files: Vec<SourceFile> = {
761 let mut guard = self.db.salsa.write();
762 entries
763 .iter()
764 .map(|e| {
765 guard.upsert_source_file_with_durability(
766 e.file.clone(),
767 e.src.clone(),
768 salsa::Durability::HIGH,
769 )
770 })
771 .collect()
772 };
773 let _t_reg = _t0.elapsed();
774
775 let db_pass1 = {
776 let guard = self.db.salsa.read();
777 (**guard).clone()
778 };
779 let stub_cache = self.db.stub_cache.clone();
780 let prepared: Vec<(Arc<str>, mir_codebase::definitions::StubSlice)> = entries
781 .into_par_iter()
782 .zip(source_files.into_par_iter())
783 .map_with(db_pass1, |db, (mut entry, salsa_file)| {
784 if let Some(slice) = entry.cached.take() {
785 let slice_arc = Arc::new(slice);
786 db.parse_cache().insert(
787 entry.hash,
788 php_v,
789 Arc::clone(&slice_arc),
790 Arc::new(Vec::new()),
791 );
792 return (entry.file.clone(), (*slice_arc).clone());
793 }
794 let defs = collect_file_definitions(&*db, salsa_file);
795 if let Some(cache) = stub_cache.as_ref() {
796 cache.put(&entry.file, &entry.hash, php_v, &defs.slice, &defs.issues);
797 }
798 (entry.file.clone(), (*defs.slice).clone())
799 })
800 .collect();
801 let _t_collect = _t0.elapsed();
802 {
808 let guard = self.db.salsa.read();
809 for (file, slice) in &prepared {
810 let entries = crate::db::subtype_index::entries_from_slice(slice);
811 let file_no = guard.locked_ref_index().intern_path(file);
812 guard.set_file_class_edges(file_no, entries);
813 }
814 }
815 drop(prepared);
816 let _t_ingest = _t0.elapsed();
817
818 if _timing {
819 let (hits, misses) = self.stub_cache_stats();
820 eprintln!(
821 "[vendor] read={:.0}ms reg={:.0}ms collect={:.0}ms ingest={:.0}ms total={:.0}ms (cache hits={hits} misses={misses})",
822 _t_read.as_secs_f64() * 1000.0,
823 (_t_reg - _t_read).as_secs_f64() * 1000.0,
824 (_t_collect - _t_reg).as_secs_f64() * 1000.0,
825 (_t_ingest - _t_collect).as_secs_f64() * 1000.0,
826 _t_ingest.as_secs_f64() * 1000.0,
827 );
828 }
829
830 {
831 let mut guard = self.db.salsa.write();
832 guard.rebuild_workspace_symbol_index();
833 }
834
835 crate::collector::print_collector_stats();
836 }
837}
838
839#[cfg(test)]
840mod tests {
841 use rustc_hash::FxHashMap as HashMap;
842 use std::fs;
843 use std::sync::{Arc, Mutex, OnceLock};
844
845 use crate::{AnalysisSession, BatchOptions, PhpVersion};
846
847 fn metrics_test_lock() -> &'static Mutex<()> {
848 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
849 LOCK.get_or_init(|| Mutex::new(()))
850 }
851
852 fn write_php(dir: &tempfile::TempDir, name: &str, contents: &str) -> std::path::PathBuf {
853 let path = dir.path().join(name);
854 fs::write(&path, contents).expect("write fixture");
855 path
856 }
857
858 #[test]
859 fn identical_batch_rerun_replays_without_symbols() {
860 let _guard = metrics_test_lock().lock().expect("metrics test lock");
861 let dir = tempfile::tempdir().expect("tempdir");
862 let file = write_php(
863 &dir,
864 "Example.php",
865 "<?php\nclass Example {\n public function answer(): int { return 42; }\n}\n(new Example())->answer();\n",
866 );
867 let session = AnalysisSession::new(PhpVersion::LATEST);
868 let opts = BatchOptions::new().without_symbols();
869 let hash = crate::cache::hash_content(
870 "<?php\nclass Example {\n public function answer(): int { return 42; }\n}\n(new Example())->answer();\n",
871 );
872 let hashes = HashMap::from_iter([(Arc::from(file.to_string_lossy().as_ref()), hash)]);
873
874 let first = session.analyze_paths(std::slice::from_ref(&file), &opts);
875 assert!(first.issues.is_empty());
876 assert!(
877 session
878 .transient_batch_replay(PhpVersion::LATEST.cache_byte(), true, &hashes)
879 .is_some(),
880 "expected first run to populate transient replay"
881 );
882
883 crate::metrics::test_reset();
884 let second = session.analyze_paths(std::slice::from_ref(&file), &opts);
885 assert!(second.issues.is_empty());
886 assert!(second.symbols.is_empty());
887
888 let dump = crate::metrics::dump().expect("metrics enabled in tests");
889 assert!(
890 dump.contains("whole-file walks : 0"),
891 "expected replay to skip body walks, got:\n{dump}"
892 );
893 assert!(
894 dump.contains("symbols allocated : 0"),
895 "expected diagnostics-only replay to avoid symbol allocation, got:\n{dump}"
896 );
897 }
898
899 #[test]
900 fn identical_batch_rerun_replays_with_symbols() {
901 let _guard = metrics_test_lock().lock().expect("metrics test lock");
902 let dir = tempfile::tempdir().expect("tempdir");
903 let file = write_php(
904 &dir,
905 "Example.php",
906 "<?php\nclass Example {\n public function answer(): int { return 42; }\n}\n(new Example())->answer();\n",
907 );
908 let session = AnalysisSession::new(PhpVersion::LATEST);
909 let opts = BatchOptions::new();
910 let hash = crate::cache::hash_content(
911 "<?php\nclass Example {\n public function answer(): int { return 42; }\n}\n(new Example())->answer();\n",
912 );
913 let hashes = HashMap::from_iter([(Arc::from(file.to_string_lossy().as_ref()), hash)]);
914
915 let first = session.analyze_paths(std::slice::from_ref(&file), &opts);
916 assert!(
917 !first.symbols.is_empty(),
918 "expected first run to collect symbols"
919 );
920 assert!(
921 session
922 .transient_batch_replay(PhpVersion::LATEST.cache_byte(), false, &hashes)
923 .is_some(),
924 "expected first run to populate transient replay"
925 );
926
927 crate::metrics::test_reset();
928 let second = session.analyze_paths(std::slice::from_ref(&file), &opts);
929 assert_eq!(second.symbols.len(), first.symbols.len());
930
931 let dump = crate::metrics::dump().expect("metrics enabled in tests");
932 assert!(
933 dump.contains("whole-file walks : 0"),
934 "expected replay to skip body walks, got:\n{dump}"
935 );
936 }
937
938 #[test]
939 fn session_mutation_clears_batch_replay() {
940 let _guard = metrics_test_lock().lock().expect("metrics test lock");
941 let dir = tempfile::tempdir().expect("tempdir");
942 let file = write_php(
943 &dir,
944 "Example.php",
945 "<?php\nclass Example {\n public function answer(): int { return 42; }\n}\n(new Example())->answer();\n",
946 );
947 let file_arc: Arc<str> = Arc::from(file.to_string_lossy().as_ref());
948 let session = AnalysisSession::new(PhpVersion::LATEST);
949 let opts = BatchOptions::new().without_symbols();
950
951 let _ = session.analyze_paths(std::slice::from_ref(&file), &opts);
952 session.ingest_file(
953 Arc::clone(&file_arc),
954 Arc::from(
955 "<?php\nclass Example {\n public function answer(): string { return 'forty two'; }\n}\n(new Example())->answer();\n",
956 ),
957 );
958
959 crate::metrics::test_reset();
960 let result = session.analyze_paths(std::slice::from_ref(&file), &opts);
961 assert!(result.issues.is_empty());
962
963 let dump = crate::metrics::dump().expect("metrics enabled in tests");
964 assert!(
965 dump.contains("whole-file walks : 1"),
966 "expected session mutation to invalidate replay, got:\n{dump}"
967 );
968 }
969}