Skip to main content

mir_analyzer/session/
ingest.rs

1use super::*;
2
3impl AnalysisSession {
4    /// Cheap clone of the salsa db for a read-only query. The lock is held
5    /// only for the duration of the clone, so concurrent readers never
6    /// serialize on each other or on writes for longer than the clone itself.
7    ///
8    /// **Internal API — exposes Salsa types.** Subject to change without
9    /// notice. Public consumers should use the typed query methods
10    /// ([`Self::definition_of`], [`Self::hover`], etc.) instead.
11    #[doc(hidden)]
12    pub fn snapshot_db(&self) -> MirDbStorage {
13        self.db.snapshot_db()
14    }
15
16    /// Register or update a [`crate::db::SourceFile`] salsa input and return its
17    /// handle, without running definition collection or reference recording.
18    ///
19    /// The write-path entry point for a host that drives this db's salsa inputs
20    /// directly (the LSP database-convergence path) and pulls definitions
21    /// lazily via tracked queries, rather than the eager [`Self::ingest_file`].
22    ///
23    /// **Internal API — exposes Salsa types.** Subject to change without notice.
24    #[doc(hidden)]
25    pub fn upsert_source_file(
26        &self,
27        path: Arc<str>,
28        text: Arc<str>,
29        durability: salsa::Durability,
30    ) -> crate::db::SourceFile {
31        self.db.upsert_source_file(path, text, durability)
32    }
33
34    /// Look up an existing [`crate::db::SourceFile`] handle by path.
35    ///
36    /// **Internal API — exposes Salsa types.** Subject to change without notice.
37    #[doc(hidden)]
38    pub fn lookup_source_file(&self, path: &str) -> Option<crate::db::SourceFile> {
39        self.db.lookup_source_file(path)
40    }
41
42    /// Mark a [`crate::db::SourceFile`] as removed from the workspace.
43    ///
44    /// **Internal API — exposes Salsa types.** Subject to change without notice.
45    #[doc(hidden)]
46    pub fn remove_source_file_input(&self, path: &str) {
47        self.db.remove_source_file(path);
48    }
49
50    /// Run `f` with exclusive `&mut` access to the shared salsa db, for a host
51    /// that owns additional salsa ingredients (inputs/tracked fns) on this db
52    /// and needs to create or mutate them. Held under the db write lock, so it
53    /// serialises with all other writers.
54    ///
55    /// **Internal API — exposes Salsa types.** Subject to change without notice.
56    #[doc(hidden)]
57    pub fn with_db_mut<R>(&self, f: impl FnOnce(&mut MirDbStorage) -> R) -> R {
58        let mut guard = self.db.salsa.write();
59        f(&mut guard)
60    }
61
62    /// Run `f` with shared access to the canonical (non-snapshot) salsa db,
63    /// under the read lock. For host-owned reads of off-salsa state that must
64    /// observe the live db rather than a clone.
65    ///
66    /// `f` MUST NOT run salsa queries/input reads (tracked fns, `X.field(db)`):
67    /// the shared handle has one `ZalsaLocal` query stack, so doing so races any
68    /// concurrent salsa read on this handle and aborts the process. Use
69    /// [`Self::snapshot_db`] for salsa queries.
70    ///
71    /// **Internal API — exposes Salsa types.** Subject to change without notice.
72    #[doc(hidden)]
73    pub fn with_db_ref<R>(&self, f: impl FnOnce(&MirDbStorage) -> R) -> R {
74        let guard = self.db.salsa.read();
75        f(&guard)
76    }
77
78    /// Commit a batch of reference locations from a db snapshot into the
79    /// session's shared maps.  Called by [`crate::FileAnalyzer`] and
80    /// [`crate::BatchFileAnalyzer`] after parallel body analysis to flush the pending
81    /// buffers that accumulate in worker db clones.
82    pub(crate) fn commit_ref_locs_batch(&self, locs: Vec<RefLoc>) {
83        if locs.is_empty() {
84            return;
85        }
86        let guard = self.db.salsa.read();
87        guard.commit_reference_locations_batch(locs);
88    }
89
90    /// Replace `file`'s reference postings with `locs` (its complete set from
91    /// a fresh single-file analysis) and mark freshness against `text` and
92    /// `generation` — both captured before the analysis, so a concurrent
93    /// edit or file add leaves the mark stale, which is the safe direction.
94    /// `resolved` follows [`Self::mark_ref_committed`]'s contract.
95    pub(crate) fn commit_file_refs(
96        &self,
97        file: &Arc<str>,
98        text: Option<Arc<str>>,
99        locs: Vec<RefLoc>,
100        generation: u64,
101        resolved: bool,
102    ) {
103        {
104            let guard = self.db.salsa.read();
105            guard.set_file_reference_locations(file.as_ref(), locs);
106        }
107        if let Some(text) = text {
108            // No memoized output on the imperative path — the empty weak
109            // handle makes the next re-analysis sweep recommit once (and
110            // record the real memo), which is the safe direction.
111            self.mark_ref_committed(file, &text, None, generation, resolved);
112        }
113    }
114
115    /// Run a closure with read access to a database snapshot.
116    ///
117    /// **Internal API — exposes Salsa types.** Subject to change without
118    /// notice.
119    #[doc(hidden)]
120    pub fn read<R>(&self, f: impl FnOnce(&dyn MirDatabase) -> R) -> R {
121        let db = self.snapshot_db();
122        f(&db)
123    }
124
125    /// definition-collection ingestion. Updates the file's source text in the salsa db,
126    /// runs definition collection, and ingests the resulting stub slice.
127    /// Triggers stub loading on first call. Also updates the cache's reverse-
128    /// dependency graph for `file` so cross-file invalidation stays correct
129    /// across incremental edits — without rebuilding the graph from scratch.
130    ///
131    /// If `file` was previously ingested, its old definitions and reference
132    /// locations are removed first so renames / deletions don't leave stale
133    /// state in the codebase. (Without this, long-running sessions would
134    /// accumulate dead reference-location entries indefinitely.)
135    pub fn ingest_file(&self, file: Arc<str>, source: Arc<str>) {
136        self.ensure_all_stubs();
137
138        // The symbols this file defined as of its last ingest. Read from the
139        // explicit `last_ingested_symbols` map rather than re-deriving via
140        // `file_defined_symbols` (a salsa query on the `SourceFile` input):
141        // when a host drives the db directly it may have already updated that
142        // input to the new text, which would make a re-derived "old" set equal
143        // the new set and silently drop deletions.
144        let old_symbols: HashSet<Arc<str>> = self
145            .last_ingested_symbols
146            .read()
147            .get(file.as_ref())
148            .cloned()
149            .unwrap_or_default();
150
151        {
152            let mut guard = self.db.salsa.write();
153            guard.remove_file_definitions(file.as_ref());
154        }
155        let file_defs =
156            self.db
157                .collect_and_ingest_file(file.clone(), source.as_ref(), self.php_version);
158
159        // Derive this file's defined symbols from the `FileDefinitions` just
160        // computed above — do NOT re-read them via a salsa query on the shared
161        // `.salsa.read()` handle. That query (`collect_file_definitions`) borrows
162        // the handle's single `ZalsaLocal` query stack, so two concurrent
163        // `ingest_file` calls doing it would race and abort the process under
164        // debug assertions. Reusing `file_defs` needs no db access at all.
165        let new_symbols: HashSet<Arc<str>> = file_defs.defined_symbols();
166        self.last_ingested_symbols
167            .write()
168            .insert(file.as_ref().to_string(), new_symbols.clone());
169
170        // Symbols removed from this file must be tracked so dependency_graph()
171        // can still produce edges to files referencing the now-gone symbols.
172        let deleted: Vec<Arc<str>> = old_symbols.difference(&new_symbols).cloned().collect();
173        let re_added: Vec<Arc<str>> = new_symbols.difference(&old_symbols).cloned().collect();
174        if !deleted.is_empty() {
175            // A deleted symbol may unshadow a lazy-loadable one (e.g. a vendor
176            // class with the same FQCN); prepared files must re-run warm-up.
177            self.bump_prepare_generation();
178        }
179        if !deleted.is_empty() || !re_added.is_empty() {
180            let mut stale = self.stale_defined_symbols.write();
181            let entry = stale.entry(file.as_ref().to_string()).or_default();
182            for sym in deleted {
183                entry.insert(sym);
184            }
185            for sym in &re_added {
186                entry.remove(sym);
187            }
188            if entry.is_empty() {
189                stale.remove(file.as_ref());
190            }
191        }
192        if !re_added.is_empty() {
193            // A newly-defined symbol may resolve references other files'
194            // commits left unresolved; advance the workspace generation so
195            // their freshness passes re-verify. New-file registration bumps
196            // on its own — this covers definitions appearing in an
197            // already-registered file (edits, `set_file_text` lazy loads).
198            self.db.salsa.write().bump_workspace_revision();
199        }
200
201        self.update_reverse_deps_for(&file);
202        // Evict cached analysis results for files that depend on this one so
203        // that the next re_analyze_file call re-analyses them rather than
204        // replaying a stale cache entry. Mirrors the eviction in
205        // `re_analyze_file` (batch.rs) but applies to the ingest path used by
206        // LSP servers that edit a single file without re-analysing it.
207        if let Some(cache) = self.cache.as_deref() {
208            cache.evict_with_dependents(&[file.to_string()]);
209        }
210        // Only evict cache entries whose resolver-mapped path equals this
211        // file. FQCNs the resolver can't map (psr4 miss) stay cached — no
212        // ingest could change their fate. Avoids the per-keystroke storm
213        // where wholesale clearing forces every unresolved FQCN to re-hit
214        // the resolver on the next FileAnalyzer iteration.
215        self.evict_unresolvable_for_file(&file);
216
217        // If the workspace symbol index singleton has already been built, keep
218        // it consistent with this edit *incrementally*: subtract the file's old
219        // declarations and add its new ones (tier-aware). Body-only edits are a
220        // no-op inside `update_workspace_index_for_file` (name-only
221        // FileDeclarations equality → no singleton write → the HIGH-durability
222        // dep does not invalidate body-analysis memos). Only the rare ambiguous
223        // case (a removed name still declared by another file, where this file
224        // owned the winning entry) falls back to a full O(N) rebuild.
225        {
226            let mut guard = self.db.salsa.write();
227            if guard.workspace_symbol_index_singleton().is_some() {
228                if let Some(sf) = guard.lookup_source_file(file.as_ref()) {
229                    if !guard.update_workspace_index_for_file(sf) {
230                        guard.rebuild_workspace_symbol_index();
231                    }
232                }
233            }
234        }
235
236        // Keep the inverted indexes in step with the edit. Class edges come
237        // straight from the definitions just collected; reference postings
238        // for the new text are recomputed lazily (analysis has not run yet),
239        // so the file's freshness mark is dropped rather than updated.
240        {
241            let entries = crate::db::subtype_index::entries_from_slice(&file_defs.slice);
242            let guard = self.db.salsa.read();
243            guard.set_file_class_edges(&file, entries);
244        }
245        // Freshness is keyed on the Arc actually stored on the input (the
246        // upsert keeps the prior Arc when content is equal), so read it back.
247        let stored_text = {
248            let db = self.snapshot_db();
249            db.lookup_source_file(file.as_ref())
250                .map(|sf| sf.text(&db as &dyn MirDatabase))
251        };
252        if let Some(text) = stored_text {
253            self.mark_defs_committed(&file, &text);
254        }
255        // `remove_file_definitions` above cleared the file's postings, so the
256        // freshness mark must drop unconditionally — even for unchanged text —
257        // or a query would trust the now-empty posting lists.
258        self.forget_ref_committed(file.as_ref());
259    }
260
261    /// [`Self::ingest_file`] followed by the file's Phase-1 warm-up
262    /// ([`Self::prepare_file_for_analysis`]): its direct class references are
263    /// resolved and lazy-loaded *now*, at write time, instead of serially at
264    /// the front of the next references / re-analysis read.
265    ///
266    /// This is the host edit-path entry point (rust-analyzer's discipline:
267    /// mutation happens only when text changes; requests are pure reads).
268    /// Lazy loads triggered by the warm-up go through plain
269    /// [`Self::ingest_file`], so faulting in a dependency never cascades into
270    /// preparing *its* dependencies — the load frontier stays one file wide.
271    pub fn ingest_file_prepared(&self, file: Arc<str>, source: Arc<str>) {
272        self.ingest_file(file.clone(), source);
273        self.prepare_file_for_analysis(&file);
274    }
275
276    /// Register `source` as the text of `file` in the salsa input layer **without**
277    /// parsing or running definition collection.
278    ///
279    /// This is the LSP-friendly bulk-population entry point: after a workspace
280    /// scan, callers can feed every discovered file's text to the session
281    /// cheaply (an Arc clone plus a HashMap insert per file). Name resolution
282    /// then happens on demand via [`Self::load_class`], which reads
283    /// the file from disk through the configured [`crate::ClassResolver`] and
284    /// runs definition collection lazily when a class FQCN actually needs to resolve.
285    ///
286    /// Contrast with [`Self::ingest_file`], which eagerly parses, runs definition collection,
287    /// and populates the symbol index. Use `ingest_file` for files the user is
288    /// actively editing (where in-memory text diverges from disk); use
289    /// `set_file_text` for files known only through the workspace scan.
290    ///
291    /// Clears the negative cache: a previously-unresolvable FQCN may now
292    /// resolve if its defining file is among the newly-registered set.
293    pub fn set_file_text(&self, file: Arc<str>, source: Arc<str>) {
294        {
295            let mut guard = self.db.salsa.write();
296            guard.upsert_source_file(file.clone(), source);
297        }
298        self.evict_unresolvable_for_file(&file);
299    }
300
301    /// Bulk-register vendor / library files with HIGH salsa durability.
302    ///
303    /// HIGH-durability files are not expected to change during the session.
304    /// When a LOW-durability project file is edited, salsa can skip O(N)
305    /// dependency verification for every HIGH-durability file, reducing
306    /// `workspace_symbol_index` re-verification cost to O(project files only).
307    ///
308    /// Definition collection runs lazily on first symbol access; no parsing at call time.
309    pub fn set_vendor_files<I>(&self, files: I)
310    where
311        I: IntoIterator<Item = (Arc<str>, Arc<str>)>,
312    {
313        let mut guard = self.db.salsa.write();
314        for (file, source) in files {
315            guard.upsert_source_file_with_durability(file, source, salsa::Durability::HIGH);
316        }
317    }
318
319    /// Build or refresh the `WorkspaceSymbolIndexSingleton` from all currently
320    /// registered files.
321    ///
322    /// After this call, `find_class_like`, `find_function`, and
323    /// `find_global_constant` read `singleton.index(db)` — a single
324    /// `Durability::HIGH` tracked dep — instead of recomputing the full
325    /// O(N_files) dep list via `workspace_symbol_index`. On subsequent
326    /// LOW-durability (project-file) body edits the dep short-circuits in O(1).
327    ///
328    /// Call this once after all vendor + stub + project files have been
329    /// ingested (end of workspace warm-up). Also called automatically by
330    /// [`Self::ingest_file`] when a file's declared names change.
331    pub fn rebuild_workspace_symbol_index(&self) {
332        self.db.salsa.write().rebuild_workspace_symbol_index();
333    }
334
335    /// Bulk variant of [`Self::set_file_text`]. Acquires the salsa write lock
336    /// once for the entire batch instead of once per file.
337    ///
338    /// The intended LSP scan loop is:
339    /// ```text
340    /// let files: Vec<_> = walk_workspace()
341    ///     .map(|path| (path, fs::read(&path).unwrap()))
342    ///     .collect();
343    /// session.set_workspace_files(files);
344    /// ```
345    /// After this call, every file's source text is known to salsa. No
346    /// parsing has happened yet — Definition collection runs per file on the first
347    /// `load_class` that needs to consult it.
348    pub fn set_workspace_files<I>(&self, files: I)
349    where
350        I: IntoIterator<Item = (Arc<str>, Arc<str>)>,
351    {
352        let registered_paths: Vec<Arc<str>> = {
353            let mut guard = self.db.salsa.write();
354            files
355                .into_iter()
356                .map(|(file, source)| {
357                    guard.upsert_source_file(file.clone(), source);
358                    file
359                })
360                .collect()
361        };
362        if !registered_paths.is_empty() && self.resolver.is_some() {
363            self.evict_unresolvable_for_files(&registered_paths);
364        }
365    }
366
367    /// The workspace generation epoch — the rust-analyzer-style "are we up to
368    /// date" counter. Bumped whenever a file is added or removed. A consumer
369    /// records this alongside the diagnostics it publishes for a file; when the
370    /// value later advances (background indexing registered more files), those
371    /// files become candidates for re-analysis + re-publish.
372    pub fn index_generation(&self) -> u64 {
373        self.db.salsa.read().workspace_revision_value()
374    }
375
376    /// Index one bounded chunk of `(path, text)` files — the chunked background
377    /// indexing primitive.
378    ///
379    /// For each chunk this: (1) registers the files as `Durability::HIGH` salsa
380    /// inputs in one short write window, (2) parses them to prime the in-process
381    /// and on-disk declaration caches (in parallel when `parallelism ==
382    /// `[`IndexParallelism::Rayon`]; sequentially for wasm / single-thread
383    /// consumers), and (3) merges their declarations into the workspace symbol
384    /// index singleton **incrementally** (no full rebuild) so partially-indexed
385    /// symbols resolve immediately.
386    ///
387    /// The library spawns no thread: the consumer pumps chunks from its own
388    /// driver (LSP worker thread, or one chunk per wasm event-loop tick),
389    /// re-checking higher-priority work between calls. `cancel` is honoured at
390    /// chunk boundaries so an edit can abandon queued indexing cheaply.
391    ///
392    /// **Contract:** index the workspace *incrementally* through this method;
393    /// don't bulk-register the entire file set up front and then index — the
394    /// first call lazily seeds the singleton from the currently-registered set
395    /// (built-in stubs + this chunk), so keeping that initial set small keeps
396    /// the first call cheap. Call [`Self::finalize_index`] once after the last
397    /// chunk to reconcile authoritatively.
398    ///
399    /// **Responsiveness:** parsing / declaration collection happens off the
400    /// salsa write lock (on a snapshot); only the cheap symbol-map merge runs
401    /// under the lock, so the write window per chunk is short and an interactive
402    /// read on another thread blocks at most that long. Note that, per salsa's
403    /// snapshot model, a *cancellable query* in flight on another thread (e.g.
404    /// `hover`, `definition_of`, `FileAnalyzer::analyze`) when this batch takes
405    /// the write lock may unwind with `salsa::Cancelled`; a multi-threaded
406    /// consumer should catch that and retry the request (the rust-analyzer
407    /// pattern). A single-threaded consumer that interleaves requests *between*
408    /// `index_batch` calls never observes cancellation.
409    pub fn index_batch(
410        &self,
411        files: &[(Arc<str>, Arc<str>)],
412        parallelism: crate::IndexParallelism,
413        cancel: &crate::IndexCancel,
414    ) -> crate::IndexBatchOutcome {
415        if files.is_empty() || cancel.is_cancelled() {
416            return crate::IndexBatchOutcome {
417                registered: 0,
418                cancelled: cancel.is_cancelled(),
419                generation: self.index_generation(),
420            };
421        }
422        self.ensure_all_stubs();
423
424        // 1. Register the chunk as HIGH-durability inputs — one short write
425        //    window, then release the lock so interactive requests interleave.
426        let sources: Vec<crate::db::SourceFile> = {
427            let mut guard = self.db.salsa.write();
428            files
429                .iter()
430                .map(|(file, source)| {
431                    guard.upsert_source_file_with_durability(
432                        file.clone(),
433                        source.clone(),
434                        salsa::Durability::HIGH,
435                    )
436                })
437                .collect()
438        };
439        let registered = sources.len();
440
441        if cancel.is_cancelled() {
442            return crate::IndexBatchOutcome {
443                registered,
444                cancelled: true,
445                generation: self.index_generation(),
446            };
447        }
448
449        // Is this the seed chunk (no singleton yet)? If so we must collect decls
450        // for the whole currently-registered set (stubs + this chunk); otherwise
451        // just this chunk.
452        let seed = self
453            .db
454            .salsa
455            .read()
456            .workspace_symbol_index_singleton()
457            .is_none();
458        let snap = self.db.snapshot_db();
459        let to_collect: Vec<crate::db::SourceFile> = if seed {
460            snap.all_source_files()
461        } else {
462            sources.clone()
463        };
464
465        // 2. Collect per-file declarations OFF the write lock (on a snapshot).
466        //    This is where parsing happens — crucially NOT while holding the
467        //    write lock, so concurrent interactive reads are not blocked for the
468        //    parse duration. Also primes the shared parse/disk caches.
469        let collect_one = |db: &crate::db::MirDbStorage, sf: crate::db::SourceFile| {
470            (sf, crate::db::collect_file_declarations(db, sf))
471        };
472        let decls: Vec<(crate::db::SourceFile, crate::db::FileDeclarations)> =
473            if parallelism == crate::IndexParallelism::Rayon {
474                use rayon::prelude::*;
475                to_collect
476                    .par_iter()
477                    .map_with(snap.clone(), |db, &sf| collect_one(db, sf))
478                    .collect()
479            } else {
480                to_collect
481                    .iter()
482                    .map(|&sf| collect_one(&snap, sf))
483                    .collect()
484            };
485        drop(snap);
486
487        if cancel.is_cancelled() {
488            return crate::IndexBatchOutcome {
489                registered,
490                cancelled: true,
491                generation: self.index_generation(),
492            };
493        }
494
495        // 3. Apply to the singleton under a SHORT write window — only cheap map
496        //    construction / merge runs here (no parse).
497        {
498            let mut guard = self.db.salsa.write();
499            if guard.workspace_symbol_index_singleton().is_none() {
500                guard.build_workspace_index_from_decls(decls);
501            } else {
502                guard.merge_precomputed_into_workspace_index(&decls);
503            }
504        }
505
506        crate::IndexBatchOutcome {
507            registered,
508            cancelled: cancel.is_cancelled(),
509            generation: self.index_generation(),
510        }
511    }
512
513    /// Authoritative full rebuild of the workspace symbol index. Call once
514    /// after the consumer has pumped every [`Self::index_batch`] chunk (end of
515    /// warm-up) to reconcile the incrementally-merged index against the full
516    /// registered set. Cheap after indexing — every file's declarations are
517    /// already cached.
518    pub fn finalize_index(&self) {
519        self.db.salsa.write().rebuild_workspace_symbol_index();
520    }
521
522    /// Replay disk-cached reference-location postings and subtype-index class
523    /// edges for `files`, so a returning session's find-references /
524    /// goto-implementation queries are answered from the index immediately
525    /// instead of paying the on-demand analysis sweep the first time each
526    /// file is queried (`indexed_references_to`/`indexed_subtype_classes`'s
527    /// freshness pass already handles a miss correctly — this only shortens
528    /// the common warm-start case).
529    ///
530    /// A no-op (per file) unless the disk cache from a *previous* run has an
531    /// entry whose content hash matches `files`' current text: [`Self::with_cache`]/
532    /// [`Self::with_cache_dir`] must be attached, and each file's reference
533    /// locations ([`AnalysisCache`], populated by the CLI batch pipeline) or
534    /// definitions ([`crate::stub_cache::StubSliceCache`], populated by
535    /// [`Self::ingest_file`]/vendor ingestion) must already be on disk from
536    /// some earlier run/tool invocation against this exact content. A first-
537    /// ever run (nothing cached yet) is unaffected — every file simply falls
538    /// through to the existing lazy on-demand paths, same as without this call.
539    ///
540    /// Registers `files` as `Durability::HIGH` salsa inputs (like
541    /// [`Self::index_batch`]) if not already registered. Safe to call
542    /// alongside `index_batch` in any order; both merge into the same
543    /// maintained indexes.
544    pub fn warm_start_files(&self, files: &[(Arc<str>, Arc<str>)]) {
545        let Some(cache) = self.cache.clone() else {
546            return;
547        };
548        let stub_cache = self.db.stub_cache.clone();
549        let php_v = self.php_version.cache_byte();
550
551        {
552            let mut guard = self.db.salsa.write();
553            for (file, text) in files {
554                guard.upsert_source_file_with_durability(
555                    file.clone(),
556                    text.clone(),
557                    salsa::Durability::HIGH,
558                );
559            }
560        }
561
562        // Generation after registration: replayed postings reflect a *prior*
563        // session's workspace, so any later file/symbol add must re-verify
564        // them (the None-output mark below also disables resolved immunity).
565        let commit_gen = self.index_generation();
566
567        for (file, _) in files {
568            // Freshness is keyed on the Arc actually stored on the input — an
569            // upsert against already-registered, content-equal text keeps the
570            // prior Arc (see `ingest_file`), so read back what's really there
571            // rather than assume identity with the `text` passed in above.
572            let stored_text = {
573                let db = self.snapshot_db();
574                db.lookup_source_file(file.as_ref())
575                    .map(|sf| sf.text(&db as &dyn MirDatabase))
576            };
577            let Some(stored_text) = stored_text else {
578                continue;
579            };
580
581            let hex = crate::cache::hash_content(&stored_text);
582            if let Some((issues, ref_locs)) = cache.get(file, &hex) {
583                let locs: Vec<RefLoc> = ref_locs
584                    .iter()
585                    .map(|(symbol, line, col_start, col_end)| RefLoc {
586                        symbol_key: Arc::clone(symbol),
587                        file: file.clone(),
588                        line: *line,
589                        col_start: *col_start,
590                        col_end: *col_end,
591                    })
592                    .collect();
593                // Resolved from the cached issue set: a fully-resolved replay
594                // survives the registrations/lazy loads that follow warm-up
595                // instead of being invalidated by the first generation bump.
596                let resolved = !crate::db::issues_have_unresolved_names(&issues);
597                self.commit_file_refs(file, Some(stored_text.clone()), locs, commit_gen, resolved);
598            }
599
600            if let Some(stub_cache) = &stub_cache {
601                let hash = crate::stub_cache::hash_source(&stored_text);
602                if let Some(mut slice) = stub_cache.get(file, &hash, php_v) {
603                    crate::stub_cache::prepare_for_ingest(&mut slice);
604                    let entries = crate::db::subtype_index::entries_from_slice(&slice);
605                    {
606                        let guard = self.db.salsa.read();
607                        guard.set_file_class_edges(file, entries);
608                    }
609                    self.mark_defs_committed(file, &stored_text);
610                }
611            }
612        }
613    }
614
615    /// Drop a file's contribution to the session: codebase definitions,
616    /// reference locations, salsa input handle, cache entry, and outgoing
617    /// reverse-dependency edges. Cache entries of *dependent* files are
618    /// also evicted (cross-file invalidation).
619    ///
620    /// Use this when a file is closed by the consumer, or before a re-ingest
621    /// of substantially changed content. (Plain re-ingest via
622    /// [`Self::ingest_file`] also drops old definitions, but does not
623    /// remove the salsa input handle — call this for full cleanup.)
624    pub fn invalidate_file(&self, file: &str) {
625        {
626            let mut guard = self.db.salsa.write();
627            guard.remove_file_definitions(file);
628            guard.remove_source_file(file);
629            guard.clear_file_class_edges(file);
630        }
631        self.forget_ref_committed(file);
632        self.forget_defs_committed(file);
633        // Outgoing structural edges disappear from the derived graph
634        // automatically: the file is no longer in `source_file_paths()`, so
635        // `dependency_graph()` stops iterating it.
636        // Clear stale symbol tracking for this file — it's fully gone.
637        self.stale_defined_symbols.write().remove(file);
638        self.last_ingested_symbols.write().remove(file);
639        // Declarations this file provided are gone; other prepared files may
640        // now need their warm-up re-run to lazy-load replacements.
641        self.forget_prepared(file);
642        self.bump_prepare_generation();
643        if let Some(cache) = &self.cache {
644            cache.update_reverse_deps_for_file(file, &HashSet::default());
645            cache.evict_with_dependents(&[file.to_string()]);
646        }
647        // The file is gone; cache entries that previously mapped to it stay
648        // unresolvable until the file (or another with matching symbols) is
649        // ingested again. Selective evict mirrors the ingest path.
650        self.evict_unresolvable_for_file(file);
651        // Vendor files are static in the eager-index model — closing a project
652        // buffer never evicts them (no per-file pinning). Memory is bounded by
653        // the LRU on `collect_file_definitions` and the parse cache instead.
654    }
655
656    /// Number of files currently tracked in this session's salsa input set.
657    /// Stable across reads; useful for diagnostics and memory bounds checks.
658    pub fn tracked_file_count(&self) -> usize {
659        let guard = self.db.salsa.read();
660        guard.source_file_count()
661    }
662
663    // -----------------------------------------------------------------------
664    // Read-only codebase queries
665    //
666    // All take a brief lock to clone the db, then run the lookup against the
667    // owned snapshot — concurrent edits proceed without blocking.
668    // -----------------------------------------------------------------------
669}