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