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