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