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(&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)))
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 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        // ---- 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
141        // ---- Cross-file invalidation: evict dependents whose surface changed --
142        // A file whose content changed re-analyzes itself regardless (its body
143        // pass misses the cache below). It cascades to dependents only when its
144        // declaration-level surface changed: a body-only edit to a declared-
145        // return callable leaves every dependent's result intact. A missing or
146        // pre-firewall (empty) stored surface is treated as unknown and cascades.
147        if let Some(cache) = &self.cache {
148            let content_changed: Vec<String> = file_data
149                .iter()
150                .filter_map(|(f, _src)| {
151                    let valid = content_hexes.get(f).is_some_and(|h| cache.is_valid(f, h));
152                    if valid {
153                        None
154                    } else {
155                        Some(f.to_string())
156                    }
157                })
158                .collect();
159            if !content_changed.is_empty() {
160                topology_changed = true;
161            }
162            let mut seeds: Vec<String> = content_changed
163                .into_iter()
164                .filter(|f| {
165                    let new_surface = surface_hashes
166                        .get(f.as_str())
167                        .map(String::as_str)
168                        .unwrap_or("");
169                    // Keep (cascade) unless the stored surface is known and equal.
170                    !matches!(
171                        cache.surface_hash(f),
172                        Some(stored) if !stored.is_empty() && stored == new_surface
173                    )
174                })
175                .collect();
176            seeds.extend(removed_files.iter().cloned());
177            if !seeds.is_empty() {
178                cache.evict_with_dependents(&seeds);
179            }
180        }
181
182        // Prime the in-process parse cache so the pre-warm loop below avoids
183        // re-parsing every project file through collect_file_definitions.
184        {
185            let guard = self.db.salsa.read();
186            let php_v = php_version.cache_byte();
187            for (defs, hash, has_hard_parse_errors, _surface) in &file_defs {
188                if !*has_hard_parse_errors {
189                    guard.prime_parse_cache(
190                        *hash,
191                        php_v,
192                        Arc::clone(&defs.slice),
193                        Arc::clone(&defs.issues),
194                    );
195                }
196            }
197        }
198
199        let mut files_with_parse_errors: HashSet<Arc<str>> = HashSet::default();
200        {
201            // Commit subtype-index class edges alongside issue collection —
202            // parity with `ingest_file`'s single-file path, so goto-implementation
203            // sees implementors from a batch/vendor run without waiting for
204            // each file to be individually touched by an on-demand commit path.
205            let guard = self.db.salsa.read();
206            for (parsed, (defs, _hash, _hard_err, _surface)) in parsed_files.iter().zip(file_defs) {
207                for issue in defs.issues.iter() {
208                    if matches!(issue.kind, mir_issues::IssueKind::ParseError { .. })
209                        && issue.severity == mir_issues::Severity::Error
210                    {
211                        files_with_parse_errors.insert(issue.location.file.clone());
212                    }
213                }
214                let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
215                guard.set_file_class_edges(&parsed.file, entries);
216                all_issues.extend(Arc::unwrap_or_clone(defs.issues));
217            }
218        }
219        let _t_ingest = _t0.elapsed();
220
221        // ---- Pre-warm collect_file_definitions for project files -------------
222        {
223            let db_prewarm = {
224                let guard = self.db.salsa.read();
225                (**guard).clone()
226            };
227            let project_source_files: Vec<SourceFile> = {
228                let guard = self.db.salsa.read();
229                parsed_files
230                    .iter()
231                    .filter_map(|p| (**guard).lookup_source_file(&p.file))
232                    .collect()
233            };
234            project_source_files
235                .into_par_iter()
236                .for_each_with(db_prewarm, |db, sf| {
237                    let _ = collect_file_definitions(db as &dyn MirDatabase, sf);
238                });
239        }
240        let _t_prewarm_ms = (_t0.elapsed() - _t_ingest).as_secs_f64() * 1000.0;
241
242        // Fold the freshly-registered project files into the workspace symbol
243        // index singleton. The singleton may have been built from vendor before
244        // this run (CLI indexes vendor before analyze_paths); since adding files
245        // no longer nulls it, project classes would otherwise be invisible to
246        // find_class_like and reported as false UndefinedClass.
247        self.refresh_workspace_index();
248
249        // ---- Lazy-load unknown classes via PSR-4 ----------------------------
250        let _t_before_lazy = _t0.elapsed();
251        if let Some(psr4) = self.psr4.clone() {
252            self.lazy_load_missing_classes(psr4, php_version, &mut all_issues);
253        }
254        let _t_lazyload_ms = (_t0.elapsed() - _t_before_lazy).as_secs_f64() * 1000.0;
255
256        // ---- Class-level checks ---------------------------------------------
257        let analyzed_file_set: HashSet<Arc<str>> =
258            file_data.iter().map(|(f, _)| f.clone()).collect();
259
260        // Definitions are all collected and indexed — fire the plugin hook
261        // (Psalm's AfterCodebasePopulated) before any body/class analysis.
262        if let Some(plugins) = mir_plugin::snapshot() {
263            if plugins.hooks().after_codebase_populated {
264                let files: Vec<Arc<str>> = analyzed_file_set.iter().cloned().collect();
265                plugins.after_codebase_populated(&mut mir_plugin::AfterCodebasePopulatedEvent {
266                    files: &files,
267                });
268            }
269        }
270        let _t_class_analyzer = std::time::Instant::now();
271        {
272            let class_db = {
273                let guard = self.db.salsa.read();
274                (**guard).clone()
275            };
276            let class_issues = crate::class::ClassAnalyzer::with_files(
277                &class_db,
278                analyzed_file_set.clone(),
279                &file_data,
280            )
281            .analyze_all();
282            all_issues.extend(class_issues);
283        }
284        let _t_class_analyzer_ms = _t_class_analyzer.elapsed().as_secs_f64() * 1000.0;
285
286        let _t_class_checks = _t0.elapsed();
287
288        let mut db_main = {
289            let guard = self.db.salsa.read();
290            (**guard).clone()
291        };
292        // All index mutation for the body pass is done (lazy_load_missing_classes
293        // + refresh ran above; lazy_load_from_body_issues runs *after* this pass
294        // on a separate db). Freeze the index on this ephemeral clone so each
295        // find_class_like borrows it instead of cloning the singleton's three
296        // Arcs per call — the per-worker `map_with` clone bumps the refcount once.
297        db_main.freeze_workspace_index();
298
299        // ---- Body analysis: function/method bodies in parallel --------------
300        type BodyResult = (
301            Arc<str>,
302            Vec<Issue>,
303            Vec<crate::symbol::ResolvedSymbol>,
304            Vec<RefLoc>,
305        );
306        let body_results: Vec<BodyResult> = parsed_files
307            .par_iter()
308            .filter(|parsed| !files_with_parse_errors.contains(&parsed.file))
309            .map_with(db_main, |db, parsed| {
310                let mut driver = BodyAnalyzer::new(&*db as &dyn MirDatabase, php_version);
311                // Diagnostics-only consumers never read the symbol vecs —
312                // don't build them (a Type clone per reference) at all.
313                driver.collect_symbols = !opts.skip_symbols;
314                let (issues, symbols) = if let Some(cache) = &self.cache {
315                    let h = content_hexes
316                        .get(parsed.file.as_ref())
317                        .cloned()
318                        .unwrap_or_else(|| hash_content(parsed.source()));
319                    if let Some((cached_issues, ref_locs)) = cache.get(&parsed.file, &h) {
320                        // Cache replay: rebuild the file's complete reference
321                        // set straight from the cached tuples — no pending-
322                        // buffer detour. Symbol keys are Arc-shared with the
323                        // cache entry, so this allocates no strings.
324                        let locs: Vec<RefLoc> = ref_locs
325                            .iter()
326                            .map(|(symbol, line, col_start, col_end)| RefLoc {
327                                symbol_key: Arc::clone(symbol),
328                                file: parsed.file.clone(),
329                                line: *line,
330                                col_start: *col_start,
331                                col_end: *col_end,
332                            })
333                            .collect();
334                        return (
335                            parsed.file.clone(),
336                            cached_issues.to_vec(),
337                            Vec::new(),
338                            locs,
339                        );
340                    }
341                    let (issues, symbols) = driver.analyze_bodies(
342                        parsed.owned(),
343                        parsed.file.clone(),
344                        parsed.source(),
345                        parsed.source_map(),
346                    );
347                    let pending = db.take_pending_ref_locs();
348                    let cache_locs: Arc<[crate::cache::CachedRefLoc]> = pending
349                        .iter()
350                        .map(|r| (Arc::clone(&r.symbol_key), r.line, r.col_start, r.col_end))
351                        .collect();
352                    let surface = surface_hashes
353                        .get(parsed.file.as_ref())
354                        .cloned()
355                        .unwrap_or_default();
356                    cache.put(
357                        &parsed.file,
358                        h,
359                        surface,
360                        issues.as_slice().into(),
361                        cache_locs,
362                    );
363                    if let Some(cb) = &opts.on_file_done {
364                        cb();
365                    }
366                    let symbols = if opts.skip_symbols {
367                        Vec::new()
368                    } else {
369                        symbols
370                    };
371                    return (parsed.file.clone(), issues, symbols, pending);
372                } else {
373                    driver.analyze_bodies(
374                        parsed.owned(),
375                        parsed.file.clone(),
376                        parsed.source(),
377                        parsed.source_map(),
378                    )
379                };
380                let pending = db.take_pending_ref_locs();
381                if let Some(cb) = &opts.on_file_done {
382                    cb();
383                }
384                // Drop the per-file symbol vec inside the worker when the
385                // consumer opted out — the orchestrator never accumulates.
386                let symbols = if opts.skip_symbols {
387                    Vec::new()
388                } else {
389                    symbols
390                };
391                (parsed.file.clone(), issues, symbols, pending)
392            })
393            .collect();
394
395        let _t_body_analysis = _t0.elapsed();
396
397        // Serial commit with replace semantics: each file's output (or cache
398        // replay) is its complete reference set, so stale entries from a
399        // prior run cannot survive an append.
400        let mut all_symbols = Vec::new();
401        {
402            let guard = self.db.salsa.read();
403            for (file, issues, symbols, ref_locs) in body_results {
404                all_issues.extend(issues);
405                all_symbols.extend(symbols);
406                guard.set_file_reference_locations(file.as_ref(), ref_locs);
407            }
408        }
409
410        // ---- Post-analysis lazy loading: FQCNs used without `use` imports ------
411        if let Some(psr4) = self.psr4.clone() {
412            self.lazy_load_from_body_issues(
413                psr4,
414                php_version,
415                &file_data,
416                &files_with_parse_errors,
417                &mut all_issues,
418                &mut all_symbols,
419                opts.skip_symbols,
420            );
421        }
422
423        // ---- Build reverse dep graph and persist it for the next run ---------
424        // Must run AFTER `commit_reference_locations_batch` (above): the graph's
425        // call-site / instantiation / inferred-return edges are derived from the
426        // committed reference-location map. Built any earlier (the salsa db is
427        // fresh each session) that map is empty, so only structural edges
428        // (parent/interface/trait/declared types) survive — and any dependent
429        // reachable only through a call site or inferred type goes stale.
430        if topology_changed {
431            if let Some(cache) = &self.cache {
432                let db_snapshot = {
433                    let guard = self.db.salsa.read();
434                    (**guard).clone()
435                };
436                let rev = build_reverse_deps(&db_snapshot);
437                cache.set_reverse_deps(rev);
438            }
439        }
440
441        // Persist cache hits/misses to disk
442        if let Some(cache) = &self.cache {
443            cache.flush();
444        }
445
446        // ---- Dead-code detection -------------------------------------------
447        if opts.should_run_dead_code() {
448            let salsa = self.snapshot_db();
449            let _t_dead_code = std::time::Instant::now();
450            let dead_code_issues =
451                crate::dead_code::DeadCodeAnalyzer::with_files(&salsa, analyzed_file_set.clone())
452                    .analyze();
453            all_issues.extend(dead_code_issues);
454            if std::env::var("MIR_TIMING").is_ok() {
455                eprintln!(
456                    "[timing] dead_code_analyzer={:.0}ms",
457                    _t_dead_code.elapsed().as_secs_f64() * 1000.0
458                );
459            }
460        }
461
462        let _t_total = _t0.elapsed();
463        if std::env::var("MIR_TIMING").is_ok() {
464            eprintln!(
465                "[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",
466                _t_stubs.as_secs_f64() * 1000.0,
467                (_t_read - _t_stubs).as_secs_f64() * 1000.0,
468                (_t_salsa_reg - _t_read).as_secs_f64() * 1000.0,
469                (_t_collect_defs - _t_salsa_reg).as_secs_f64() * 1000.0,
470                (_t_ingest - _t_collect_defs).as_secs_f64() * 1000.0,
471                (_t_class_checks - _t_ingest).as_secs_f64() * 1000.0,
472                _t_prewarm_ms,
473                _t_lazyload_ms,
474                _t_class_analyzer_ms,
475                (_t_body_analysis - _t_class_checks).as_secs_f64() * 1000.0,
476                _t_total.as_secs_f64() * 1000.0,
477            );
478        }
479
480        // Plugin issue veto (Psalm's BeforeAddIssue) — applied to the final
481        // set before config-level suppression so a veto can't be shadowed.
482        if let Some(plugins) = mir_plugin::snapshot() {
483            if plugins.hooks().before_add_issue {
484                all_issues.retain(|i| plugins.before_add_issue(i));
485            }
486        }
487
488        opts.apply(&mut all_issues);
489        let analyzed_files_vec: Vec<Arc<str>> = analyzed_file_set.iter().cloned().collect();
490        self.apply_suppressions_and_emit_unused(&mut all_issues, &analyzed_files_vec);
491        if let Some(dump) = crate::metrics::dump() {
492            eprintln!("{dump}");
493        }
494
495        // ---- Build workspace symbol index singleton -------------------------
496        {
497            let mut guard = self.db.salsa.write();
498            guard.rebuild_workspace_symbol_index();
499        }
500
501        AnalysisResult::build(all_issues, rustc_hash::FxHashMap::default(), all_symbols)
502    }
503    /// Re-analyze a single file (definition collection + body analysis) within the batch context.
504    ///
505    /// Mirrors the old `ProjectAnalyzer::re_analyze_file` cache-aware path.
506    /// Use [`Self::reanalyze_dependents`] for LSP-style per-file flows that
507    /// don't need batch options.
508    pub fn re_analyze_file(
509        &self,
510        file_path: &str,
511        new_content: &str,
512        opts: &BatchOptions,
513    ) -> AnalysisResult {
514        let php_version = self.batch_php_version(opts);
515
516        // Fast path: content unchanged and cache has a valid entry.
517        if let Some(cache) = &self.cache {
518            let h = hash_content(new_content);
519            if let Some((cached_issues, ref_locs)) = cache.get(file_path, &h) {
520                let mut issues = cached_issues.to_vec();
521                let file: Arc<str> = Arc::from(file_path);
522                // Replace semantics: the cached set is the file's complete
523                // reference set, so stale entries from a prior version are
524                // cleared rather than appended over.
525                let locs: Vec<RefLoc> = ref_locs
526                    .iter()
527                    .map(|(symbol, line, col_start, col_end)| RefLoc {
528                        symbol_key: Arc::clone(symbol),
529                        file: file.clone(),
530                        line: *line,
531                        col_start: *col_start,
532                        col_end: *col_end,
533                    })
534                    .collect();
535                let guard = self.db.salsa.read();
536                guard.set_file_reference_locations(file_path, locs);
537                drop(guard);
538                opts.apply(&mut issues);
539                self.apply_suppressions_and_emit_unused(&mut issues, std::slice::from_ref(&file));
540                return AnalysisResult::build(issues, HashMap::default(), Vec::new());
541            }
542        }
543
544        let file: Arc<str> = Arc::from(file_path);
545
546        {
547            let mut guard = self.db.salsa.write();
548            guard.remove_file_definitions(file_path);
549        }
550
551        let file_defs = {
552            let mut guard = self.db.salsa.write();
553            let salsa_file = guard.upsert_source_file(file.clone(), Arc::from(new_content));
554            collect_file_definitions(&**guard, salsa_file).clone()
555        };
556
557        let mut all_issues: Vec<Issue> = Arc::unwrap_or_clone(file_defs.issues.clone());
558
559        {
560            let mut guard = self.db.salsa.write();
561            if guard.workspace_symbol_index_singleton().is_some() {
562                if let Some(sf) = guard.lookup_source_file(file.as_ref()) {
563                    if guard.file_declarations_changed(sf) {
564                        guard.rebuild_workspace_symbol_index();
565                    }
566                }
567            }
568        }
569
570        let (symbols, surface_hash) = {
571            let guard = self.db.salsa.write();
572
573            let parsed = php_rs_parser::parse(new_content);
574            let surface_hash = surface_fingerprint(new_content, &parsed.program);
575
576            let has_hard_errors = parsed.errors.iter().any(crate::parser::is_hard_parse_error);
577            let symbols = if !has_hard_errors {
578                let db_ref: &dyn MirDatabase = &**guard;
579                let driver = BodyAnalyzer::new(db_ref, php_version);
580                let (body_issues, symbols) = driver.analyze_bodies(
581                    &parsed.program,
582                    file.clone(),
583                    new_content,
584                    &parsed.source_map,
585                );
586                all_issues.extend(body_issues);
587                let pending = guard.take_pending_ref_locs();
588                guard.set_file_reference_locations(file.as_ref(), pending);
589                symbols
590            } else {
591                Vec::new()
592            };
593            (symbols, surface_hash)
594        };
595
596        // Bake inline-suppression marks in *before* caching: suppression is a
597        // pure function of file content (and the cache key hashes content), so
598        // the cached issues should already carry their marks. The cache-hit
599        // branch above replays this file's source without re-registering the
600        // `SourceFile` input, so the db-backed post-filter cannot recompute
601        // marks there — caching the canonical result is what keeps a fresh
602        // process honoring `@mir-ignore` on an unchanged file.
603        mark_suppressed(
604            &mut all_issues,
605            &crate::suppression::SuppressionMap::from_source(new_content),
606        );
607
608        if let Some(cache) = &self.cache {
609            let h = hash_content(new_content);
610            // Cascade to dependents only when this file's cross-file surface
611            // changed; a body-only edit to a declared-return callable leaves
612            // their results valid. Unknown (absent/empty) stored surface cascades.
613            let surface_changed = match cache.surface_hash(file_path) {
614                Some(stored) if !stored.is_empty() => stored != surface_hash,
615                _ => true,
616            };
617            if surface_changed {
618                cache.evict_with_dependents(&[file_path.to_string()]);
619            }
620            let db = self.snapshot_db();
621            let ref_locs = extract_reference_locations(&db, &file);
622            cache.put(
623                file_path,
624                h,
625                surface_hash,
626                all_issues.as_slice().into(),
627                ref_locs,
628            );
629        }
630
631        opts.apply(&mut all_issues);
632        // Emit `UnusedSuppress` for named suppressions that matched nothing —
633        // present on the cache-hit branch above and on `analyze_paths`, but
634        // missing here, so every non-cached re-analysis (the actual
635        // incremental/LSP-edit pipeline) silently dropped this diagnostic.
636        // `mark_suppressed` above already baked suppression marks into what
637        // gets cached; this only adds the (uncached, always-fresh) unused-
638        // suppression pass on top, mirroring the cache-hit branch's order.
639        self.apply_suppressions_and_emit_unused(&mut all_issues, std::slice::from_ref(&file));
640        AnalysisResult::build(all_issues, HashMap::default(), symbols)
641    }
642
643    /// Collect type definitions only from `paths` into the codebase
644    /// without analyzing method bodies or emitting issues. Used to load
645    /// vendor types.
646    ///
647    /// When a disk-backed cache is attached, per-file `StubSlice` results
648    /// from previous runs are reused on a content-hash match, eliminating
649    /// the parse + definition-collection step. Cache misses run the normal
650    /// pipeline and write back so subsequent runs hit.
651    pub fn collect_definitions(&self, paths: &[PathBuf]) {
652        let _timing = std::env::var("MIR_TIMING").is_ok();
653        let _t0 = std::time::Instant::now();
654
655        let php_v = self.php_version.cache_byte();
656
657        struct FileEntry {
658            file: Arc<str>,
659            src: Arc<str>,
660            hash: [u8; 32],
661            cached: Option<mir_codebase::definitions::StubSlice>,
662        }
663        let entries: Vec<FileEntry> = paths
664            .par_iter()
665            .filter_map(|path| {
666                let src = std::fs::read_to_string(path).ok()?;
667                let file: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
668                let src: Arc<str> = Arc::from(src);
669                let hash = hash_source(&src);
670                let cached = self.db.stub_cache.as_ref().and_then(|c| {
671                    let (mut slice, _issues) = c.get(&file, &hash, php_v)?;
672                    prepare_for_ingest(&mut slice);
673                    Some(slice)
674                });
675                Some(FileEntry {
676                    file,
677                    src,
678                    hash,
679                    cached,
680                })
681            })
682            .collect();
683        let _t_read = _t0.elapsed();
684
685        let source_files: Vec<SourceFile> = {
686            let mut guard = self.db.salsa.write();
687            entries
688                .iter()
689                .map(|e| {
690                    guard.upsert_source_file_with_durability(
691                        e.file.clone(),
692                        e.src.clone(),
693                        salsa::Durability::HIGH,
694                    )
695                })
696                .collect()
697        };
698        let _t_reg = _t0.elapsed();
699
700        let db_pass1 = {
701            let guard = self.db.salsa.read();
702            (**guard).clone()
703        };
704        let stub_cache = self.db.stub_cache.clone();
705        let prepared: Vec<(Arc<str>, mir_codebase::definitions::StubSlice)> = entries
706            .into_par_iter()
707            .zip(source_files.into_par_iter())
708            .map_with(db_pass1, |db, (mut entry, salsa_file)| {
709                if let Some(slice) = entry.cached.take() {
710                    let slice_arc = Arc::new(slice);
711                    db.parse_cache().insert(
712                        entry.hash,
713                        php_v,
714                        Arc::clone(&slice_arc),
715                        Arc::new(Vec::new()),
716                    );
717                    return (entry.file.clone(), (*slice_arc).clone());
718                }
719                let defs = collect_file_definitions(&*db, salsa_file);
720                if let Some(cache) = stub_cache.as_ref() {
721                    cache.put(&entry.file, &entry.hash, php_v, &defs.slice, &defs.issues);
722                }
723                (entry.file.clone(), (*defs.slice).clone())
724            })
725            .collect();
726        let _t_collect = _t0.elapsed();
727        // Commit subtype-index class edges for the vendor tree: without this,
728        // goto-implementation can't surface implementors that live only in
729        // vendor/ (index_batch's project-file warm sweep has the same gap for
730        // project files, but no StubSlice is cheaply available there — see
731        // the persistence work tracked separately).
732        {
733            let guard = self.db.salsa.read();
734            for (file, slice) in &prepared {
735                let entries = crate::db::subtype_index::entries_from_slice(slice);
736                guard.set_file_class_edges(file, entries);
737            }
738        }
739        drop(prepared);
740        let _t_ingest = _t0.elapsed();
741
742        if _timing {
743            let (hits, misses) = self.stub_cache_stats();
744            eprintln!(
745                "[vendor] read={:.0}ms reg={:.0}ms collect={:.0}ms ingest={:.0}ms total={:.0}ms (cache hits={hits} misses={misses})",
746                _t_read.as_secs_f64() * 1000.0,
747                (_t_reg - _t_read).as_secs_f64() * 1000.0,
748                (_t_collect - _t_reg).as_secs_f64() * 1000.0,
749                (_t_ingest - _t_collect).as_secs_f64() * 1000.0,
750                _t_ingest.as_secs_f64() * 1000.0,
751            );
752        }
753
754        {
755            let mut guard = self.db.salsa.write();
756            guard.rebuild_workspace_symbol_index();
757        }
758
759        crate::collector::print_collector_stats();
760    }
761}