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