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