Skip to main content

mir_analyzer/session/
ingest.rs

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