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