mir_analyzer/session/ingest.rs
1use super::*;
2
3impl AnalysisSession {
4 /// Cheap clone of the salsa db for a read-only query. The lock is held
5 /// only for the duration of the clone, so concurrent readers never
6 /// serialize on each other or on writes for longer than the clone itself.
7 ///
8 /// **Internal API — exposes Salsa types.** Subject to change without
9 /// notice. Public consumers should use the typed query methods
10 /// ([`Self::definition_of`], [`Self::hover`], etc.) instead.
11 #[doc(hidden)]
12 pub fn snapshot_db(&self) -> MirDbStorage {
13 self.db.snapshot_db()
14 }
15
16 /// Register or update a [`crate::db::SourceFile`] salsa input and return its
17 /// handle, without running definition collection or reference recording.
18 ///
19 /// The write-path entry point for a host that drives this db's salsa inputs
20 /// directly (the LSP database-convergence path) and pulls definitions
21 /// lazily via tracked queries, rather than the eager [`Self::ingest_file`].
22 ///
23 /// **Internal API — exposes Salsa types.** Subject to change without notice.
24 #[doc(hidden)]
25 pub fn upsert_source_file(
26 &self,
27 path: Arc<str>,
28 text: Arc<str>,
29 durability: salsa::Durability,
30 ) -> crate::db::SourceFile {
31 self.db.upsert_source_file(path, text, durability)
32 }
33
34 /// Look up an existing [`crate::db::SourceFile`] handle by path.
35 ///
36 /// **Internal API — exposes Salsa types.** Subject to change without notice.
37 #[doc(hidden)]
38 pub fn lookup_source_file(&self, path: &str) -> Option<crate::db::SourceFile> {
39 self.db.lookup_source_file(path)
40 }
41
42 /// Mark a [`crate::db::SourceFile`] as removed from the workspace.
43 ///
44 /// **Internal API — exposes Salsa types.** Subject to change without notice.
45 #[doc(hidden)]
46 pub fn remove_source_file_input(&self, path: &str) {
47 self.db.remove_source_file(path);
48 }
49
50 /// Run `f` with exclusive `&mut` access to the shared salsa db, for a host
51 /// that owns additional salsa ingredients (inputs/tracked fns) on this db
52 /// and needs to create or mutate them. Held under the db write lock, so it
53 /// serialises with all other writers.
54 ///
55 /// **Internal API — exposes Salsa types.** Subject to change without notice.
56 #[doc(hidden)]
57 pub fn with_db_mut<R>(&self, f: impl FnOnce(&mut MirDbStorage) -> R) -> R {
58 let mut guard = self.db.salsa.write();
59 f(&mut guard)
60 }
61
62 /// Run `f` with shared access to the canonical (non-snapshot) salsa db,
63 /// under the read lock. For host-owned read-only queries that must observe
64 /// the live db rather than a clone.
65 ///
66 /// **Internal API — exposes Salsa types.** Subject to change without notice.
67 #[doc(hidden)]
68 pub fn with_db_ref<R>(&self, f: impl FnOnce(&MirDbStorage) -> R) -> R {
69 let guard = self.db.salsa.read();
70 f(&guard)
71 }
72
73 /// Commit a batch of reference locations from a db snapshot into the
74 /// session's shared maps. Called by [`crate::FileAnalyzer`] and
75 /// [`crate::BatchFileAnalyzer`] after parallel body analysis to flush the pending
76 /// buffers that accumulate in worker db clones.
77 pub(crate) fn commit_ref_locs_batch(&self, locs: Vec<RefLoc>) {
78 if locs.is_empty() {
79 return;
80 }
81 let guard = self.db.salsa.read();
82 guard.commit_reference_locations_batch(locs);
83 }
84
85 /// Run a closure with read access to a database snapshot.
86 ///
87 /// **Internal API — exposes Salsa types.** Subject to change without
88 /// notice.
89 #[doc(hidden)]
90 pub fn read<R>(&self, f: impl FnOnce(&dyn MirDatabase) -> R) -> R {
91 let db = self.snapshot_db();
92 f(&db)
93 }
94
95 /// definition-collection ingestion. Updates the file's source text in the salsa db,
96 /// runs definition collection, and ingests the resulting stub slice.
97 /// Triggers stub loading on first call. Also updates the cache's reverse-
98 /// dependency graph for `file` so cross-file invalidation stays correct
99 /// across incremental edits — without rebuilding the graph from scratch.
100 ///
101 /// If `file` was previously ingested, its old definitions and reference
102 /// locations are removed first so renames / deletions don't leave stale
103 /// state in the codebase. (Without this, long-running sessions would
104 /// accumulate dead reference-location entries indefinitely.)
105 pub fn ingest_file(&self, file: Arc<str>, source: Arc<str>) {
106 self.ensure_all_stubs();
107
108 // The symbols this file defined as of its last ingest. Read from the
109 // explicit `last_ingested_symbols` map rather than re-deriving via
110 // `file_defined_symbols` (a salsa query on the `SourceFile` input):
111 // when a host drives the db directly it may have already updated that
112 // input to the new text, which would make a re-derived "old" set equal
113 // the new set and silently drop deletions.
114 let old_symbols: HashSet<Arc<str>> = self
115 .last_ingested_symbols
116 .read()
117 .get(file.as_ref())
118 .cloned()
119 .unwrap_or_default();
120
121 {
122 let mut guard = self.db.salsa.write();
123 guard.remove_file_definitions(file.as_ref());
124 }
125 let _file_defs =
126 self.db
127 .collect_and_ingest_file(file.clone(), source.as_ref(), self.php_version);
128
129 // Snapshot symbols after ingesting — O(symbols_in_file).
130 let new_symbols: HashSet<Arc<str>> = {
131 let guard = self.db.salsa.read();
132 guard.file_defined_symbols(file.as_ref())
133 };
134 self.last_ingested_symbols
135 .write()
136 .insert(file.as_ref().to_string(), new_symbols.clone());
137
138 // Symbols removed from this file must be tracked so dependency_graph()
139 // can still produce edges to files referencing the now-gone symbols.
140 let deleted: Vec<Arc<str>> = old_symbols.difference(&new_symbols).cloned().collect();
141 let re_added: Vec<Arc<str>> = new_symbols.difference(&old_symbols).cloned().collect();
142 if !deleted.is_empty() {
143 // A deleted symbol may unshadow a lazy-loadable one (e.g. a vendor
144 // class with the same FQCN); prepared files must re-run warm-up.
145 self.bump_prepare_generation();
146 }
147 if !deleted.is_empty() || !re_added.is_empty() {
148 let mut stale = self.stale_defined_symbols.write();
149 let entry = stale.entry(file.as_ref().to_string()).or_default();
150 for sym in deleted {
151 entry.insert(sym);
152 }
153 for sym in &re_added {
154 entry.remove(sym);
155 }
156 if entry.is_empty() {
157 stale.remove(file.as_ref());
158 }
159 }
160
161 self.update_reverse_deps_for(&file);
162 // Evict cached analysis results for files that depend on this one so
163 // that the next re_analyze_file call re-analyses them rather than
164 // replaying a stale cache entry. Mirrors the eviction in
165 // `re_analyze_file` (batch.rs) but applies to the ingest path used by
166 // LSP servers that edit a single file without re-analysing it.
167 if let Some(cache) = self.cache.as_deref() {
168 cache.evict_with_dependents(&[file.to_string()]);
169 }
170 // Only evict cache entries whose resolver-mapped path equals this
171 // file. FQCNs the resolver can't map (psr4 miss) stay cached — no
172 // ingest could change their fate. Avoids the per-keystroke storm
173 // where wholesale clearing forces every unresolved FQCN to re-hit
174 // the resolver on the next FileAnalyzer iteration.
175 self.evict_unresolvable_for_file(&file);
176
177 // If the workspace symbol index singleton has already been built, keep
178 // it consistent with this edit *incrementally*: subtract the file's old
179 // declarations and add its new ones (tier-aware). Body-only edits are a
180 // no-op inside `update_workspace_index_for_file` (name-only
181 // FileDeclarations equality → no singleton write → the HIGH-durability
182 // dep does not invalidate body-analysis memos). Only the rare ambiguous
183 // case (a removed name still declared by another file, where this file
184 // owned the winning entry) falls back to a full O(N) rebuild.
185 {
186 let mut guard = self.db.salsa.write();
187 if guard.workspace_symbol_index_singleton().is_some() {
188 if let Some(sf) = guard.lookup_source_file(file.as_ref()) {
189 if !guard.update_workspace_index_for_file(sf) {
190 guard.rebuild_workspace_symbol_index();
191 }
192 }
193 }
194 }
195 }
196
197 /// Register `source` as the text of `file` in the salsa input layer **without**
198 /// parsing or running definition collection.
199 ///
200 /// This is the LSP-friendly bulk-population entry point: after a workspace
201 /// scan, callers can feed every discovered file's text to the session
202 /// cheaply (an Arc clone plus a HashMap insert per file). Name resolution
203 /// then happens on demand via [`Self::load_class`], which reads
204 /// the file from disk through the configured [`crate::ClassResolver`] and
205 /// runs definition collection lazily when a class FQCN actually needs to resolve.
206 ///
207 /// Contrast with [`Self::ingest_file`], which eagerly parses, runs definition collection,
208 /// and populates the symbol index. Use `ingest_file` for files the user is
209 /// actively editing (where in-memory text diverges from disk); use
210 /// `set_file_text` for files known only through the workspace scan.
211 ///
212 /// Clears the negative cache: a previously-unresolvable FQCN may now
213 /// resolve if its defining file is among the newly-registered set.
214 pub fn set_file_text(&self, file: Arc<str>, source: Arc<str>) {
215 {
216 let mut guard = self.db.salsa.write();
217 guard.upsert_source_file(file.clone(), source);
218 }
219 self.evict_unresolvable_for_file(&file);
220 }
221
222 /// Bulk-register vendor / library files with HIGH salsa durability.
223 ///
224 /// HIGH-durability files are not expected to change during the session.
225 /// When a LOW-durability project file is edited, salsa can skip O(N)
226 /// dependency verification for every HIGH-durability file, reducing
227 /// `workspace_symbol_index` re-verification cost to O(project files only).
228 ///
229 /// Definition collection runs lazily on first symbol access; no parsing at call time.
230 pub fn set_vendor_files<I>(&self, files: I)
231 where
232 I: IntoIterator<Item = (Arc<str>, Arc<str>)>,
233 {
234 let mut guard = self.db.salsa.write();
235 for (file, source) in files {
236 guard.upsert_source_file_with_durability(file, source, salsa::Durability::HIGH);
237 }
238 }
239
240 /// Build or refresh the `WorkspaceSymbolIndexSingleton` from all currently
241 /// registered files.
242 ///
243 /// After this call, `find_class_like`, `find_function`, and
244 /// `find_global_constant` read `singleton.index(db)` — a single
245 /// `Durability::HIGH` tracked dep — instead of recomputing the full
246 /// O(N_files) dep list via `workspace_symbol_index`. On subsequent
247 /// LOW-durability (project-file) body edits the dep short-circuits in O(1).
248 ///
249 /// Call this once after all vendor + stub + project files have been
250 /// ingested (end of workspace warm-up). Also called automatically by
251 /// [`Self::ingest_file`] when a file's declared names change.
252 pub fn rebuild_workspace_symbol_index(&self) {
253 self.db.salsa.write().rebuild_workspace_symbol_index();
254 }
255
256 /// Bulk variant of [`Self::set_file_text`]. Acquires the salsa write lock
257 /// once for the entire batch instead of once per file.
258 ///
259 /// The intended LSP scan loop is:
260 /// ```text
261 /// let files: Vec<_> = walk_workspace()
262 /// .map(|path| (path, fs::read(&path).unwrap()))
263 /// .collect();
264 /// session.set_workspace_files(files);
265 /// ```
266 /// After this call, every file's source text is known to salsa. No
267 /// parsing has happened yet — Definition collection runs per file on the first
268 /// `load_class` that needs to consult it.
269 pub fn set_workspace_files<I>(&self, files: I)
270 where
271 I: IntoIterator<Item = (Arc<str>, Arc<str>)>,
272 {
273 let registered_paths: Vec<Arc<str>> = {
274 let mut guard = self.db.salsa.write();
275 files
276 .into_iter()
277 .map(|(file, source)| {
278 guard.upsert_source_file(file.clone(), source);
279 file
280 })
281 .collect()
282 };
283 if !registered_paths.is_empty() && self.resolver.is_some() {
284 self.evict_unresolvable_for_files(®istered_paths);
285 }
286 }
287
288 /// The workspace generation epoch — the rust-analyzer-style "are we up to
289 /// date" counter. Bumped whenever a file is added or removed. A consumer
290 /// records this alongside the diagnostics it publishes for a file; when the
291 /// value later advances (background indexing registered more files), those
292 /// files become candidates for re-analysis + re-publish.
293 pub fn index_generation(&self) -> u64 {
294 self.db.salsa.read().workspace_revision_value()
295 }
296
297 /// Index one bounded chunk of `(path, text)` files — the chunked background
298 /// indexing primitive.
299 ///
300 /// For each chunk this: (1) registers the files as `Durability::HIGH` salsa
301 /// inputs in one short write window, (2) parses them to prime the in-process
302 /// and on-disk declaration caches (in parallel when `parallelism ==
303 /// `[`IndexParallelism::Rayon`]; sequentially for wasm / single-thread
304 /// consumers), and (3) merges their declarations into the workspace symbol
305 /// index singleton **incrementally** (no full rebuild) so partially-indexed
306 /// symbols resolve immediately.
307 ///
308 /// The library spawns no thread: the consumer pumps chunks from its own
309 /// driver (LSP worker thread, or one chunk per wasm event-loop tick),
310 /// re-checking higher-priority work between calls. `cancel` is honoured at
311 /// chunk boundaries so an edit can abandon queued indexing cheaply.
312 ///
313 /// **Contract:** index the workspace *incrementally* through this method;
314 /// don't bulk-register the entire file set up front and then index — the
315 /// first call lazily seeds the singleton from the currently-registered set
316 /// (built-in stubs + this chunk), so keeping that initial set small keeps
317 /// the first call cheap. Call [`Self::finalize_index`] once after the last
318 /// chunk to reconcile authoritatively.
319 ///
320 /// **Responsiveness:** parsing / declaration collection happens off the
321 /// salsa write lock (on a snapshot); only the cheap symbol-map merge runs
322 /// under the lock, so the write window per chunk is short and an interactive
323 /// read on another thread blocks at most that long. Note that, per salsa's
324 /// snapshot model, a *cancellable query* in flight on another thread (e.g.
325 /// `hover`, `definition_of`, `FileAnalyzer::analyze`) when this batch takes
326 /// the write lock may unwind with `salsa::Cancelled`; a multi-threaded
327 /// consumer should catch that and retry the request (the rust-analyzer
328 /// pattern). A single-threaded consumer that interleaves requests *between*
329 /// `index_batch` calls never observes cancellation.
330 pub fn index_batch(
331 &self,
332 files: &[(Arc<str>, Arc<str>)],
333 parallelism: crate::IndexParallelism,
334 cancel: &crate::IndexCancel,
335 ) -> crate::IndexBatchOutcome {
336 if files.is_empty() || cancel.is_cancelled() {
337 return crate::IndexBatchOutcome {
338 registered: 0,
339 cancelled: cancel.is_cancelled(),
340 generation: self.index_generation(),
341 };
342 }
343 self.ensure_all_stubs();
344
345 // 1. Register the chunk as HIGH-durability inputs — one short write
346 // window, then release the lock so interactive requests interleave.
347 let sources: Vec<crate::db::SourceFile> = {
348 let mut guard = self.db.salsa.write();
349 files
350 .iter()
351 .map(|(file, source)| {
352 guard.upsert_source_file_with_durability(
353 file.clone(),
354 source.clone(),
355 salsa::Durability::HIGH,
356 )
357 })
358 .collect()
359 };
360 let registered = sources.len();
361
362 if cancel.is_cancelled() {
363 return crate::IndexBatchOutcome {
364 registered,
365 cancelled: true,
366 generation: self.index_generation(),
367 };
368 }
369
370 // Is this the seed chunk (no singleton yet)? If so we must collect decls
371 // for the whole currently-registered set (stubs + this chunk); otherwise
372 // just this chunk.
373 let seed = self
374 .db
375 .salsa
376 .read()
377 .workspace_symbol_index_singleton()
378 .is_none();
379 let snap = self.db.snapshot_db();
380 let to_collect: Vec<crate::db::SourceFile> = if seed {
381 snap.all_source_files()
382 } else {
383 sources.clone()
384 };
385
386 // 2. Collect per-file declarations OFF the write lock (on a snapshot).
387 // This is where parsing happens — crucially NOT while holding the
388 // write lock, so concurrent interactive reads are not blocked for the
389 // parse duration. Also primes the shared parse/disk caches.
390 let collect_one = |db: &crate::db::MirDbStorage, sf: crate::db::SourceFile| {
391 (sf, crate::db::collect_file_declarations(db, sf))
392 };
393 let decls: Vec<(crate::db::SourceFile, crate::db::FileDeclarations)> =
394 if parallelism == crate::IndexParallelism::Rayon {
395 use rayon::prelude::*;
396 to_collect
397 .par_iter()
398 .map_with(snap.clone(), |db, &sf| collect_one(db, sf))
399 .collect()
400 } else {
401 to_collect
402 .iter()
403 .map(|&sf| collect_one(&snap, sf))
404 .collect()
405 };
406 drop(snap);
407
408 if cancel.is_cancelled() {
409 return crate::IndexBatchOutcome {
410 registered,
411 cancelled: true,
412 generation: self.index_generation(),
413 };
414 }
415
416 // 3. Apply to the singleton under a SHORT write window — only cheap map
417 // construction / merge runs here (no parse).
418 {
419 let mut guard = self.db.salsa.write();
420 if guard.workspace_symbol_index_singleton().is_none() {
421 guard.build_workspace_index_from_decls(decls);
422 } else {
423 guard.merge_precomputed_into_workspace_index(&decls);
424 }
425 }
426
427 crate::IndexBatchOutcome {
428 registered,
429 cancelled: cancel.is_cancelled(),
430 generation: self.index_generation(),
431 }
432 }
433
434 /// Authoritative full rebuild of the workspace symbol index. Call once
435 /// after the consumer has pumped every [`Self::index_batch`] chunk (end of
436 /// warm-up) to reconcile the incrementally-merged index against the full
437 /// registered set. Cheap after indexing — every file's declarations are
438 /// already cached.
439 pub fn finalize_index(&self) {
440 self.db.salsa.write().rebuild_workspace_symbol_index();
441 }
442
443 /// Drop a file's contribution to the session: codebase definitions,
444 /// reference locations, salsa input handle, cache entry, and outgoing
445 /// reverse-dependency edges. Cache entries of *dependent* files are
446 /// also evicted (cross-file invalidation).
447 ///
448 /// Use this when a file is closed by the consumer, or before a re-ingest
449 /// of substantially changed content. (Plain re-ingest via
450 /// [`Self::ingest_file`] also drops old definitions, but does not
451 /// remove the salsa input handle — call this for full cleanup.)
452 pub fn invalidate_file(&self, file: &str) {
453 {
454 let mut guard = self.db.salsa.write();
455 guard.remove_file_definitions(file);
456 guard.remove_source_file(file);
457 }
458 // Outgoing structural edges disappear from the derived graph
459 // automatically: the file is no longer in `source_file_paths()`, so
460 // `dependency_graph()` stops iterating it.
461 // Clear stale symbol tracking for this file — it's fully gone.
462 self.stale_defined_symbols.write().remove(file);
463 self.last_ingested_symbols.write().remove(file);
464 // Declarations this file provided are gone; other prepared files may
465 // now need their warm-up re-run to lazy-load replacements.
466 self.forget_prepared(file);
467 self.bump_prepare_generation();
468 if let Some(cache) = &self.cache {
469 cache.update_reverse_deps_for_file(file, &HashSet::default());
470 cache.evict_with_dependents(&[file.to_string()]);
471 }
472 // The file is gone; cache entries that previously mapped to it stay
473 // unresolvable until the file (or another with matching symbols) is
474 // ingested again. Selective evict mirrors the ingest path.
475 self.evict_unresolvable_for_file(file);
476 // Vendor files are static in the eager-index model — closing a project
477 // buffer never evicts them (no per-file pinning). Memory is bounded by
478 // the LRU on `collect_file_definitions` and the parse cache instead.
479 }
480
481 /// Number of files currently tracked in this session's salsa input set.
482 /// Stable across reads; useful for diagnostics and memory bounds checks.
483 pub fn tracked_file_count(&self) -> usize {
484 let guard = self.db.salsa.read();
485 guard.source_file_count()
486 }
487
488 // -----------------------------------------------------------------------
489 // Read-only codebase queries
490 //
491 // All take a brief lock to clone the db, then run the lookup against the
492 // owned snapshot — concurrent edits proceed without blocking.
493 // -----------------------------------------------------------------------
494}