mir_analyzer/session/incremental.rs
1use super::*;
2
3/// How many times the re-analysis pass re-runs after a concurrent write cancels it, then the sweep yields like an explicit cancellation.
4const PASS_ATTEMPTS: usize = 4;
5
6impl AnalysisSession {
7 /// Retrieve the source text the session has registered for `file`, if
8 /// any. Returns `None` when the file has never been ingested. Used by
9 /// the parallel re-analysis path to re-feed dependents to body analysis without
10 /// the caller having to track sources independently.
11 pub fn source_of(&self, file: &str) -> Option<Arc<str>> {
12 let db = self.snapshot_db();
13 let sf = db.lookup_source_file(file)?;
14 Some(sf.text(&db).clone())
15 }
16
17 /// Re-analyze every transitive dependent of `file`.
18 ///
19 /// When the user saves a file that other files depend on (e.g. editing
20 /// a base class, an interface, or a trait), those dependents may have
21 /// new diagnostics. Concurrent callers may run sweeps simultaneously;
22 /// each returns its per-file analysis results so the LSP server can
23 /// publish updated diagnostics in one batch.
24 ///
25 /// Source text for dependents is retrieved from the session's salsa
26 /// inputs (set by previous `ingest_file` calls) — the caller doesn't
27 /// need to track or re-read files. Files for which the session has no
28 /// source are silently skipped (returns the analyzable subset).
29 ///
30 /// Cross-file inferred return types are resolved on demand via salsa.
31 pub fn reanalyze_dependents(&mut self, file: &str) -> Vec<(Arc<str>, crate::FileAnalysis)> {
32 self.reanalyze_dependents_cancellable(file, &crate::IndexCancel::new())
33 }
34
35 /// Cancellable variant of [`Self::reanalyze_dependents`].
36 ///
37 /// The consumer flips `cancel` (typically because a newer edit arrived) to
38 /// abandon the re-analysis; the flag is checked at each file boundary. Salsa
39 /// cannot unwind the plain-Rust body-analysis walk mid-flight, so a file
40 /// already in progress finishes, but no further files are started. Files
41 /// skipped due to cancellation are simply absent from the returned vec —
42 /// the consumer should drop a stale flag and start fresh work on each edit.
43 pub fn reanalyze_dependents_cancellable(
44 &mut self,
45 file: &str,
46 cancel: &crate::IndexCancel,
47 ) -> Vec<(Arc<str>, crate::FileAnalysis)> {
48 if cancel.is_cancelled() {
49 return Vec::new();
50 }
51
52 // Phase 1: compute dependents outside the analysis loop.
53 let dependents = self.dependency_graph().transitive_dependents(file);
54 if dependents.is_empty() {
55 return Vec::new();
56 }
57 let dependents: Vec<Arc<str>> = dependents
58 .into_iter()
59 .map(|path| Arc::from(path.as_str()))
60 .collect();
61 self.reanalyze_file_set(dependents, cancel)
62 }
63
64 /// Re-analyze an explicit file set — typically the editor's currently
65 /// open files — after an edit elsewhere in the workspace.
66 ///
67 /// The caller passes the files it publishes diagnostics for instead of
68 /// computing the edited file's transitive dependents; salsa memoization
69 /// makes unaffected files ~free. Per-edit cost is O(open files).
70 ///
71 /// Files the session has no source for are silently skipped. Cancellation
72 /// semantics match [`Self::reanalyze_dependents_cancellable`].
73 pub fn reanalyze_files_cancellable(
74 &mut self,
75 files: &[Arc<str>],
76 cancel: &crate::IndexCancel,
77 ) -> Vec<(Arc<str>, crate::FileAnalysis)> {
78 if cancel.is_cancelled() || files.is_empty() {
79 return Vec::new();
80 }
81 if !self.settle_workspace_index_cancellable(&|| cancel.is_cancelled()) {
82 return Vec::new();
83 }
84 self.reanalyze_file_set(files.to_vec(), cancel)
85 }
86
87 /// Shared body of [`Self::reanalyze_dependents_cancellable`] and
88 /// [`Self::reanalyze_files_cancellable`]: warm up, analyze in parallel,
89 /// commit reference locations.
90 fn reanalyze_file_set(
91 &mut self,
92 files: Vec<Arc<str>>,
93 cancel: &crate::IndexCancel,
94 ) -> Vec<(Arc<str>, crate::FileAnalysis)> {
95 let dependents = files;
96
97 // Phase 2a: fault in each dependent's direct class references if the
98 // background indexer hasn't reached them yet (mirrors the FileAnalyzer
99 // warm-up behavior, avoiding transient false `UndefinedClass` during
100 // index warm-up).
101 //
102 // This runs SERIALLY and *before* the parallel analyze loop below:
103 // `prepare_ast_for_analysis` resolves and loads classes, and loading
104 // mutates the shared session salsa storage (`load_class` →
105 // `ingest_file` sets salsa inputs). Salsa input mutation cancels and
106 // blocks until every other database handle is released, so it must run
107 // with NO live snapshot in scope:
108 //
109 // - in parallel (the v0.37.0 regression), sibling rayon workers held
110 // live snapshot clones mid-`analyze_file`, so the first warm-up
111 // write blocked on them forever — under high dependent fan-out this
112 // deadlocked the whole runtime; and
113 // - even serially, a snapshot held across the loop (e.g. one taken to
114 // parse the dependents) blocks the very first write.
115 //
116 // `prepare_file_for_analysis` takes a *scoped* snapshot to fetch the
117 // parsed AST, drops it (the `Arc<ParseResult>` is owned), and only
118 // then warms up. Files already prepared against their current text
119 // skip the parse + AST walk entirely — hosts on the
120 // `ingest_file_prepared` write path pre-pay this per edit, making the
121 // whole loop a map-lookup sweep.
122 {
123 // Closed before `commit_gen` is read, so commits carry the
124 // post-load generation.
125 let mut session = self.defer_revision_bumps();
126 for file in &dependents {
127 if cancel.is_cancelled() {
128 return Vec::new();
129 }
130 if !session.prepare_file_for_analysis_cancellable(file, &|| cancel.is_cancelled()) {
131 return Vec::new();
132 }
133 }
134 }
135
136 // Phase 2b: drive each dependent through the `analyze_file` tracked
137 // query. Salsa's memo validation does the real work here: after a
138 // body-only edit, a dependent whose tracked inputs are structurally
139 // unchanged (`FileDefinitions` backdating) returns its cached output
140 // without re-running body analysis — re-analysis cost scales with
141 // what actually changed, not with dependent count.
142 //
143 // The snapshot is taken AFTER the warm-up above so the loop observes
144 // the freshly-loaded classes. This loop is read-only on salsa: it does
145 // not mutate inputs, so its snapshot never contends on a write.
146 //
147 // Keep this loop on the caller thread. A request may already be one
148 // of several concurrent host threads, while background indexing uses
149 // the shared Rayon pool. Sending each sweep back through that pool
150 // creates a pool-fan-in deadlock: every worker can block on a writer
151 // while the thread that would release it waits for a worker. It also
152 // prevents a cancelled request from making progress. The caller-level
153 // concurrency preserves throughput without that dependency.
154 //
155 // Dependents' `FileAnalysis::symbols` are empty on this path:
156 // per-expression symbols are intentionally not memoized (a typical
157 // file resolves thousands; caching them balloons memory), and
158 // diagnostics consumers don't read them. Hover / go-to-definition
159 // flows analyze the open file directly via [`crate::FileAnalyzer`].
160 //
161 // The loop short-circuits when cancellation has been requested.
162 // Generation before the snapshot: a file add racing the sweep leaves
163 // the commits stale (self-healing), never wrongly fresh.
164 type Analyzed = (
165 Arc<str>,
166 Arc<str>,
167 std::sync::Arc<crate::db::AnalyzeOutput>,
168 Vec<crate::db::SubtypeEntry>,
169 Option<super::RefCachePut>,
170 Option<Box<[mir_types::Name]>>,
171 );
172 type Pass = (
173 Option<Arc<crate::db::class_mention_index::MentionScanner>>,
174 Vec<Analyzed>,
175 );
176 let mut attempts_left = PASS_ATTEMPTS;
177 let (commit_gen, mention_scanner, mut results) = loop {
178 if cancel.is_cancelled() || attempts_left == 0 {
179 return Vec::new();
180 }
181 attempts_left -= 1;
182 let gen = self.index_generation();
183 // The pass snapshot is created and dropped INSIDE this closure so no handle survives into the commit below — holding one there deadlocks a concurrent input writer waiting in `cancel_others`.
184 // A write landing mid-pass raises `salsa::Cancelled`; catch it and retry rather than discarding the whole sweep.
185 let attempt =
186 salsa::Cancelled::catch(std::panic::AssertUnwindSafe(|| -> Option<Pass> {
187 // Freeze on the pass-scoped snapshot: warm-up (2a) completed
188 // every lazy load, and a concurrent index write cancels the
189 // pass, so the frozen view is never stale. Same discipline as
190 // the batch body pass.
191 if cancel.is_cancelled() {
192 return None;
193 }
194 let mut db_main = self.snapshot_db();
195 db_main.freeze_workspace_index();
196 // Sweeps are the steady-state population path for the mention
197 // index: every analyzed file gets a current mention scan
198 // alongside its postings, so later reference-gate checks are
199 // set lookups.
200 let mention_scanner = db_main.class_mention_scanner();
201 let analyzed: Vec<Analyzed> = dependents
202 .iter()
203 .filter_map(|file| {
204 if cancel.is_cancelled() {
205 return None;
206 }
207 let sf = db_main.lookup_source_file(file.as_ref())?;
208 // Capture the text the analysis ran against: the
209 // freshness marks below must record exactly this Arc,
210 // so a text write racing the sweep leaves the file
211 // dirty rather than wrongly marked fresh.
212 let text = sf.text(&db_main as &dyn crate::db::MirDatabase).clone();
213 let out = crate::db::analyze_file(
214 &db_main as &dyn crate::db::MirDatabase,
215 sf,
216 )
217 .clone();
218 let defs = crate::db::collect_file_definitions(
219 &db_main as &dyn crate::db::MirDatabase,
220 sf,
221 );
222 let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
223 // Stage the disk-cache write only when the postings
224 // commit below will actually rewrite — a no-op
225 // re-sweep (current commit) adds no hashing or
226 // parse-walk cost per file.
227 let put = if self.ref_commit_is_current(file.as_ref(), &text, &out) {
228 None
229 } else {
230 self.stage_ref_cache_put(
231 &db_main as &dyn crate::db::MirDatabase,
232 sf,
233 file.as_ref(),
234 &text,
235 &out,
236 )
237 };
238 let mentions = mention_scanner.as_ref().and_then(|s| {
239 (!db_main.class_mentions_current(file.as_ref(), &text, s.epoch()))
240 .then(|| s.scan(&text))
241 });
242 Some((file.clone(), text, out, entries, put, mentions))
243 })
244 .collect();
245 Some((mention_scanner, analyzed))
246 }));
247 match attempt {
248 Ok(Some((scanner, analyzed))) => break (gen, scanner, analyzed),
249 Ok(None) => return Vec::new(),
250 Err(_) if cancel.is_cancelled() => return Vec::new(),
251 Err(_) => std::thread::yield_now(),
252 }
253 };
254
255 // Serial commit: each dependent's output is its complete reference
256 // set, so replace rather than append. Both inverted indexes and their
257 // freshness marks update here — this is what keeps read queries
258 // lookup-shaped instead of re-validating every candidate memo.
259 // Unchanged files (same text, same memoized output) skip the rebuild
260 // entirely, so a no-op re-sweep is a pointer compare per file.
261 //
262 // Runs with no live snapshot: see the pass closure above.
263 {
264 let guard = &self.db.salsa;
265 let mut dependency_graph_changed = false;
266 for (file, text, out, entries, put, mentions) in results.iter_mut() {
267 // Pointer-identical memo ⇒ identical postings: skip the
268 // index rewrite. The mark is re-stamped unconditionally so a
269 // no-op sweep still advances the commit's generation.
270 if !self.ref_commit_is_current(file.as_ref(), text, out) {
271 let file_no = guard.locked_ref_index().intern_path(file);
272 guard.set_file_reference_locations(file_no, out.ref_locs.to_vec());
273 dependency_graph_changed = true;
274 }
275 if let (Some(s), Some(m)) = (&mention_scanner, mentions.take()) {
276 guard.set_file_class_mentions(file, text, s.epoch(), m);
277 }
278 if let Some(put) = put.take() {
279 self.apply_ref_cache_put(file.as_ref(), out, put);
280 }
281 self.mark_ref_committed(
282 file,
283 text,
284 Some(out),
285 commit_gen,
286 !out.has_unresolved_names(),
287 );
288 if !self.is_defs_committed(file.as_ref(), text) {
289 let file_no = guard.locked_ref_index().intern_path(file);
290 guard.set_file_class_edges(file_no, entries.clone());
291 self.mark_defs_committed(file, text);
292 dependency_graph_changed = true;
293 }
294 }
295 if dependency_graph_changed {
296 self.clear_dependency_graph_cache();
297 }
298 }
299
300 results
301 .into_iter()
302 .map(|(file, _, out, _, _, _)| {
303 (
304 file,
305 crate::FileAnalysis {
306 issues: out.issues.to_vec(),
307 symbols: Vec::new(),
308 },
309 )
310 })
311 .collect()
312 }
313
314 /// FQCNs that `file` imports via `use` statements but that aren't yet
315 /// loaded in the session.
316 ///
317 /// Designed as the input to background prefetching: after the LSP server
318 /// Return the `use`-import alias map for a file: a list of `(alias, fqcn)`
319 /// pairs where `alias` is the local name (e.g. `"Str"`) and `fqcn` is the
320 /// fully-qualified name (e.g. `"Illuminate\\Support\\Str"`).
321 ///
322 /// Completion handlers can use this to expand a short class name written
323 /// before `::` into its FQN before looking up static members, mirroring the
324 /// same alias expansion that go-to-definition already performs via
325 /// `symbol_at` + `definition_of`.
326 ///
327 /// Returns an empty Vec if the file has not been ingested or has no use
328 /// imports.
329 pub fn class_imports(&self, file: &str) -> Vec<(Arc<str>, Arc<str>)> {
330 let db = self.snapshot_db();
331 let imports = db.file_class_imports(file);
332 imports
333 .iter()
334 .map(|(alias, fqcn)| (Arc::from(alias.as_str()), Arc::from(fqcn.as_str())))
335 .collect()
336 }
337
338 /// ingests an open buffer, it can call this and lazy-load the returned
339 /// FQCNs on a worker thread so the user's first Cmd+Click into vendor
340 /// code doesn't pay the file-read+parse cost.
341 ///
342 /// Returns an empty Vec if the file hasn't been ingested or has no
343 /// unresolved imports.
344 pub fn pending_lazy_loads(&self, file: &str) -> Vec<Arc<str>> {
345 use rustc_hash::FxHashSet;
346
347 let db = self.snapshot_db();
348 let imports = db.file_imports(file);
349 if imports.is_empty() {
350 return Vec::new();
351 }
352 let mut out = Vec::new();
353 let mut seen: FxHashSet<Arc<str>> = FxHashSet::default();
354 for fqcn in imports.values() {
355 let here = crate::db::Fqcn::interned(&db, *fqcn);
356 if crate::db::find_class_like(&db, here).is_some() {
357 continue;
358 }
359 if let Some(resolver) = &self.resolver {
360 let fqcn_arc: Arc<str> = Arc::from(fqcn.as_str());
361 if !seen.insert(fqcn_arc.clone()) {
362 continue;
363 }
364 if resolver.resolve(fqcn.as_str()).is_some() {
365 out.push(fqcn_arc);
366 }
367 }
368 }
369 out
370 }
371
372 /// Convenience: synchronously lazy-load every import of `file` that
373 /// isn't already in the codebase. Returns the number successfully loaded.
374 ///
375 /// Uses a single shared-visited two-tier BFS across all pending imports
376 /// (see [`Self::load_classes_transitive_bounded`]) with a shallow depth so
377 /// member access on imported types type-checks without pulling in the
378 /// entire vendor tree.
379 pub fn prefetch_imports(&mut self, file: &str) -> usize {
380 let pending = self.pending_lazy_loads(file);
381 if pending.is_empty() {
382 return 0;
383 }
384 // Fault in each imported FQCN directly (single-file load + tier-merge).
385 // Inheritance ancestors / signature types resolve through the eagerly
386 // built workspace symbol index — no transitive walk needed here.
387 let mut session = self.defer_revision_bumps();
388 let mut loaded = 0;
389 for fqcn in &pending {
390 if session.load_class(fqcn.as_ref()).is_loaded() {
391 loaded += 1;
392 }
393 }
394 loaded
395 }
396
397 /// All class / interface / trait / enum FQCNs currently known to the
398 /// session, each paired with the file that defines them when available.
399 ///
400 /// Use this to build workspace-wide views (outline, fuzzy search, etc.).
401 /// Consumers implement their own search/match logic on top — the analyzer
402 /// only exposes the iterator.
403 pub fn all_classes(&self) -> Vec<(Arc<str>, Option<mir_types::Location>)> {
404 let db = self.snapshot_db();
405 crate::db::workspace_classes(&db)
406 .iter()
407 .filter_map(|fqcn| {
408 let here = crate::db::Fqcn::from_str(&db, fqcn.as_str());
409 crate::db::find_class_like(&db, here)
410 .map(|class| (Arc::<str>::from(fqcn.as_str()), class.location().cloned()))
411 })
412 .collect()
413 }
414
415 /// All global function FQNs currently known to the session, each paired
416 /// with their declaration location when available.
417 pub fn all_functions(&self) -> Vec<(Arc<str>, Option<mir_types::Location>)> {
418 let db = self.snapshot_db();
419 crate::db::workspace_functions(&db)
420 .iter()
421 .filter_map(|fqn| {
422 let here = crate::db::Fqcn::from_str(&db, fqn.as_str());
423 crate::db::find_function(&db, here)
424 .map(|f| (Arc::<str>::from(fqn.as_str()), f.location.clone()))
425 })
426 .collect()
427 }
428
429 /// Every ancestor of `fqcn` — extended class, implemented interfaces,
430 /// used traits, transitively — most-derived first. Does not include
431 /// `fqcn` itself. Memoized per FQCN (shares `class_ancestors_by_fqcn`'s
432 /// tracked cache with member-resolution paths).
433 pub fn ancestors_of(&self, fqcn: &str) -> Vec<Arc<str>> {
434 let db = self.snapshot_db();
435 let here = crate::db::Fqcn::from_str(&db, fqcn.trim_start_matches('\\'));
436 crate::db::class_ancestors_by_fqcn(&db, here)
437 .iter()
438 .skip(1)
439 .cloned()
440 .collect()
441 }
442
443 /// Full signature for a global function, resolved by FQN.
444 pub fn function_signature(&self, fqn: &str) -> Option<Arc<crate::FunctionDef>> {
445 let db = self.snapshot_db();
446 let here = crate::db::Fqcn::from_str(&db, fqn);
447 crate::db::find_function(&db, here)
448 }
449
450 /// Compute `file`'s outgoing dependency edges and persist them to the
451 /// disk cache's reverse-dep graph (if configured). The in-memory graph
452 /// is no longer maintained imperatively: `dependency_graph()` derives
453 /// structural symbols from the memoized [`crate::db::file_structural_symbols`]
454 /// tracked query, then projects them through the workspace symbol index
455 /// alongside body-reference symbols.
456 pub(super) fn update_reverse_deps_for(&self, file: &str) {
457 if let Some(cache) = self.cache.as_deref() {
458 let db = self.snapshot_db();
459 let targets = file_outgoing_dependencies(&db, file, true);
460 cache.update_reverse_deps_for_file(file, &targets);
461 }
462 }
463
464 /// File dependency graph: which files depend on which other files.
465 /// Used for incremental invalidation in LSP servers and build systems.
466 ///
467 /// File dependency graph: which files depend on which other files.
468 /// Used for incremental invalidation in LSP servers and build systems.
469 ///
470 /// O(edges) — iterates symbol edges from the reference index and from
471 /// `file_structural_symbols`, then resolves each symbol to its defining
472 /// file via O(1) lookup. Total cost is O(E) where E is the number of
473 /// (file, symbol) edges.
474 pub fn dependency_graph(&self) -> crate::DependencyGraph {
475 if let Some(graph) = self.dependency_graph_cache.read().as_ref() {
476 return graph.clone();
477 }
478 let db = self.snapshot_db();
479
480 fn push_edge(
481 dependencies: &mut [Vec<u32>],
482 dependents: &mut [Vec<u32>],
483 from: u32,
484 to: u32,
485 ) {
486 if from == to {
487 return;
488 }
489 dependents[to as usize].push(from);
490 dependencies[from as usize].push(to);
491 }
492
493 fn sort_and_dedup(adjacency: &mut [Vec<u32>]) {
494 for deps in adjacency {
495 deps.sort();
496 deps.dedup();
497 }
498 }
499
500 fn symbol_defining_file_id(
501 db: &dyn crate::db::MirDatabase,
502 file_ids: &HashMap<Arc<str>, u32>,
503 symbol_key: &str,
504 ) -> Option<u32> {
505 let lookup = crate::defining_file_lookup_key(symbol_key);
506 db.symbol_defining_file(lookup)
507 .and_then(|file| file_ids.get(file.as_ref()).copied())
508 }
509
510 let mut all_files: Vec<Arc<str>> = db.source_file_paths().to_vec();
511 all_files.sort();
512 assert!(
513 u32::try_from(all_files.len()).is_ok(),
514 "dependency graph file ids require at most u32::MAX files"
515 );
516 let file_ids: HashMap<Arc<str>, u32> = all_files
517 .iter()
518 .enumerate()
519 .map(|(id, file)| (file.clone(), id as u32))
520 .collect();
521
522 let mut dependencies = vec![Vec::new(); all_files.len()];
523 let mut dependents = vec![Vec::new(); all_files.len()];
524 for (file_id, file) in all_files.iter().enumerate() {
525 let file_id = file_id as u32;
526 let mut file_deps: HashSet<u32> = HashSet::default();
527
528 // O(degree(file)) — forward reference-index lookup, no full-table scan.
529 for symbol_key in db.file_referenced_symbols(file.as_ref()) {
530 if let Some(def_id) = symbol_defining_file_id(&db, &file_ids, &symbol_key) {
531 file_deps.insert(def_id);
532 }
533 }
534
535 // Declaration-level symbol edges from Salsa. These cover imports,
536 // class hierarchy edges, and type-hint-only references that never
537 // appear in file_referenced_symbols.
538 if let Some(sf) = db.lookup_source_file(file.as_ref()) {
539 for symbol in crate::db::file_structural_symbols(&db, sf).iter() {
540 if let Some(def_id) = symbol_defining_file_id(&db, &file_ids, symbol) {
541 file_deps.insert(def_id);
542 }
543 }
544 }
545
546 for dep_id in file_deps {
547 push_edge(&mut dependencies, &mut dependents, file_id, dep_id);
548 }
549 }
550
551 sort_and_dedup(&mut dependents);
552 sort_and_dedup(&mut dependencies);
553
554 // Augment with stale dependents: files referencing symbols that were
555 // deleted from their defining file. These edges disappear from the
556 // symbol_defining_file lookup but the referencing file still needs
557 // re-analysis to surface the now-broken reference.
558 {
559 let stale = self.stale_defined_symbols.read();
560 if !stale.is_empty() {
561 for (file, deleted_syms) in stale.iter() {
562 let Some(&file_id) = file_ids.get(file.as_str()) else {
563 continue;
564 };
565 for sym in deleted_syms {
566 let lookup = crate::defining_file_lookup_key(sym);
567 // `defined_symbols()` only yields top-level FQ names
568 // (classes/interfaces/traits/enums, functions, global
569 // constants) — never knows here which kind `sym` was,
570 // so probe every prefix the reference index actually
571 // uses (see `Name::codebase_key`) rather than guessing
572 // one and silently missing referencers of the others.
573 for prefix in ["cls:", "fn:", "gcnst:"] {
574 for referencing_file in
575 db.symbol_referencers_of(&format!("{prefix}{lookup}"))
576 {
577 if let Some(&ref_id) = file_ids.get(referencing_file.as_ref()) {
578 push_edge(&mut dependencies, &mut dependents, ref_id, file_id);
579 }
580 }
581 }
582 }
583 }
584 // Re-sort and dedup since we may have added entries.
585 sort_and_dedup(&mut dependents);
586 sort_and_dedup(&mut dependencies);
587 }
588 }
589
590 let graph = crate::DependencyGraph::from_compact_parts(
591 all_files,
592 file_ids,
593 dependencies,
594 dependents,
595 );
596 *self.dependency_graph_cache.write() = Some(graph.clone());
597 graph
598 }
599}