Skip to main content

mir_analyzer/batch/
run.rs

1use super::*;
2
3impl AnalysisSession {
4    /// Run the full batch analysis pipeline on a set of file paths.
5    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        // ---- Load PHP built-in stubs (before definition collection so user code can override)
11        self.load_batch_stubs(php_version);
12        // Index vendor autoload.files (global function/constant helpers such as
13        // Laravel's `confirm()`, `select()`, etc.) before body analysis so
14        // calls to these functions resolve rather than emitting UndefinedFunction.
15        self.ensure_vendor_eager_functions();
16        let _t_stubs = _t0.elapsed();
17
18        // ---- Read files in parallel ----------------------------------
19        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        // ---- Detect files deleted since the last run ------------------------
40        // A file analyzed previously but now gone from disk leaves dependents
41        // holding results that assume its definitions still exist. (A file
42        // merely absent from this run's path set but still on disk is NOT a
43        // deletion — checking disk existence avoids evicting during partial-path
44        // analysis.) Drop their own entries here; dependent eviction happens
45        // below, once per-file surface fingerprints are known.
46        //
47        // `topology_changed` tracks whether this run touched the cache (any file
48        // changed, added, or removed). When nothing changed, the reverse-dep
49        // graph loaded from disk is still accurate, so the rebuild + full
50        // cache.bin rewrite at the end of this pass is skipped.
51        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        // ---- Register Salsa source inputs for incremental follow-up calls ----
68        {
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        // ---- Definition collection from the already-parsed AST -------
77        // Returns (FileDefinitions, content_hash, has_hard_parse_errors) so we
78        // can prime the parse cache before the pre-warm loop below.
79        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        // Pair each file with its cross-file surface fingerprint (par_iter().map
120        // preserves order, so file_defs aligns with parsed_files).
121        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        // Hex content hashes derived from the pass-1 digests, so the
128        // content-changed pass and the body pass below don't re-hash every
129        // source file (BLAKE3 over all bytes, twice).
130        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        // ---- Cross-file invalidation: evict dependents whose surface changed --
153        // A file whose content changed re-analyzes itself regardless (its body
154        // pass misses the cache below). It cascades to dependents only when its
155        // declaration-level surface changed: a body-only edit to a declared-
156        // return callable leaves every dependent's result intact. A missing or
157        // pre-firewall (empty) stored surface is treated as unknown and cascades.
158        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                    // Keep (cascade) unless the stored surface is known and equal.
181                    !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        // Prime the in-process parse cache so the pre-warm loop below avoids
194        // re-parsing every project file through collect_file_definitions.
195        {
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            // Commit subtype-index class edges alongside issue collection —
213            // parity with `ingest_file`'s single-file path, so goto-implementation
214            // sees implementors from a batch/vendor run without waiting for
215            // each file to be individually touched by an on-demand commit path.
216            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        // ---- Pre-warm collect_file_definitions for project files -------------
234        {
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        // Fold the freshly-registered project files into the workspace symbol
249        // index singleton. It may predate this run (the CLI indexes vendor
250        // before analyze_paths), and adding files doesn't rebuild it.
251        self.refresh_workspace_index();
252
253        // ---- Lazy-load unknown classes via PSR-4 ----------------------------
254        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        // ---- Class-level checks ---------------------------------------------
261        let analyzed_file_set: HashSet<Arc<str>> =
262            file_data.iter().map(|(f, _)| f.clone()).collect();
263
264        // Definitions are all collected and indexed — fire the plugin hook
265        // (Psalm's AfterCodebasePopulated) before any body/class analysis.
266        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        // All index mutation for the body pass is done (lazy_load_missing_classes
291        // + refresh ran above; lazy_load_from_body_issues runs *after* this pass
292        // on a separate db). Freeze the index on this ephemeral clone so each
293        // find_class_like borrows it instead of cloning the singleton's three
294        // Arcs per call — the per-worker `map_with` clone bumps the refcount once.
295        db_main.freeze_workspace_index();
296
297        // ---- Body analysis: function/method bodies in parallel --------------
298        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                // Diagnostics-only consumers never read the symbol vecs —
310                // don't build them (a Type clone per reference) at all.
311                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                        // Cache replay: rebuild the file's complete reference
340                        // set straight from the cached tuples — no pending-
341                        // buffer detour. Symbol keys are Arc-shared with the
342                        // cache entry, so this allocates no strings.
343                        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                // Drop the per-file symbol vec inside the worker when the
404                // consumer opted out — the orchestrator never accumulates.
405                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        // Class-like plugin hooks (Psalm's AfterClassLikeAnalysis) run over
417        // every analyzed file each batch, so their issues, suppressions and
418        // reference marks never depend on body-analysis cache hits.
419        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        // Serial commit with replace semantics: each file's output (or cache
484        // replay) is its complete reference set, so stale entries from a
485        // prior run cannot survive an append.
486        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        // ---- Post-analysis lazy loading: FQCNs used without `use` imports ------
498        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        // ---- Build reverse dep graph and persist it for the next run ---------
511        // Must run AFTER `commit_reference_locations_batch` (above): the graph's
512        // call-site / instantiation / inferred-return edges are derived from the
513        // committed reference-location map. Built any earlier (the salsa db is
514        // fresh each session) that map is empty, so only structural edges
515        // (parent/interface/trait/declared types) survive — and any dependent
516        // reachable only through a call site or inferred type goes stale.
517        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        // Persist cache hits/misses to disk
526        if let Some(cache) = &self.cache {
527            cache.flush();
528        }
529
530        // ---- Dead-code detection -------------------------------------------
531        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        // Plugin issue veto (Psalm's BeforeAddIssue) — applied to the final
565        // set before config-level suppression so a veto can't be shadowed.
566        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        // ---- Build workspace symbol index singleton -------------------------
585        self.db.salsa.rebuild_workspace_symbol_index();
586
587        AnalysisResult::build(all_issues, rustc_hash::FxHashMap::default(), all_symbols)
588    }
589    /// Re-analyze a single file (definition collection + body analysis) within
590    /// the batch context, consulting the disk cache.
591    ///
592    /// Use [`Self::reanalyze_dependents`] for LSP-style per-file flows that
593    /// don't need batch options.
594    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        // Mirror-only workspace files may be waiting in the pending index
604        // set. Reconcile them before the content-hash fast path; otherwise a
605        // cached result computed against a partial workspace could be replayed
606        // before `settle_workspace_index()` gets a chance to invalidate it.
607        self.settle_workspace_index();
608
609        // Fast path: content unchanged and cache has a valid entry.
610        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                // Replace semantics: the cached set is the file's complete
616                // reference set, so stale entries from a prior version are
617                // cleared rather than appended over.
618                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                // Retire first so in-flight snapshot commits for the old
629                // text are refused, as on the analysis path below.
630                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        // Bake inline-suppression marks in *before* caching: suppression is a
697        // pure function of file content (and the cache key hashes content), so
698        // the cached issues should already carry their marks. The cache-hit
699        // branch above replays this file's source without re-registering the
700        // `SourceFile` input, so the db-backed post-filter cannot recompute
701        // marks there — caching the canonical result is what keeps a fresh
702        // process honoring `@mir-ignore` on an unchanged file.
703        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            // Cascade to dependents only when this file's cross-file surface
711            // changed; a body-only edit to a declared-return callable leaves
712            // their results valid. Unknown (absent/empty) stored surface cascades.
713            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        // Emit `UnusedSuppress` for named suppressions that matched nothing —
733        // present on the cache-hit branch above and on `analyze_paths`, but
734        // missing here, so every non-cached re-analysis (the actual
735        // incremental/LSP-edit pipeline) silently dropped this diagnostic.
736        // `mark_suppressed` above already baked suppression marks into what
737        // gets cached; this only adds the (uncached, always-fresh) unused-
738        // suppression pass on top, mirroring the cache-hit branch's order.
739        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    /// Collect type definitions only from `paths` into the codebase
744    /// without analyzing method bodies or emitting issues. Used to load
745    /// vendor types.
746    ///
747    /// When a disk-backed cache is attached, per-file `StubSlice` results
748    /// from previous runs are reused on a content-hash match, eliminating
749    /// the parse + definition-collection step. Cache misses run the normal
750    /// pipeline and write back so subsequent runs hit.
751    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        // Commit subtype-index class edges for the vendor tree: without this,
827        // goto-implementation can't surface implementors that live only in
828        // vendor/ (index_batch's project-file warm sweep has the same gap for
829        // project files, but no StubSlice is cheaply available there — see
830        // the persistence work tracked separately).
831        {
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}