Skip to main content

mir_analyzer/session/
ingest.rs

1use super::*;
2use std::panic::AssertUnwindSafe;
3
4/// One file's disk-cache lookup result from `AnalysisSession::warm_start_files`'s
5/// parallel read phase — everything the sequential apply phase needs, so it
6/// never has to re-read anything.
7struct WarmStartHit {
8    file: Arc<str>,
9    sf: crate::db::SourceFile,
10    stored_text: Arc<str>,
11    /// `(reference locations, resolved)` from a cached `AnalysisCache` hit.
12    refs: Option<(Vec<RefLoc>, bool)>,
13    /// `(subtype edges, declarations)` from a cached stub-slice hit.
14    stub: Option<(
15        Vec<crate::db::subtype_index::SubtypeEntry>,
16        crate::db::FileDeclarations,
17    )>,
18}
19
20impl AnalysisSession {
21    fn snapshot_retry<R>(&self, mut f: impl FnMut(&MirDbStorage) -> R) -> R {
22        loop {
23            let db = self.snapshot_db();
24            if let Ok(value) = salsa::Cancelled::catch(AssertUnwindSafe(|| f(&db))) {
25                return value;
26            }
27            std::thread::yield_now();
28        }
29    }
30
31    /// Cheap clone of the salsa db for a read-only query. The lock is held
32    /// only for the duration of the clone, so concurrent readers never
33    /// serialize on each other or on writes for longer than the clone itself.
34    ///
35    /// **Internal API — exposes Salsa types.** Subject to change without
36    /// notice. Public consumers should use the typed query methods
37    /// ([`Self::definition_of`], [`Self::hover`], etc.) instead.
38    #[doc(hidden)]
39    pub fn snapshot_db(&self) -> MirDbStorage {
40        self.db.snapshot_db()
41    }
42
43    /// Register or update a [`crate::db::SourceFile`] salsa input and return its
44    /// handle, without running definition collection or reference recording.
45    ///
46    /// The write-path entry point for a host that drives this db's salsa inputs
47    /// directly (the LSP database-convergence path) and pulls definitions
48    /// lazily via tracked queries, rather than the eager [`Self::ingest_file`].
49    ///
50    /// **Internal API — exposes Salsa types.** Subject to change without notice.
51    #[doc(hidden)]
52    pub fn upsert_source_file(
53        &self,
54        path: Arc<str>,
55        text: Arc<str>,
56        durability: salsa::Durability,
57    ) -> crate::db::SourceFile {
58        self.clear_transient_batch_replay();
59        self.db.upsert_source_file(path, text, durability)
60    }
61
62    /// Look up an existing [`crate::db::SourceFile`] handle by path.
63    ///
64    /// **Internal API — exposes Salsa types.** Subject to change without notice.
65    #[doc(hidden)]
66    pub fn lookup_source_file(&self, path: &str) -> Option<crate::db::SourceFile> {
67        self.db.lookup_source_file(path)
68    }
69
70    /// Mark a [`crate::db::SourceFile`] as removed from the workspace.
71    ///
72    /// **Internal API — exposes Salsa types.** Subject to change without notice.
73    #[doc(hidden)]
74    pub fn remove_source_file_input(&self, path: &str) {
75        self.clear_transient_batch_replay();
76        self.db.remove_source_file(path);
77    }
78
79    /// Run `f` with exclusive `&mut` access to the shared salsa db, for a host
80    /// that owns additional salsa ingredients (inputs/tracked fns) on this db
81    /// and needs to create or mutate them. Held under the db write lock, so it
82    /// serialises with all other writers.
83    ///
84    /// **Internal API — exposes Salsa types.** Subject to change without notice.
85    #[doc(hidden)]
86    pub fn with_db_mut<R>(&self, f: impl FnOnce(&mut MirDbStorage) -> R) -> R {
87        self.clear_transient_batch_replay();
88        let mut guard = self.db.salsa.write();
89        f(&mut guard)
90    }
91
92    /// Run `f` with shared access to the canonical (non-snapshot) salsa db,
93    /// under the read lock. For host-owned reads of off-salsa state that must
94    /// observe the live db rather than a clone.
95    ///
96    /// `f` MUST NOT run salsa queries/input reads (tracked fns, `X.field(db)`):
97    /// the shared handle has one `ZalsaLocal` query stack, so doing so races any
98    /// concurrent salsa read on this handle and aborts the process. Use
99    /// [`Self::snapshot_db`] for salsa queries.
100    ///
101    /// **Internal API — exposes Salsa types.** Subject to change without notice.
102    #[doc(hidden)]
103    pub fn with_db_ref<R>(&self, f: impl FnOnce(&MirDbStorage) -> R) -> R {
104        let guard = self.db.salsa.read();
105        f(&guard)
106    }
107
108    /// Replace `file`'s reference postings with `locs` (its complete set from
109    /// a fresh single-file analysis) and mark freshness against `text` and
110    /// `generation` — both captured before the analysis, so a concurrent
111    /// edit or file add leaves the mark stale, which is the safe direction.
112    /// `resolved` follows [`Self::mark_ref_committed`]'s contract.
113    pub(crate) fn commit_file_refs(
114        &self,
115        file: &Arc<str>,
116        text: Option<Arc<str>>,
117        locs: Vec<RefLoc>,
118        generation: u64,
119        resolved: bool,
120    ) {
121        self.clear_transient_batch_replay();
122        {
123            let guard = self.db.salsa.read();
124            guard.set_file_reference_locations(file.as_ref(), locs);
125        }
126        self.clear_dependency_graph_cache();
127        if let Some(text) = text {
128            // No memoized output on the imperative path — the empty weak
129            // handle makes the next re-analysis sweep recommit once (and
130            // record the real memo), which is the safe direction.
131            self.mark_ref_committed(file, &text, None, generation, resolved);
132        }
133    }
134
135    /// Run a closure with read access to a database snapshot.
136    ///
137    /// **Internal API — exposes Salsa types.** Subject to change without
138    /// notice.
139    #[doc(hidden)]
140    pub fn read<R>(&self, f: impl FnOnce(&dyn MirDatabase) -> R) -> R {
141        let db = self.snapshot_db();
142        f(&db)
143    }
144
145    /// definition-collection ingestion. Updates the file's source text in the salsa db,
146    /// runs definition collection, and ingests the resulting stub slice.
147    /// Triggers stub loading on first call. Also updates the cache's reverse-
148    /// dependency graph for `file` so cross-file invalidation stays correct
149    /// across incremental edits — without rebuilding the graph from scratch.
150    ///
151    /// If `file` was previously ingested, its old definitions and reference
152    /// locations are removed first so renames / deletions don't leave stale
153    /// state in the codebase. (Without this, long-running sessions would
154    /// accumulate dead reference-location entries indefinitely.)
155    pub fn ingest_file(&self, file: Arc<str>, source: Arc<str>) {
156        self.ensure_all_stubs();
157        let existing_text = self.snapshot_retry(|db| {
158            db.lookup_source_file(file.as_ref())
159                .map(|sf| sf.text(db as &dyn MirDatabase).clone())
160        });
161        if existing_text
162            .as_ref()
163            .is_some_and(|text| text.as_ref() == source.as_ref())
164            && self
165                .last_ingested_symbols
166                .read()
167                .contains_key(file.as_ref())
168        {
169            return;
170        }
171        self.clear_transient_batch_replay();
172
173        // The symbols this file defined as of its last ingest. Read from the
174        // explicit `last_ingested_symbols` map rather than re-deriving via
175        // `file_defined_symbols` (a salsa query on the `SourceFile` input):
176        // when a host drives the db directly it may have already updated that
177        // input to the new text, which would make a re-derived "old" set equal
178        // the new set and silently drop deletions.
179        let old_symbols: HashSet<Arc<str>> = self
180            .last_ingested_symbols
181            .read()
182            .get(file.as_ref())
183            .cloned()
184            .unwrap_or_default();
185        let old_structural_targets: HashSet<String> = self
186            .last_structural_targets
187            .read()
188            .get(file.as_ref())
189            .cloned()
190            .unwrap_or_default();
191
192        {
193            let mut guard = self.db.salsa.write();
194            guard.remove_file_definitions(file.as_ref());
195        }
196        let file_defs =
197            self.db
198                .collect_and_ingest_file(file.clone(), source.as_ref(), self.php_version);
199        let new_decls = {
200            let sf = self
201                .lookup_source_file(file.as_ref())
202                .expect("collect_and_ingest_file must register SourceFile");
203            crate::db::decls_from_slice(&file_defs.slice, sf)
204        };
205
206        // Derive this file's defined symbols from the `FileDefinitions` just
207        // computed above — do NOT re-read them via a salsa query on the shared
208        // `.salsa.read()` handle. That query (`collect_file_definitions`) borrows
209        // the handle's single `ZalsaLocal` query stack, so two concurrent
210        // `ingest_file` calls doing it would race and abort the process under
211        // debug assertions. Reusing `file_defs` needs no db access at all.
212        let new_symbols: HashSet<Arc<str>> = file_defs.defined_symbols();
213        self.last_ingested_symbols
214            .write()
215            .insert(file.as_ref().to_string(), new_symbols.clone());
216
217        // Symbols removed from this file must be tracked so dependency_graph()
218        // can still produce edges to files referencing the now-gone symbols.
219        let deleted: Vec<Arc<str>> = old_symbols.difference(&new_symbols).cloned().collect();
220        let re_added: Vec<Arc<str>> = new_symbols.difference(&old_symbols).cloned().collect();
221        if !deleted.is_empty() {
222            // A deleted symbol may unshadow a lazy-loadable one (e.g. a vendor
223            // class with the same FQCN); prepared files must re-run warm-up.
224            self.bump_prepare_generation();
225        }
226        if !deleted.is_empty() || !re_added.is_empty() {
227            let mut stale = self.stale_defined_symbols.write();
228            let entry = stale.entry(file.as_ref().to_string()).or_default();
229            for sym in &deleted {
230                entry.insert(sym.clone());
231            }
232            for sym in &re_added {
233                entry.remove(sym);
234            }
235            if entry.is_empty() {
236                stale.remove(file.as_ref());
237            }
238        }
239        if !re_added.is_empty() {
240            // A newly-defined symbol may resolve references other files'
241            // commits left unresolved; advance the workspace generation so
242            // their freshness passes re-verify. New-file registration bumps
243            // on its own — this covers definitions appearing in an
244            // already-registered file (edits, `set_file_text` lazy loads).
245            self.db.salsa.write().bump_workspace_revision();
246        }
247
248        // Structural edges depend only on declaration-shaped data already
249        // committed above, so compute them now and only invalidate the cached
250        // dependency graph when those edges actually changed.
251        let new_structural_targets =
252            self.snapshot_retry(|db| file_outgoing_dependencies(db, file.as_ref(), false));
253        self.last_structural_targets
254            .write()
255            .insert(file.as_ref().to_string(), new_structural_targets.clone());
256        let dependency_graph_changed = old_structural_targets != new_structural_targets
257            || !deleted.is_empty()
258            || !re_added.is_empty();
259        if dependency_graph_changed {
260            self.clear_dependency_graph_cache();
261        }
262
263        self.update_reverse_deps_for(&file);
264        // Evict cached analysis results for files that depend on this one so
265        // that the next re_analyze_file call re-analyses them rather than
266        // replaying a stale cache entry. Mirrors the eviction in
267        // `re_analyze_file` (batch.rs) but applies to the ingest path used by
268        // LSP servers that edit a single file without re-analysing it.
269        if let Some(cache) = self.cache.as_deref() {
270            cache.evict_with_dependents(&[file.to_string()]);
271        }
272        // Only evict cache entries whose resolver-mapped path equals this
273        // file. FQCNs the resolver can't map (psr4 miss) stay cached — no
274        // ingest could change their fate. Avoids the per-keystroke storm
275        // where wholesale clearing forces every unresolved FQCN to re-hit
276        // the resolver on the next FileAnalyzer iteration.
277        self.evict_unresolvable_for_file(&file);
278
279        // If the workspace symbol index singleton has already been built, keep
280        // it consistent with this edit *incrementally*: subtract the file's old
281        // declarations and add its new ones (tier-aware). Body-only edits are a
282        // no-op inside `update_workspace_index_for_file` (name-only
283        // FileDeclarations equality → no singleton write → the HIGH-durability
284        // dep does not invalidate body-analysis memos). Only the rare ambiguous
285        // case (a removed name still declared by another file, where this file
286        // owned the winning entry) falls back to a full O(N) rebuild.
287        {
288            let mut guard = self.db.salsa.write();
289            if guard.workspace_symbol_index_singleton().is_some() {
290                if let Some(sf) = guard.lookup_source_file(file.as_ref()) {
291                    if !guard.update_workspace_index_for_file(sf, new_decls.clone()) {
292                        guard.rebuild_workspace_symbol_index();
293                    }
294                    guard.clear_index_pending(file.as_ref());
295                }
296            }
297        }
298
299        // Keep the inverted indexes in step with the edit. Class edges come
300        // straight from the definitions just collected; reference postings
301        // for the new text are recomputed lazily (analysis has not run yet),
302        // so the file's freshness mark is dropped rather than updated.
303        {
304            let entries = crate::db::subtype_index::entries_from_slice(&file_defs.slice);
305            let guard = self.db.salsa.read();
306            guard.set_file_class_edges(&file, entries);
307        }
308        // Freshness is keyed on the Arc actually stored on the input (the
309        // upsert keeps the prior Arc when content is equal), so read it back.
310        self.mark_defs_committed(&file, &source);
311        // `remove_file_definitions` above cleared the file's postings, so the
312        // freshness mark must drop unconditionally — even for unchanged text —
313        // or a query would trust the now-empty posting lists.
314        self.forget_ref_committed(file.as_ref());
315    }
316
317    /// [`Self::ingest_file`] followed by the file's Phase-1 warm-up
318    /// ([`Self::prepare_file_for_analysis`]): its direct class references are
319    /// resolved and lazy-loaded *now*, at write time, instead of serially at
320    /// the front of the next references / re-analysis read.
321    ///
322    /// This is the host edit-path entry point (rust-analyzer's discipline:
323    /// mutation happens only when text changes; requests are pure reads).
324    /// Lazy loads triggered by the warm-up go through plain
325    /// [`Self::ingest_file`], so faulting in a dependency never cascades into
326    /// preparing *its* dependencies — the load frontier stays one file wide.
327    pub fn ingest_file_prepared(&self, file: Arc<str>, source: Arc<str>) {
328        self.clear_transient_batch_replay();
329        self.ingest_file(file.clone(), source);
330        self.prepare_file_for_analysis(&file);
331    }
332
333    /// Register `source` as the text of `file` in the salsa input layer **without**
334    /// parsing or running definition collection.
335    ///
336    /// This is the LSP-friendly bulk-population entry point: after a workspace
337    /// scan, callers can feed every discovered file's text to the session
338    /// cheaply (an Arc clone plus a HashMap insert per file). Name resolution
339    /// then happens on demand via [`Self::load_class`], which reads
340    /// the file from disk through the configured [`crate::ClassResolver`] and
341    /// runs definition collection lazily when a class FQCN actually needs to resolve.
342    ///
343    /// Contrast with [`Self::ingest_file`], which eagerly parses, runs definition collection,
344    /// and populates the symbol index. Use `ingest_file` for files the user is
345    /// actively editing (where in-memory text diverges from disk); use
346    /// `set_file_text` for files known only through the workspace scan.
347    ///
348    /// Clears the negative cache: a previously-unresolvable FQCN may now
349    /// resolve if its defining file is among the newly-registered set.
350    pub fn set_file_text(&self, file: Arc<str>, source: Arc<str>) {
351        self.clear_transient_batch_replay();
352        {
353            let mut guard = self.db.salsa.write();
354            guard.upsert_source_file(file.clone(), source);
355        }
356        self.clear_dependency_graph_cache();
357        self.evict_unresolvable_for_file(&file);
358    }
359
360    /// Bulk-register vendor / library files with HIGH salsa durability.
361    ///
362    /// HIGH-durability files are not expected to change during the session.
363    /// When a LOW-durability project file is edited, salsa can skip O(N)
364    /// dependency verification for every HIGH-durability file, reducing
365    /// `workspace_symbol_index` re-verification cost to O(project files only).
366    ///
367    /// Definition collection runs lazily on first symbol access; no parsing at call time.
368    pub fn set_vendor_files<I>(&self, files: I)
369    where
370        I: IntoIterator<Item = (Arc<str>, Arc<str>)>,
371    {
372        self.clear_transient_batch_replay();
373        self.clear_dependency_graph_cache();
374        // One revision bump for the batch, not one per registered file. The
375        // scope must close after the guard drops (the flush takes the lock).
376        let _deferred_bumps = self.defer_revision_bumps();
377        let mut guard = self.db.salsa.write();
378        for (file, source) in files {
379            guard.upsert_source_file_with_durability(file, source, salsa::Durability::HIGH);
380        }
381        drop(guard);
382    }
383
384    /// Build or refresh the `WorkspaceSymbolIndexSingleton` from all currently
385    /// registered files.
386    ///
387    /// After this call, `find_class_like`, `find_function`, and
388    /// `find_global_constant` read `singleton.index(db)` — a single
389    /// `Durability::HIGH` tracked dep — instead of recomputing the full
390    /// O(N_files) dep list via `workspace_symbol_index`. On subsequent
391    /// LOW-durability (project-file) body edits the dep short-circuits in O(1).
392    ///
393    /// Call this once after all vendor + stub + project files have been
394    /// ingested (end of workspace warm-up). Also called automatically by
395    /// [`Self::ingest_file`] when a file's declared names change.
396    pub fn rebuild_workspace_symbol_index(&self) {
397        self.db.salsa.write().rebuild_workspace_symbol_index();
398    }
399
400    /// Bulk variant of [`Self::set_file_text`]. Acquires the salsa write lock
401    /// once for the entire batch instead of once per file.
402    ///
403    /// The intended LSP scan loop is:
404    /// ```text
405    /// let files: Vec<_> = walk_workspace()
406    ///     .map(|path| (path, fs::read(&path).unwrap()))
407    ///     .collect();
408    /// session.set_workspace_files(files);
409    /// ```
410    /// After this call, every file's source text is known to salsa. No
411    /// parsing has happened yet — Definition collection runs per file on the first
412    /// `load_class` that needs to consult it.
413    pub fn set_workspace_files<I>(&self, files: I)
414    where
415        I: IntoIterator<Item = (Arc<str>, Arc<str>)>,
416    {
417        self.clear_dependency_graph_cache();
418        // One revision bump for the batch, not one per registered file.
419        let _deferred_bumps = self.defer_revision_bumps();
420        let registered_paths: Vec<Arc<str>> = {
421            let mut guard = self.db.salsa.write();
422            files
423                .into_iter()
424                .map(|(file, source)| {
425                    guard.upsert_source_file(file.clone(), source);
426                    file
427                })
428                .collect()
429        };
430        if !registered_paths.is_empty() && self.resolver.is_some() {
431            self.evict_unresolvable_for_files(&registered_paths);
432        }
433    }
434
435    /// The workspace generation epoch — the rust-analyzer-style "are we up to
436    /// date" counter. Bumped whenever a file is added or removed. A consumer
437    /// records this alongside the diagnostics it publishes for a file; when the
438    /// value later advances (background indexing registered more files), those
439    /// files become candidates for re-analysis + re-publish.
440    pub fn index_generation(&self) -> u64 {
441        self.db.salsa.read().workspace_revision_value()
442    }
443
444    /// The text-write epoch — salsa's own global revision, which bumps on
445    /// every source-file add, edit, or removal (any input write at all),
446    /// unlike [`Self::index_generation`] which only bumps on file
447    /// add/remove/declaration changes (a body-only edit deliberately leaves
448    /// it unchanged, to avoid over-invalidating workspace-enumeration
449    /// queries). A cache of a per-file or per-query *result* — as opposed to
450    /// declaration-shape state — must key on this instead: any text write
451    /// can move or add/remove a reference location within that file, even
452    /// when no declaration changed. See `MirDbStorage::current_revision`'s
453    /// doc comment for why this must be salsa's own revision rather than a
454    /// hand-rolled counter.
455    pub fn text_revision(&self) -> salsa::Revision {
456        self.db.salsa.read().current_revision()
457    }
458
459    /// The combined generation the session's memo caches key on: salsa's
460    /// text revision plus the off-salsa subtype-edge epoch. The epoch covers
461    /// what the revision can't — subtype edges and anonymous-class `impl:`
462    /// postings committed *within* one revision (e.g. a subtype BFS
463    /// admitting a file the reference gate skipped), which change a member
464    /// query's hierarchy fan-out without any text write.
465    pub(crate) fn query_cache_generation(&self) -> (salsa::Revision, u64) {
466        let guard = self.db.salsa.read();
467        (guard.current_revision(), guard.subtype_edges_epoch())
468    }
469
470    /// Index one bounded chunk of `(path, text)` files — the chunked background
471    /// indexing primitive.
472    ///
473    /// For each chunk this: (1) registers the files as `Durability::HIGH` salsa
474    /// inputs in one short write window, (2) parses them to prime the in-process
475    /// and on-disk declaration caches (in parallel when `parallelism ==
476    /// `[`IndexParallelism::Rayon`]; sequentially for wasm / single-thread
477    /// consumers), and (3) merges their declarations into the workspace symbol
478    /// index singleton **incrementally** (no full rebuild) so partially-indexed
479    /// symbols resolve immediately.
480    ///
481    /// The library spawns no thread: the consumer pumps chunks from its own
482    /// driver (LSP worker thread, or one chunk per wasm event-loop tick),
483    /// re-checking higher-priority work between calls. `cancel` is honoured at
484    /// chunk boundaries so an edit can abandon queued indexing cheaply.
485    ///
486    /// **Contract:** index the workspace *incrementally* through this method;
487    /// don't bulk-register the entire file set up front and then index — the
488    /// first call lazily seeds the singleton from the currently-registered set
489    /// (built-in stubs + this chunk), so keeping that initial set small keeps
490    /// the first call cheap. Call [`Self::finalize_index`] once after the last
491    /// chunk to reconcile authoritatively.
492    ///
493    /// **Responsiveness:** parsing / declaration collection happens off the
494    /// salsa write lock (on a snapshot); only the cheap symbol-map merge runs
495    /// under the lock, so the write window per chunk is short and an interactive
496    /// read on another thread blocks at most that long. Note that, per salsa's
497    /// snapshot model, a *cancellable query* in flight on another thread (e.g.
498    /// `hover`, `definition_of`, `FileAnalyzer::analyze_diagnostics_only`) when this batch takes
499    /// the write lock may unwind with `salsa::Cancelled`; a multi-threaded
500    /// consumer should catch that and retry the request (the rust-analyzer
501    /// pattern). A single-threaded consumer that interleaves requests *between*
502    /// `index_batch` calls never observes cancellation.
503    pub fn index_batch(
504        &self,
505        files: &[(Arc<str>, Arc<str>)],
506        parallelism: crate::IndexParallelism,
507        cancel: &crate::IndexCancel,
508    ) -> crate::IndexBatchOutcome {
509        if files.is_empty() || cancel.is_cancelled() {
510            return crate::IndexBatchOutcome {
511                registered: 0,
512                cancelled: cancel.is_cancelled(),
513                generation: self.index_generation(),
514            };
515        }
516        self.ensure_all_stubs();
517        self.clear_dependency_graph_cache();
518
519        // 1. Register the chunk as HIGH-durability inputs — one short write
520        //    window, then release the lock so interactive requests interleave.
521        //    One revision bump for the chunk, not one per new file: each bump
522        //    is an input write that cancels in-flight readers.
523        let sources: Vec<crate::db::SourceFile> = {
524            let _deferred_bumps = self.defer_revision_bumps();
525            let mut guard = self.db.salsa.write();
526            files
527                .iter()
528                .map(|(file, source)| {
529                    guard.upsert_source_file_with_durability(
530                        file.clone(),
531                        source.clone(),
532                        salsa::Durability::HIGH,
533                    )
534                })
535                .collect()
536        };
537        let registered = sources.len();
538
539        if cancel.is_cancelled() {
540            return crate::IndexBatchOutcome {
541                registered,
542                cancelled: true,
543                generation: self.index_generation(),
544            };
545        }
546
547        // Is this the seed chunk (no singleton yet)? If so we must collect decls
548        // for the whole currently-registered set (stubs + this chunk); otherwise
549        // just this chunk.
550        let seed = self
551            .db
552            .salsa
553            .read()
554            .workspace_symbol_index_singleton()
555            .is_none();
556        let snap = self.db.snapshot_db();
557        let to_collect: Vec<crate::db::SourceFile> = if seed {
558            snap.all_source_files()
559        } else {
560            sources.clone()
561        };
562
563        // 2. Collect per-file declarations OFF the write lock (on a snapshot).
564        //    This is where parsing happens — crucially NOT while holding the
565        //    write lock, so concurrent interactive reads are not blocked for the
566        //    parse duration. Also primes the shared parse/disk caches.
567        let collect_one = |db: &crate::db::MirDbStorage, sf: crate::db::SourceFile| {
568            (sf, crate::db::collect_file_declarations(db, sf).clone())
569        };
570        let decls: Vec<(crate::db::SourceFile, crate::db::FileDeclarations)> =
571            if parallelism == crate::IndexParallelism::Rayon {
572                use rayon::prelude::*;
573                to_collect
574                    .par_iter()
575                    .map_with(snap.clone(), |db, &sf| collect_one(db, sf))
576                    .collect()
577            } else {
578                to_collect
579                    .iter()
580                    .map(|&sf| collect_one(&snap, sf))
581                    .collect()
582            };
583        drop(snap);
584
585        if cancel.is_cancelled() {
586            return crate::IndexBatchOutcome {
587                registered,
588                cancelled: true,
589                generation: self.index_generation(),
590            };
591        }
592
593        // 3. Apply to the singleton under a SHORT write window — only cheap map
594        //    construction / merge runs here (no parse).
595        {
596            let mut guard = self.db.salsa.write();
597            if guard.workspace_symbol_index_singleton().is_none() {
598                guard.build_workspace_index_from_decls(decls);
599            } else {
600                guard.merge_precomputed_into_workspace_index(&decls);
601            }
602        }
603
604        crate::IndexBatchOutcome {
605            registered,
606            cancelled: cancel.is_cancelled(),
607            generation: self.index_generation(),
608        }
609    }
610
611    /// Authoritative full rebuild of the workspace symbol index. Call once
612    /// after the consumer has pumped every [`Self::index_batch`] chunk (end of
613    /// warm-up) to reconcile the incrementally-merged index against the full
614    /// registered set. Cheap after indexing — every file's declarations are
615    /// already cached.
616    pub fn finalize_index(&self) {
617        self.db.salsa.write().rebuild_workspace_symbol_index();
618    }
619
620    /// Replay disk-cached reference-location postings and subtype-index class
621    /// edges for `files`, so a returning session's find-references /
622    /// goto-implementation queries are answered from the index immediately
623    /// instead of paying the on-demand analysis sweep the first time each
624    /// file is queried (`indexed_references_to`/`indexed_subtype_classes`'s
625    /// freshness pass already handles a miss correctly — this only shortens
626    /// the common warm-start case).
627    ///
628    /// A no-op (per file) unless the disk cache from a *previous* run has an
629    /// entry whose content hash matches `files`' current text: [`Self::with_cache`]/
630    /// [`Self::with_cache_dir`] must be attached, and each file's reference
631    /// locations ([`AnalysisCache`], populated by the CLI batch pipeline) or
632    /// definitions ([`crate::stub_cache::StubSliceCache`], populated by
633    /// [`Self::ingest_file`]/vendor ingestion) must already be on disk from
634    /// some earlier run/tool invocation against this exact content. A first-
635    /// ever run (nothing cached yet) is unaffected — every file simply falls
636    /// through to the existing lazy on-demand paths, same as without this call.
637    ///
638    /// Registers `files` as `Durability::HIGH` salsa inputs (like
639    /// [`Self::index_batch`]) if not already registered. Safe to call
640    /// alongside `index_batch` in any order; both merge into the same
641    /// maintained indexes.
642    ///
643    /// Returns every file whose replayed postings carry an unresolved name
644    /// (`live_analyzed: false` in `RefCommit` terms) — these are seeded, but
645    /// `indexed_references_to`'s freshness pass can never treat them as
646    /// immune to growth, so the *first* query that touches one of them pays
647    /// a full `analyze_file` synchronously on the request path (measured:
648    /// ~1.3-1.5s on a distinctive static method in a 15K-file workspace).
649    /// The subset is known right here, at warm-start time — a caller with
650    /// idle time between warm-start and the first live query (e.g. an LSP
651    /// server between `indexReady` and the user's first request) can close
652    /// most of that gap by handing this list to
653    /// [`Self::reanalyze_files_cancellable`] on a background thread, the
654    /// same pattern [`Self::prefetch_imports`] uses for lazy-loaded
655    /// vendor FQCNs:
656    ///
657    /// ```ignore
658    /// let unresolved = session.warm_start_files(&files);
659    /// let s = session.clone();
660    /// std::thread::spawn(move || {
661    ///     s.reanalyze_files_cancellable(&unresolved, &crate::IndexCancel::new());
662    /// });
663    /// ```
664    ///
665    /// This only relocates cost that would otherwise land on a user-facing
666    /// query — it cannot eliminate the freshness check itself, since an
667    /// unresolved posting is workspace-generation-sensitive by design.
668    pub fn warm_start_files(&self, files: &[(Arc<str>, Arc<str>)]) -> Vec<Arc<str>> {
669        let Some(cache) = self.cache.clone() else {
670            return Vec::new();
671        };
672        let stub_cache = self.db.stub_cache.clone();
673        let php_v = self.php_version.cache_byte();
674
675        // Register the whole bundled stub set up front. A seeded symbol-index
676        // singleton is only sound when no stub registration arrives after the
677        // seed (`ensure_stubs_for_ast` upserts bypass index maintenance and
678        // would leave the new stubs invisible); with everything registered
679        // here, later lazy stub loads are no-ops. Same contract as
680        // `index_batch`.
681        self.ensure_all_stubs();
682
683        {
684            let mut guard = self.db.salsa.write();
685            for (file, text) in files {
686                guard.upsert_source_file_with_durability(
687                    file.clone(),
688                    text.clone(),
689                    salsa::Durability::HIGH,
690                );
691            }
692        }
693
694        // Generation after registration: replayed postings reflect a *prior*
695        // session's workspace, so any later file/symbol add must re-verify
696        // them (the None-output mark below also disables resolved immunity).
697        let commit_gen = self.index_generation();
698
699        // Phase 1: read the disk-cache slices in parallel, off any lock — the
700        // actual I/O cost of a warm-start replay at scale (~0.8-0.9s of a
701        // 3.9s warm boot at 15.4K files serially; this is the `index_batch`
702        // pattern already used elsewhere in this file). Only cheap map
703        // construction/merge runs in phase 2, under a lock.
704        let snap = self.snapshot_db();
705        let hits: Vec<WarmStartHit> = {
706            use rayon::prelude::*;
707            files
708                .par_iter()
709                .map_with(snap.clone(), |db, (file, _)| {
710                    // Freshness is keyed on the Arc actually stored on the
711                    // input — an upsert against already-registered,
712                    // content-equal text keeps the prior Arc (see
713                    // `ingest_file`), so read back what's really there rather
714                    // than assume identity with the file's own `text`.
715                    let sf = db.lookup_source_file(file.as_ref())?;
716                    let stored_text = sf.text(db).clone();
717
718                    let hex = crate::cache::hash_content(&stored_text);
719                    let refs = cache.get(file, &hex).map(|(issues, ref_locs)| {
720                        let locs: Vec<RefLoc> = ref_locs
721                            .iter()
722                            .map(|(symbol, line, col_start, col_end)| RefLoc {
723                                symbol_key: Arc::clone(symbol),
724                                file: file.clone(),
725                                line: *line,
726                                col_start: *col_start,
727                                col_end: *col_end,
728                            })
729                            .collect();
730                        // Resolved from the cached issue set: a fully-resolved
731                        // replay survives the registrations/lazy loads that
732                        // follow warm-up instead of being invalidated by the
733                        // first generation bump.
734                        let resolved = !crate::db::issues_have_unresolved_names(&issues);
735                        (locs, resolved)
736                    });
737
738                    let stub = stub_cache.as_ref().and_then(|stub_cache| {
739                        let hash = crate::stub_cache::hash_source(&stored_text);
740                        let (mut slice, _issues) = stub_cache.get(file, &hash, php_v)?;
741                        crate::stub_cache::prepare_for_ingest(&mut slice);
742                        let entries = crate::db::subtype_index::entries_from_slice(&slice);
743                        let decls = crate::db::decls_from_slice(&slice, sf);
744                        Some((entries, decls))
745                    });
746
747                    Some(WarmStartHit {
748                        file: file.clone(),
749                        sf,
750                        stored_text,
751                        refs,
752                        stub,
753                    })
754                })
755                .filter_map(|hit| hit)
756                .collect()
757        };
758        drop(snap);
759
760        // Phase 2: apply — only cheap salsa input writes and map merges run
761        // here, under the lock each one already required individually.
762        let mut seed_decls: Vec<(crate::db::SourceFile, crate::db::FileDeclarations)> =
763            Vec::with_capacity(hits.len());
764        let mut unresolved: Vec<Arc<str>> = Vec::new();
765        let mut structural_target_files: Vec<Arc<str>> = Vec::new();
766        let mut dependency_graph_changed = false;
767        {
768            let guard = self.db.salsa.read();
769            for hit in hits {
770                let WarmStartHit {
771                    file,
772                    sf,
773                    stored_text,
774                    refs,
775                    stub,
776                } = hit;
777                if let Some((locs, resolved)) = refs {
778                    guard.set_file_reference_locations(file.as_ref(), locs);
779                    self.mark_ref_committed(&file, &stored_text, None, commit_gen, resolved);
780                    dependency_graph_changed = true;
781                    if !resolved {
782                        unresolved.push(file.clone());
783                    }
784                }
785                if let Some((entries, decls)) = stub {
786                    guard.set_file_class_edges(&file, entries);
787                    self.mark_defs_committed(&file, &stored_text);
788                    structural_target_files.push(file.clone());
789                    dependency_graph_changed = true;
790                    seed_decls.push((sf, decls));
791                }
792            }
793        }
794        if !structural_target_files.is_empty() {
795            let db = self.snapshot_db();
796            let mut structural_targets = self.last_structural_targets.write();
797            for file in &structural_target_files {
798                structural_targets.insert(
799                    file.as_ref().to_string(),
800                    file_outgoing_dependencies(&db, file.as_ref(), false),
801                );
802            }
803        }
804        if dependency_graph_changed {
805            self.clear_dependency_graph_cache();
806        }
807
808        self.seed_workspace_index_from_warm_start(seed_decls);
809        unresolved
810    }
811
812    /// Seed the workspace symbol index singleton from warm-start declaration
813    /// projections, so a returning session's first query answers from an O(1)
814    /// map instead of the tracked O(all-files) `workspace_symbol_index` walk
815    /// (~4s at 15K files: one `collect_file_definitions` slice-deserialization
816    /// per file, re-validated after every prepare-loop revision bump).
817    ///
818    /// `covered` holds decls projected from content-hash-valid disk slices.
819    /// Files without a valid slice (changed since last session, plus any stub
820    /// not yet slice-cached) are collected in parallel — real parses, so the
821    /// seed is skipped entirely when the gap is large (a first-ever boot,
822    /// where the parse bill belongs to the background sweep, not startup).
823    fn seed_workspace_index_from_warm_start(
824        &self,
825        covered: Vec<(crate::db::SourceFile, crate::db::FileDeclarations)>,
826    ) {
827        use rustc_hash::FxHashSet;
828        if covered.is_empty() {
829            return;
830        }
831
832        let snap = self.snapshot_db();
833        let all = snap.all_source_files();
834        let covered_set: FxHashSet<crate::db::SourceFile> =
835            covered.iter().map(|(sf, _)| *sf).collect();
836        let missing: Vec<crate::db::SourceFile> = all
837            .iter()
838            .copied()
839            .filter(|sf| !covered_set.contains(sf))
840            .collect();
841
842        // Gap ceiling: beyond this the "fill" is a workspace-scale parse and
843        // seeding stops being a warm start. 1024 absorbs a large changed set
844        // plus the bundled stubs; the quarter bound keeps tiny workspaces
845        // seedable even when most files changed.
846        let threshold = 1024usize.max(all.len() / 4);
847        if missing.len() > threshold {
848            return;
849        }
850
851        // Parse/collect the gap off the write lock, in parallel, on one
852        // snapshot — the `index_batch` pattern. `collect_file_declarations`
853        // is disk-slice-accelerated itself, so "missing" here often means a
854        // cheap deserialization rather than a parse.
855        let gap_decls: Vec<(crate::db::SourceFile, crate::db::FileDeclarations)> = {
856            use rayon::prelude::*;
857            missing
858                .par_iter()
859                .map_with(snap.clone(), |db, &sf| {
860                    (sf, crate::db::collect_file_declarations(db, sf).clone())
861                })
862                .collect()
863        };
864        drop(snap);
865
866        let mut decls = covered;
867        decls.extend(gap_decls);
868
869        let mut guard = self.db.salsa.write();
870        if guard.workspace_symbol_index_singleton().is_none() {
871            guard.build_workspace_index_from_decls(decls);
872        } else {
873            // Something (e.g. a vendor-eager `index_batch`) seeded first; its
874            // singleton is already maintained incrementally. Merge only files
875            // it hasn't seen — `merge_precomputed_into_workspace_index` skips
876            // files already snapshotted.
877            guard.merge_precomputed_into_workspace_index(&decls);
878        }
879    }
880
881    /// Reconcile the symbol-index singleton with mirror-only text writes.
882    ///
883    /// Plain `upsert_source_file_with_durability` calls (an LSP host
884    /// mirroring watcher-driven external edits or new files) bypass
885    /// `ingest_file`'s incremental index maintenance; those files accumulate
886    /// in a pending set while a singleton exists. Query entry points call
887    /// this first so the singleton is never consulted stale. Declaration
888    /// memos are pre-warmed on a snapshot (parallel, off the write lock);
889    /// the per-file merge under the lock is then a memo hit.
890    pub fn settle_workspace_index(&self) {
891        if self.db.salsa.read().index_pending_is_empty() {
892            return;
893        }
894        let pending = self.db.salsa.read().take_index_pending();
895        if pending.is_empty() {
896            return;
897        }
898        let snap = self.snapshot_db();
899        let sfs: Vec<crate::db::SourceFile> = pending
900            .iter()
901            .filter_map(|p| snap.lookup_source_file(p.as_ref()))
902            .collect();
903        let decls: Vec<(crate::db::SourceFile, crate::db::FileDeclarations)> = sfs
904            .iter()
905            .map(|&sf| (sf, crate::db::collect_file_declarations(&snap, sf).clone()))
906            .collect();
907        // Best-effort pre-warm; a concurrent write may cancel it, in which
908        // case the update below collects under the lock (single file, rare).
909        let _ = salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| {
910            use rayon::prelude::*;
911            sfs.par_iter().for_each_with(snap.clone(), |db, &sf| {
912                let _ = crate::db::collect_file_declarations(db, sf);
913            });
914        }));
915        drop(snap);
916        let mut guard = self.db.salsa.write();
917        // `update_workspace_index_for_file` clones the singleton maps per
918        // call; for a bulk arrival (branch switch) one full rebuild — memo
919        // validations plus a single map build, since the decls were just
920        // pre-warmed above — beats N clones under the write lock.
921        if sfs.len() > 32 {
922            guard.rebuild_workspace_symbol_index();
923            return;
924        }
925        for (sf, decls) in decls {
926            if !guard.update_workspace_index_for_file(sf, decls) {
927                guard.rebuild_workspace_symbol_index();
928            }
929        }
930    }
931
932    /// Drop a file's contribution to the session: codebase definitions,
933    /// reference locations, salsa input handle, cache entry, and outgoing
934    /// reverse-dependency edges. Cache entries of *dependent* files are
935    /// also evicted (cross-file invalidation).
936    ///
937    /// Use this when a file is closed by the consumer, or before a re-ingest
938    /// of substantially changed content. (Plain re-ingest via
939    /// [`Self::ingest_file`] also drops old definitions, but does not
940    /// remove the salsa input handle — call this for full cleanup.)
941    pub fn invalidate_file(&self, file: &str) {
942        self.clear_transient_batch_replay();
943        self.clear_dependency_graph_cache();
944        {
945            let mut guard = self.db.salsa.write();
946            guard.remove_file_definitions(file);
947            guard.remove_source_file(file);
948            guard.clear_file_class_edges(file);
949        }
950        self.forget_ref_committed(file);
951        self.forget_defs_committed(file);
952        // Outgoing structural edges disappear from the derived graph
953        // automatically: the file is no longer in `source_file_paths()`, so
954        // `dependency_graph()` stops iterating it.
955        // Clear stale symbol tracking for this file — it's fully gone.
956        self.stale_defined_symbols.write().remove(file);
957        self.last_ingested_symbols.write().remove(file);
958        self.last_structural_targets.write().remove(file);
959        // Declarations this file provided are gone; other prepared files may
960        // now need their warm-up re-run to lazy-load replacements.
961        self.forget_prepared(file);
962        self.bump_prepare_generation();
963        if let Some(cache) = &self.cache {
964            cache.update_reverse_deps_for_file(file, &HashSet::default());
965            cache.evict_with_dependents(&[file.to_string()]);
966        }
967        // The file is gone; cache entries that previously mapped to it stay
968        // unresolvable until the file (or another with matching symbols) is
969        // ingested again. Selective evict mirrors the ingest path.
970        self.evict_unresolvable_for_file(file);
971        // Vendor files are static in the eager-index model — closing a project
972        // buffer never evicts them (no per-file pinning). Memory is bounded by
973        // the LRU on `collect_file_definitions` and the parse cache instead.
974    }
975
976    /// Number of files currently tracked in this session's salsa input set.
977    /// Stable across reads; useful for diagnostics and memory bounds checks.
978    pub fn tracked_file_count(&self) -> usize {
979        let guard = self.db.salsa.read();
980        guard.source_file_count()
981    }
982
983    // -----------------------------------------------------------------------
984    // Read-only codebase queries
985    //
986    // All take a brief lock to clone the db, then run the lookup against the
987    // owned snapshot — concurrent edits proceed without blocking.
988    // -----------------------------------------------------------------------
989}