Skip to main content

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.locked_ref_index().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        self.index.mark_defs_committed(&file, &stored_text);
311    }
312
313    /// [`Self::ingest_file`] followed by the file's Phase-1 warm-up
314    /// ([`Self::prepare_file_for_analysis`]): its direct class references are
315    /// resolved and lazy-loaded *now*, at write time, instead of serially at
316    /// the front of the next references / re-analysis read.
317    ///
318    /// The host edit-path entry point: mutation happens only when text
319    /// changes; requests are pure reads.
320    /// Lazy loads triggered by the warm-up go through plain
321    /// [`Self::ingest_file`], so faulting in a dependency never cascades into
322    /// preparing *its* dependencies — the load frontier stays one file wide.
323    pub fn ingest_file_prepared(&mut self, file: Arc<str>, source: Arc<str>) {
324        self.index.clear_transient_batch_replay();
325        self.ingest_file(file.clone(), source);
326        self.prepare_file_for_analysis(&file);
327    }
328
329    /// Register `source` as the text of `file` in the salsa input layer **without**
330    /// parsing or running definition collection.
331    ///
332    /// This is the LSP-friendly bulk-population entry point: after a workspace
333    /// scan, callers can feed every discovered file's text to the session
334    /// cheaply (an Arc clone plus a HashMap insert per file). Name resolution
335    /// then happens on demand via [`Self::load_class`], which reads
336    /// the file from disk through the configured [`crate::ClassResolver`] and
337    /// runs definition collection lazily when a class FQCN actually needs to resolve.
338    ///
339    /// Contrast with [`Self::ingest_file`], which eagerly parses, runs definition collection,
340    /// and populates the symbol index. Use `ingest_file` for files the user is
341    /// actively editing (where in-memory text diverges from disk); use
342    /// `set_file_text` for files known only through the workspace scan.
343    ///
344    /// Clears the negative cache: a previously-unresolvable FQCN may now
345    /// resolve if its defining file is among the newly-registered set.
346    pub fn set_file_text(&mut self, file: Arc<str>, source: Arc<str>) {
347        self.index.clear_transient_batch_replay();
348        let (changed, was_registered, index_was_initialized) = {
349            let db = &mut self.db.salsa;
350            let index_was_initialized = db.workspace_symbol_index_singleton().is_some();
351            let existing = db.lookup_source_file(file.as_ref());
352            let changed = existing.is_none_or(|sf| sf.text(db).as_ref() != source.as_ref());
353            db.upsert_source_file(file.clone(), source.clone());
354            (changed, existing.is_some(), index_was_initialized)
355        };
356        self.index.clear_dependency_graph_cache();
357        // Before the workspace index singleton exists, mirror-only writes
358        // cannot be queued for `settle_workspace_index`. A newly registered
359        // file can nevertheless make an unresolved name in any cached
360        // analysis resolvable, so invalidate those results immediately. Once
361        // the singleton exists, reconciliation coalesces this invalidation
362        // across the pending batch.
363        if changed {
364            if let Some(cache) = self.cache.as_deref() {
365                let invalidate_path = was_registered || !cache.matches_content(&file, &source);
366                if invalidate_path {
367                    cache.evict_files_and_dependents(&[file.to_string()]);
368                }
369                if !index_was_initialized {
370                    cache.evict_unresolved();
371                }
372            }
373        }
374        self.evict_unresolvable_for_file(&file);
375    }
376
377    /// Bulk-register vendor / library files with HIGH salsa durability.
378    ///
379    /// HIGH-durability files are not expected to change during the session.
380    /// When a LOW-durability project file is edited, salsa can skip O(N)
381    /// dependency verification for every HIGH-durability file, reducing
382    /// `workspace_symbol_index` re-verification cost to O(project files only).
383    ///
384    /// Definition collection runs lazily on first symbol access; no parsing at call time.
385    pub fn set_vendor_files<I>(&mut self, files: I)
386    where
387        I: IntoIterator<Item = (Arc<str>, Arc<str>)>,
388    {
389        self.index.clear_transient_batch_replay();
390        self.index.clear_dependency_graph_cache();
391        // One revision bump for the batch, not one per registered file.
392        let mut session = self.defer_revision_bumps();
393        let db = &mut session.db.salsa;
394        let index_was_initialized = db.workspace_symbol_index_singleton().is_some();
395        let mut changed_inputs = Vec::new();
396        for (file, source) in files {
397            let existing = db.lookup_source_file(file.as_ref());
398            if existing.is_none_or(|sf| sf.text(db).as_ref() != source.as_ref()) {
399                changed_inputs.push((file.clone(), source.clone(), existing.is_some()));
400            }
401            db.upsert_source_file_with_durability(file, source, salsa::Durability::HIGH);
402        }
403        if let Some(cache) = session.cache.as_deref() {
404            let paths: Vec<String> = changed_inputs
405                .iter()
406                .filter(|(file, source, existed)| *existed || !cache.matches_content(file, source))
407                .map(|(file, _, _)| file.to_string())
408                .collect();
409            cache.evict_files_and_dependents(&paths);
410            if !index_was_initialized && !changed_inputs.is_empty() {
411                cache.evict_unresolved();
412            }
413        }
414    }
415
416    /// Build or refresh the `WorkspaceSymbolIndexSingleton` from all currently
417    /// registered files.
418    ///
419    /// After this call, `find_class_like`, `find_function`, and
420    /// `find_global_constant` read `singleton.index(db)` — a single
421    /// `Durability::HIGH` tracked dep — instead of recomputing the full
422    /// O(N_files) dep list via `workspace_symbol_index`. On subsequent
423    /// LOW-durability (project-file) body edits the dep short-circuits in O(1).
424    ///
425    /// Call this once after all vendor + stub + project files have been
426    /// ingested (end of workspace warm-up). Also called automatically by
427    /// [`Self::ingest_file`] when a file's declared names change.
428    pub fn rebuild_workspace_symbol_index(&mut self) {
429        self.db.salsa.rebuild_workspace_symbol_index();
430    }
431
432    /// Bulk variant of [`Self::set_file_text`], with one revision bump for the
433    /// whole batch.
434    ///
435    /// The intended LSP scan loop is:
436    /// ```text
437    /// let files: Vec<_> = walk_workspace()
438    ///     .map(|path| (path, fs::read(&path).unwrap()))
439    ///     .collect();
440    /// session.set_workspace_files(files);
441    /// ```
442    /// After this call, every file's source text is known to salsa. No
443    /// parsing has happened yet — Definition collection runs per file on the first
444    /// `load_class` that needs to consult it.
445    pub fn set_workspace_files<I>(&mut self, files: I)
446    where
447        I: IntoIterator<Item = (Arc<str>, Arc<str>)>,
448    {
449        self.index.clear_dependency_graph_cache();
450        // One revision bump for the batch, not one per registered file.
451        let mut session = self.defer_revision_bumps();
452        let index_was_initialized = session.workspace_symbol_index_ready();
453        let (registered_paths, changed_inputs): (Vec<Arc<str>>, Vec<ChangedInput>) = {
454            let db = &mut session.db.salsa;
455            let mut registered = Vec::new();
456            let mut changed = Vec::new();
457            for (file, source) in files {
458                let existing = db.lookup_source_file(file.as_ref());
459                if existing.is_none_or(|sf| sf.text(db).as_ref() != source.as_ref()) {
460                    changed.push((file.clone(), source.clone(), existing.is_some()));
461                }
462                db.upsert_source_file(file.clone(), source);
463                registered.push(file);
464            }
465            (registered, changed)
466        };
467        if let Some(cache) = session.cache.as_deref() {
468            let paths: Vec<String> = changed_inputs
469                .iter()
470                .filter(|(file, source, existed)| *existed || !cache.matches_content(file, source))
471                .map(|(file, _, _)| file.to_string())
472                .collect();
473            cache.evict_files_and_dependents(&paths);
474            if !index_was_initialized && !changed_inputs.is_empty() {
475                cache.evict_unresolved();
476            }
477        }
478        if !registered_paths.is_empty() && session.resolver.is_some() {
479            session.evict_unresolvable_for_files(&registered_paths);
480        }
481    }
482
483    /// The workspace generation epoch ("are we up to date" counter). Bumped whenever a file is added or removed. A consumer
484    /// records this alongside the diagnostics it publishes for a file; when the
485    /// value later advances (background indexing registered more files), those
486    /// files become candidates for re-analysis + re-publish.
487    pub fn index_generation(&self) -> u64 {
488        self.db.salsa.workspace_revision_value()
489    }
490
491    /// The text-write epoch — salsa's own global revision, which bumps on
492    /// every source-file add, edit, or removal (any input write at all),
493    /// unlike [`Self::index_generation`] which only bumps on file
494    /// add/remove/declaration changes (a body-only edit deliberately leaves
495    /// it unchanged, to avoid over-invalidating workspace-enumeration
496    /// queries). A cache of a per-file or per-query *result* — as opposed to
497    /// declaration-shape state — must key on this instead: any text write
498    /// can move or add/remove a reference location within that file, even
499    /// when no declaration changed. See `MirDbStorage::current_revision`'s
500    /// doc comment for why this must be salsa's own revision rather than a
501    /// hand-rolled counter.
502    pub fn text_revision(&self) -> salsa::Revision {
503        self.db.salsa.current_revision()
504    }
505
506    /// Index one bounded chunk of `(path, text)` files — the chunked background
507    /// indexing primitive.
508    ///
509    /// For each chunk this: (1) registers the files as `Durability::HIGH` salsa
510    /// inputs in one short write window, (2) parses them to prime the in-process
511    /// and on-disk declaration caches (in parallel for
512    /// [`crate::IndexParallelism::Rayon`]; sequentially for wasm /
513    /// single-thread consumers), and (3) merges their declarations into the
514    /// workspace symbol index singleton **incrementally** (no full rebuild) so
515    /// partially-indexed symbols resolve immediately.
516    ///
517    /// The library spawns no thread: the consumer pumps chunks from its own
518    /// driver (LSP worker thread, or one chunk per wasm event-loop tick),
519    /// re-checking higher-priority work between calls. `cancel` is honoured at
520    /// chunk boundaries so an edit can abandon queued indexing cheaply.
521    ///
522    /// **Contract:** index the workspace *incrementally* through this method;
523    /// don't bulk-register the entire file set up front and then index — the
524    /// first call lazily seeds the singleton from the currently-registered set
525    /// (built-in stubs + this chunk), so keeping that initial set small keeps
526    /// the first call cheap. Call [`Self::finalize_index`] once after the last
527    /// chunk to reconcile authoritatively.
528    ///
529    /// **Responsiveness:** parsing runs on a snapshot; only the cheap
530    /// symbol-map merge writes salsa. Queries running on other threads'
531    /// snapshots during that write may unwind with `salsa::Cancelled` and
532    /// should be retried.
533    pub fn index_batch(
534        &mut self,
535        files: &[(Arc<str>, Arc<str>)],
536        parallelism: crate::IndexParallelism,
537        cancel: &crate::IndexCancel,
538    ) -> crate::IndexBatchOutcome {
539        if files.is_empty() || cancel.is_cancelled() {
540            return crate::IndexBatchOutcome {
541                registered: 0,
542                cancelled: cancel.is_cancelled(),
543                generation: self.index_generation(),
544            };
545        }
546        self.ensure_all_stubs();
547        self.index.clear_dependency_graph_cache();
548
549        // 1. Register the chunk as HIGH-durability inputs, with one revision
550        //    bump for the chunk rather than one per new file.
551        let (sources, changed_inputs, had_index): IndexedSources = {
552            let mut session = self.defer_revision_bumps();
553            let db = &mut session.db.salsa;
554            let had_index = db.workspace_symbol_index_singleton().is_some();
555            let mut changed_inputs = Vec::new();
556            let sources = files
557                .iter()
558                .map(|(file, source)| {
559                    let existing = db.lookup_source_file(file.as_ref());
560                    if existing.is_none_or(|sf| sf.text(db).as_ref() != source.as_ref()) {
561                        changed_inputs.push((file.clone(), source.clone(), existing.is_some()));
562                    }
563                    db.upsert_source_file_with_durability(
564                        file.clone(),
565                        source.clone(),
566                        salsa::Durability::HIGH,
567                    )
568                })
569                .collect();
570            (sources, changed_inputs, had_index)
571        };
572        let registered = sources.len();
573        if let Some(cache) = self.cache.as_deref() {
574            let paths: Vec<String> = changed_inputs
575                .iter()
576                .filter(|(file, source, existed)| *existed || !cache.matches_content(file, source))
577                .map(|(file, _, _)| file.to_string())
578                .collect();
579            cache.evict_files_and_dependents(&paths);
580            if !had_index && !changed_inputs.is_empty() {
581                cache.evict_unresolved();
582            }
583        }
584
585        if cancel.is_cancelled() {
586            return crate::IndexBatchOutcome {
587                registered,
588                cancelled: true,
589                generation: self.index_generation(),
590            };
591        }
592
593        // Is this the seed chunk (no singleton yet)? If so we must collect decls
594        // for the whole currently-registered set (stubs + this chunk); otherwise
595        // just this chunk.
596        let seed = self.db.salsa.workspace_symbol_index_singleton().is_none();
597        let view = self.db_view();
598        let snap = view.db();
599        let to_collect: Vec<crate::db::SourceFile> = if seed {
600            snap.all_source_files()
601        } else {
602            sources.clone()
603        };
604
605        // 2. Collect per-file declarations on a snapshot (this is where parsing
606        //    happens); also primes the shared parse/disk caches.
607        let collect_one = |db: &crate::db::MirDbStorage, sf: crate::db::SourceFile| {
608            (sf, crate::db::collect_file_declarations(db, sf).clone())
609        };
610        let decls: Vec<(crate::db::SourceFile, crate::db::FileDeclarations)> =
611            if parallelism == crate::IndexParallelism::Rayon {
612                use rayon::prelude::*;
613                to_collect
614                    .par_iter()
615                    .map_with(snap.clone(), |db, &sf| collect_one(db, sf))
616                    .collect()
617            } else {
618                to_collect.iter().map(|&sf| collect_one(snap, sf)).collect()
619            };
620        drop(view);
621
622        if cancel.is_cancelled() {
623            return crate::IndexBatchOutcome {
624                registered,
625                cancelled: true,
626                generation: self.index_generation(),
627            };
628        }
629
630        // 3. Apply to the singleton under a SHORT write window — only cheap map
631        //    construction / merge runs here (no parse).
632        let (declarations_changed, displaced_owners) = {
633            let db = &mut self.db.salsa;
634            let declarations_changed = decls
635                .iter()
636                .any(|(sf, decls)| !db.file_declarations_match(*sf, decls));
637            let old_index = if declarations_changed && self.cache.is_some() {
638                db.workspace_symbol_index_singleton()
639                    .map(|singleton| singleton.index(db).clone())
640            } else {
641                None
642            };
643            let candidates = old_index.as_ref().map(|_| decls.clone());
644            if db.workspace_symbol_index_singleton().is_none() {
645                db.build_workspace_index_from_decls(decls);
646            } else {
647                db.merge_precomputed_into_workspace_index(&decls);
648            }
649            let displaced_owners = old_index
650                .zip(candidates)
651                .map(|(old, candidates)| displaced_cache_owners(db, &old, &candidates))
652                .unwrap_or_default();
653            (declarations_changed, displaced_owners)
654        };
655        if let Some(cache) = self.cache.as_deref() {
656            cache.evict_with_dependents(&displaced_owners);
657            if declarations_changed {
658                cache.evict_unresolved();
659            }
660        }
661
662        crate::IndexBatchOutcome {
663            registered,
664            cancelled: cancel.is_cancelled(),
665            generation: self.index_generation(),
666        }
667    }
668
669    /// Authoritative full rebuild of the workspace symbol index. Call once
670    /// after the consumer has pumped every [`Self::index_batch`] chunk (end of
671    /// warm-up) to reconcile the incrementally-merged index against the full
672    /// registered set. Cheap after indexing — every file's declarations are
673    /// already cached.
674    pub fn finalize_index(&mut self) {
675        self.db.salsa.rebuild_workspace_symbol_index();
676    }
677
678    /// Replay disk-cached reference-location postings and subtype-index class
679    /// edges for `files`, so a returning session's find-references /
680    /// goto-implementation queries are answered from the index immediately
681    /// instead of paying the on-demand analysis sweep the first time each
682    /// file is queried (`indexed_references_to`/`indexed_subtype_classes`'s
683    /// freshness pass already handles a miss correctly — this only shortens
684    /// the common warm-start case).
685    ///
686    /// A no-op (per file) unless the disk cache from a *previous* run has an
687    /// entry whose content hash matches `files`' current text: [`Self::with_cache`]/
688    /// [`Self::with_cache_dir`] must be attached, and each file's reference
689    /// locations ([`AnalysisCache`], populated by the CLI batch pipeline) or
690    /// definitions ([`crate::stub_cache::StubSliceCache`], populated by
691    /// [`Self::ingest_file`]/vendor ingestion) must already be on disk from
692    /// some earlier run/tool invocation against this exact content. A first-
693    /// ever run (nothing cached yet) is unaffected — every file simply falls
694    /// through to the existing lazy on-demand paths, same as without this call.
695    ///
696    /// Registers `files` as `Durability::HIGH` salsa inputs (like
697    /// [`Self::index_batch`]) if not already registered. Safe to call
698    /// alongside `index_batch` in any order; both merge into the same
699    /// maintained indexes.
700    ///
701    /// Returns every file whose replayed postings carry an unresolved name
702    /// (`live_analyzed: false` in `RefCommit` terms) — these are seeded, but
703    /// `indexed_references_to`'s freshness pass can never treat them as
704    /// immune to growth, so the *first* query that touches one of them pays
705    /// a full `analyze_file` synchronously on the request path (measured:
706    /// ~1.3-1.5s on a distinctive static method in a 15K-file workspace).
707    /// The subset is known right here, at warm-start time — a caller with
708    /// idle time between warm-start and the first live query (e.g. an LSP
709    /// server between `indexReady` and the user's first request) can close
710    /// most of that gap by handing this list to
711    /// [`Self::reanalyze_files_cancellable`] on a background thread, the
712    /// same pattern [`Self::prefetch_imports`] uses for lazy-loaded
713    /// vendor FQCNs:
714    ///
715    /// ```ignore
716    /// let unresolved = session.warm_start_files(&files);
717    /// let s = session.clone();
718    /// std::thread::spawn(move || {
719    ///     s.reanalyze_files_cancellable(&unresolved, &crate::IndexCancel::new());
720    /// });
721    /// ```
722    ///
723    /// This only relocates cost that would otherwise land on a user-facing
724    /// query — it cannot eliminate the freshness check itself, since an
725    /// unresolved posting is workspace-generation-sensitive by design.
726    pub fn warm_start_files(&mut self, files: &[(Arc<str>, Arc<str>)]) -> Vec<Arc<str>> {
727        let Some(cache) = self.cache.clone() else {
728            return Vec::new();
729        };
730        let stub_cache = self.db.stub_cache.clone();
731        let php_v = self.php_version.cache_byte();
732
733        // Register the whole bundled stub set up front. A seeded symbol-index
734        // singleton is only sound when no stub registration arrives after the
735        // seed (`ensure_stubs_for_ast` upserts bypass index maintenance and
736        // would leave the new stubs invisible); with everything registered
737        // here, later lazy stub loads are no-ops. Same contract as
738        // `index_batch`.
739        self.ensure_all_stubs();
740
741        {
742            let db = &mut self.db.salsa;
743            for (file, text) in files {
744                db.upsert_source_file_with_durability(
745                    file.clone(),
746                    text.clone(),
747                    salsa::Durability::HIGH,
748                );
749            }
750        }
751
752        // Generation after registration: replayed postings reflect a *prior*
753        // session's workspace, so any later file/symbol add must re-verify
754        // them (the None-output mark below also disables resolved immunity).
755        let commit_gen = self.index_generation();
756
757        // Phase 1: read the disk-cache slices in parallel — the actual I/O
758        // cost of a warm-start replay at scale (~0.8-0.9s of a 3.9s warm boot
759        // at 15.4K files serially; this is the `index_batch` pattern already
760        // used elsewhere in this file). Only cheap map construction/merge
761        // runs in phase 2.
762        let view = self.db_view();
763        let hits: Vec<WarmStartHit> = {
764            use rayon::prelude::*;
765            files
766                .par_iter()
767                .map_with(view.db().clone(), |db, (file, _)| {
768                    // Freshness is keyed on the Arc actually stored on the
769                    // input — an upsert against already-registered,
770                    // content-equal text keeps the prior Arc (see
771                    // `ingest_file`), so read back what's really there rather
772                    // than assume identity with the file's own `text`.
773                    let sf = db.lookup_source_file(file.as_ref())?;
774                    let stored_text = sf.text(db).clone();
775
776                    let hex = crate::cache::hash_content(&stored_text);
777                    let refs = cache.get(file, &hex).map(|(issues, ref_locs)| {
778                        let locs: Vec<RefLoc> = ref_locs
779                            .iter()
780                            .map(|(symbol, line, col_start, col_end)| RefLoc {
781                                symbol_key: Arc::clone(symbol),
782                                file: file.clone(),
783                                line: *line,
784                                col_start: *col_start,
785                                col_end: *col_end,
786                            })
787                            .collect();
788                        // Resolved from the cached issue set: a fully-resolved
789                        // replay survives the registrations/lazy loads that
790                        // follow warm-up instead of being invalidated by the
791                        // first generation bump.
792                        let resolved = !crate::db::issues_have_unresolved_names(&issues);
793                        (locs, resolved)
794                    });
795
796                    let stub = stub_cache.as_ref().and_then(|stub_cache| {
797                        let hash = crate::stub_cache::hash_source(&stored_text);
798                        let (mut slice, _issues) = stub_cache.get(file, &hash, php_v)?;
799                        crate::stub_cache::prepare_for_ingest(&mut slice);
800                        let entries = crate::db::subtype_index::entries_from_slice(&slice);
801                        let decls = crate::db::decls_from_slice(&slice, sf);
802                        Some((entries, decls))
803                    });
804
805                    Some(WarmStartHit {
806                        file: file.clone(),
807                        sf,
808                        stored_text,
809                        refs,
810                        stub,
811                    })
812                })
813                .filter_map(|hit| hit)
814                .collect()
815        };
816        drop(view);
817
818        // Phase 2: apply — only cheap salsa input writes and map merges.
819        let mut seed_decls: Vec<(crate::db::SourceFile, crate::db::FileDeclarations)> =
820            Vec::with_capacity(hits.len());
821        let mut unresolved: Vec<Arc<str>> = Vec::new();
822        let mut structural_target_files: Vec<Arc<str>> = Vec::new();
823        let mut dependency_graph_changed = false;
824        {
825            let db = &self.db.salsa;
826            for hit in hits {
827                let WarmStartHit {
828                    file,
829                    sf,
830                    stored_text,
831                    refs,
832                    stub,
833                } = hit;
834                if let Some((locs, resolved)) = refs {
835                    let file_no = db.locked_ref_index().intern_path(&file);
836                    db.set_file_reference_locations(file_no, locs);
837                    self.index
838                        .mark_ref_committed(&file, &stored_text, None, commit_gen, resolved);
839                    dependency_graph_changed = true;
840                    if !resolved {
841                        unresolved.push(file.clone());
842                    }
843                }
844                if let Some((entries, decls)) = stub {
845                    let file_no = db.locked_ref_index().intern_path(&file);
846                    db.set_file_class_edges(file_no, entries);
847                    self.index.mark_defs_committed(&file, &stored_text);
848                    structural_target_files.push(file.clone());
849                    dependency_graph_changed = true;
850                    seed_decls.push((sf, decls));
851                }
852            }
853        }
854        for file in &structural_target_files {
855            let targets = file_outgoing_dependencies(&self.db.salsa, file.as_ref(), false);
856            self.last_structural_targets
857                .insert(file.as_ref().to_string(), targets);
858        }
859        if dependency_graph_changed {
860            self.index.clear_dependency_graph_cache();
861        }
862
863        self.seed_workspace_index_from_warm_start(seed_decls);
864        unresolved
865    }
866
867    /// Seed the workspace symbol index singleton from warm-start declaration
868    /// projections, so a returning session's first query answers from an O(1)
869    /// map instead of the tracked O(all-files) `workspace_symbol_index` walk
870    /// (~4s at 15K files: one `collect_file_definitions` slice-deserialization
871    /// per file, re-validated after every prepare-loop revision bump).
872    ///
873    /// `covered` holds decls projected from content-hash-valid disk slices.
874    /// Files without a valid slice (changed since last session, plus any stub
875    /// not yet slice-cached) are collected in parallel — real parses, so the
876    /// seed is skipped entirely when the gap is large (a first-ever boot,
877    /// where the parse bill belongs to the background sweep, not startup).
878    fn seed_workspace_index_from_warm_start(
879        &mut self,
880        covered: Vec<(crate::db::SourceFile, crate::db::FileDeclarations)>,
881    ) {
882        use rustc_hash::FxHashSet;
883        if covered.is_empty() {
884            return;
885        }
886
887        let view = self.db_view();
888        let snap = view.db();
889        let all = snap.all_source_files();
890        let covered_set: FxHashSet<crate::db::SourceFile> =
891            covered.iter().map(|(sf, _)| *sf).collect();
892        let missing: Vec<crate::db::SourceFile> = all
893            .iter()
894            .copied()
895            .filter(|sf| !covered_set.contains(sf))
896            .collect();
897
898        // Gap ceiling: beyond this the "fill" is a workspace-scale parse and
899        // seeding stops being a warm start. 1024 absorbs a large changed set
900        // plus the bundled stubs; the quarter bound keeps tiny workspaces
901        // seedable even when most files changed.
902        let threshold = 1024usize.max(all.len() / 4);
903        if missing.len() > threshold {
904            return;
905        }
906
907        // Parse/collect the gap in parallel on one snapshot, as `index_batch`
908        // does. `collect_file_declarations`
909        // is disk-slice-accelerated itself, so "missing" here often means a
910        // cheap deserialization rather than a parse.
911        let gap_decls: Vec<(crate::db::SourceFile, crate::db::FileDeclarations)> = {
912            use rayon::prelude::*;
913            missing
914                .par_iter()
915                .map_with(snap.clone(), |db, &sf| {
916                    (sf, crate::db::collect_file_declarations(db, sf).clone())
917                })
918                .collect()
919        };
920        drop(view);
921
922        let mut decls = covered;
923        decls.extend(gap_decls);
924
925        let db = &mut self.db.salsa;
926        if db.workspace_symbol_index_singleton().is_none() {
927            db.build_workspace_index_from_decls(decls);
928        } else {
929            // Something (e.g. a vendor-eager `index_batch`) seeded first; its
930            // singleton is already maintained incrementally. Merge only files
931            // it hasn't seen — `merge_precomputed_into_workspace_index` skips
932            // files already snapshotted.
933            db.merge_precomputed_into_workspace_index(&decls);
934        }
935    }
936
937    /// Reconcile the symbol-index singleton with mirror-only text writes.
938    ///
939    /// Plain `upsert_source_file_with_durability` calls (an LSP host
940    /// mirroring watcher-driven external edits or new files) bypass
941    /// `ingest_file`'s incremental index maintenance; those files accumulate
942    /// in a pending set while a singleton exists. Query entry points call
943    /// this first so the singleton is never consulted stale. Declaration
944    /// memos are pre-warmed on a snapshot in parallel, so the per-file merge
945    /// is a memo hit.
946    pub fn settle_workspace_index(&mut self) {
947        let _ = self.settle_workspace_index_cancellable(&|| false);
948    }
949
950    /// The single write prelude every interactive query needs before it can
951    /// read a consistent snapshot: reconcile the workspace index, then (if a
952    /// specific file is the target of the query) warm up that file's direct
953    /// dependencies via [`Self::prepare_file_for_analysis`].
954    ///
955    /// Run it before handing out an [`super::AnalysisSnapshot`]: snapshot
956    /// queries load missing classes on demand, but workspace-wide ones only
957    /// enumerate what this has settled. Pass the file a query is about, or
958    /// `None` for workspace-wide queries.
959    pub fn prepare_for_query(&mut self, file: Option<&Arc<str>>) {
960        self.settle_workspace_index();
961        if let Some(file) = file {
962            self.prepare_file_for_analysis(file);
963        }
964    }
965
966    /// Cancellable form of [`Self::settle_workspace_index`]. Returns `false`
967    /// when the caller's request was cancelled, or the round cap ran out,
968    /// before the pending index work could be reconciled.
969    pub(crate) fn settle_workspace_index_cancellable(
970        &mut self,
971        should_cancel: &(dyn Fn() -> bool + Sync),
972    ) -> bool {
973        self.db.salsa.adopt_on_demand_files();
974        // Bounded: snapshot readers keep queueing on-demand loads meanwhile.
975        let mut rounds_left = SETTLE_ROUNDS;
976        loop {
977            if rounds_left == 0 {
978                return self.db.salsa.index_pending_is_empty();
979            }
980            rounds_left -= 1;
981            // Every early return below drops the claim, which re-queues its
982            // paths for the next settle.
983            let claim = {
984                if should_cancel() {
985                    return false;
986                }
987                let view = self.db_view();
988                let db = view.db();
989                if db.index_pending_is_empty() {
990                    return true;
991                }
992                if should_cancel() {
993                    return false;
994                }
995                db.claim_index_pending()
996            };
997            if claim.paths().is_empty() {
998                return true;
999            }
1000
1001            if should_cancel() {
1002                return false;
1003            }
1004            let decls: Vec<(crate::db::SourceFile, crate::db::FileDeclarations)> = {
1005                use rayon::prelude::*;
1006                let view = self.db_view();
1007                let snap = view.db();
1008                let sfs: Vec<crate::db::SourceFile> = claim
1009                    .paths()
1010                    .iter()
1011                    .filter_map(|p| snap.lookup_source_file(p.as_ref()))
1012                    .collect();
1013                sfs.par_iter()
1014                    .map_with(snap.clone(), |db, &sf| {
1015                        (sf, crate::db::collect_file_declarations(db, sf).clone())
1016                    })
1017                    .collect()
1018            };
1019
1020            if should_cancel() {
1021                return false;
1022            }
1023            let db = &mut self.db.salsa;
1024            let declarations_changed = decls
1025                .iter()
1026                .any(|(sf, decls)| !db.file_declarations_match(*sf, decls));
1027            let old_index = if declarations_changed && self.cache.is_some() {
1028                db.workspace_symbol_index_singleton()
1029                    .map(|singleton| singleton.index(db).clone())
1030            } else {
1031                None
1032            };
1033            let candidates = old_index.as_ref().map(|_| decls.clone());
1034            // `update_workspace_index_for_file` clones the singleton maps per
1035            // call; for a bulk arrival (branch switch) one full rebuild — memo
1036            // validations plus a single map build, since the decls were just
1037            // pre-warmed above — beats N clones.
1038            if decls.len() > 32 {
1039                db.rebuild_workspace_symbol_index();
1040            } else {
1041                for (sf, decls) in decls {
1042                    if !db.update_workspace_index_for_file(sf, decls) {
1043                        db.rebuild_workspace_symbol_index();
1044                    }
1045                }
1046            }
1047            let displaced_owners = old_index
1048                .zip(candidates)
1049                .map(|(old, candidates)| displaced_cache_owners(db, &old, &candidates))
1050                .unwrap_or_default();
1051
1052            // New declarations can satisfy negative lookups with no reverse
1053            // dependency edge. Body-only edits leave these entries intact.
1054            if let Some(cache) = self.cache.as_deref() {
1055                cache.evict_with_dependents(&displaced_owners);
1056                if declarations_changed {
1057                    cache.evict_unresolved();
1058                }
1059            }
1060            claim.commit();
1061        }
1062    }
1063
1064    /// Drop a file's contribution to the session: codebase definitions,
1065    /// reference locations, salsa input handle, cache entry, and outgoing
1066    /// reverse-dependency edges. Cache entries of *dependent* files are
1067    /// also evicted (cross-file invalidation).
1068    ///
1069    /// Use this when a file is closed by the consumer, or before a re-ingest
1070    /// of substantially changed content. (Plain re-ingest via
1071    /// [`Self::ingest_file`] also drops old definitions, but does not
1072    /// remove the salsa input handle — call this for full cleanup.)
1073    pub fn invalidate_file(&mut self, file: &str) {
1074        self.index.clear_transient_batch_replay();
1075        self.index.clear_dependency_graph_cache();
1076        self.index.retire_file(&self.db.salsa, file);
1077        self.db.salsa.remove_source_file(file);
1078        // Outgoing structural edges disappear from the derived graph
1079        // automatically: the file is no longer in `source_file_paths()`, so
1080        // `dependency_graph()` stops iterating it.
1081        // Clear stale symbol tracking for this file — it's fully gone.
1082        self.stale_defined_symbols.remove(file);
1083        self.last_ingested_symbols.remove(file);
1084        self.last_structural_targets.remove(file);
1085        // Declarations this file provided are gone; other prepared files may
1086        // now need their warm-up re-run to lazy-load replacements.
1087        self.forget_prepared(file);
1088        self.bump_prepare_generation();
1089        if let Some(cache) = &self.cache {
1090            cache.update_reverse_deps_for_file(file, &HashSet::default());
1091            cache.evict_with_dependents(&[file.to_string()]);
1092        }
1093        // The file is gone; cache entries that previously mapped to it stay
1094        // unresolvable until the file (or another with matching symbols) is
1095        // ingested again. Selective evict mirrors the ingest path.
1096        self.evict_unresolvable_for_file(file);
1097        // Vendor files are static in the eager-index model — closing a project
1098        // buffer never evicts them (no per-file pinning). Memory is bounded by
1099        // the LRU on `collect_file_definitions` and the parse cache instead.
1100    }
1101
1102    /// Number of files currently tracked in this session's salsa input set.
1103    /// Stable across reads; useful for diagnostics and memory bounds checks.
1104    pub fn tracked_file_count(&self) -> usize {
1105        self.db.source_file_count()
1106    }
1107}