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