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