Skip to main content

mir_analyzer/session/
ingest.rs

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