Skip to main content

mir_analyzer/session/
snapshot.rs

1use std::panic::AssertUnwindSafe;
2use std::sync::Arc;
3
4use rustc_hash::FxHashSet as HashSet;
5use salsa::Cancelled;
6
7use super::index_state::{
8    hash_files, AnalyzedFile, DefsFreshness, IndexState, QueryGeneration, RefFreshness,
9    RefQueryCacheKey, RetireEpoch, SubtypeQueryCacheKey, ViewStamp,
10};
11use super::queries::{identifier_char_col, span_range, ReferenceGate};
12use super::SubtypeClassSite;
13use crate::cache::AnalysisCache;
14use crate::db::{MirDatabase, MirDbStorage};
15use crate::php_version::PhpVersion;
16
17/// A read handle on one revision of an [`super::AnalysisSession`]:
18/// `Send + Clone`, for running queries on threads other than the owner's.
19///
20/// No query writes salsa inputs. Class-likes and built-in stubs the symbol
21/// index lacks load on demand as pure reads; the owner adopts them at its
22/// next [`super::AnalysisSession::prepare_for_query`], which also settles
23/// the index before a snapshot is handed out.
24///
25/// A snapshot blocks the owner's next input write until it is dropped (salsa
26/// waits for outstanding handles), so hand snapshots out per request and
27/// never park one. When the owner starts a write, in-flight queries unwind
28/// and return `Err(Cancelled)`: drop the snapshot and request a fresh one.
29///
30/// Queries may still commit to the shared reference/subtype indexes (lazy
31/// freshness passes); those commits land in the owner's state too.
32#[derive(Clone)]
33pub struct AnalysisSnapshot {
34    pub(super) db: MirDbStorage,
35    pub(super) index: Arc<IndexState>,
36    pub(super) cache: Option<Arc<AnalysisCache>>,
37    pub(super) php_version: PhpVersion,
38    /// [`Self::index_generation`] captured when the owner created the
39    /// snapshot, while no write could race it.
40    pub(super) index_generation: u64,
41    /// Retirements this snapshot's view already reflects; commits for files
42    /// retired later are refused.
43    pub(super) retire_epoch: RetireEpoch,
44}
45
46const _: () = {
47    const fn assert_send_clone<T: Send + Clone>() {}
48    assert_send_clone::<AnalysisSnapshot>();
49};
50
51/// An [`AnalysisSnapshot`] that borrows its owner, so the owner's next
52/// `&mut self` write can't start while it's alive. A live handle across that
53/// write would deadlock the owner in salsa's `cancel_others`.
54pub(crate) struct DbView<'a> {
55    snapshot: AnalysisSnapshot,
56    _owner: std::marker::PhantomData<&'a super::AnalysisSession>,
57}
58
59impl<'a> DbView<'a> {
60    pub(super) fn new(_owner: &'a super::AnalysisSession, snapshot: AnalysisSnapshot) -> Self {
61        Self {
62            snapshot,
63            _owner: std::marker::PhantomData,
64        }
65    }
66}
67
68// Without a `Drop` impl, NLL ends the owner borrow at the view's last use
69// while its db clone lives on to the end of scope.
70struct ConstantDeclSite {
71    file: Arc<str>,
72    line: u32,
73    stmt_col: u32,
74    stmt_end: u32,
75    name_col: u32,
76    name_len: u32,
77}
78
79impl Drop for DbView<'_> {
80    fn drop(&mut self) {}
81}
82
83impl std::ops::Deref for DbView<'_> {
84    type Target = AnalysisSnapshot;
85    fn deref(&self) -> &AnalysisSnapshot {
86        &self.snapshot
87    }
88}
89
90/// Analyses staged by [`AnalysisSnapshot::stage_warm`], awaiting commit.
91struct WarmPass {
92    mention_scanner: Option<Arc<crate::db::class_mention_index::MentionScanner>>,
93    analyzed: Vec<AnalyzedFile>,
94}
95
96/// A fallback mention scan `(file, text scanned, names found)`, recorded
97/// after the pass so later gate checks become set lookups.
98type MentionScanRecord = (Arc<str>, Arc<str>, Box<[mir_types::Name]>);
99
100/// Below this many files the serial loop beats spawning workers.
101const MIN_PARALLEL_FILES: usize = 4;
102
103/// Stages `files` on dedicated analysis threads, each with its own clone of
104/// the frozen `db`, pulling paths from a shared cursor (rust-analyzer's
105/// cache-priming shape). Output keeps `files` order. `None` when `cancel`
106/// fired. The first panic on any worker, a salsa [`Cancelled`] included,
107/// stops the rest and is re-raised unchanged on the caller.
108fn stage_in_parallel(
109    db: &MirDbStorage,
110    files: &[Arc<str>],
111    cancel: &crate::IndexCancel,
112    parallel: bool,
113    stage: impl Fn(&MirDbStorage, &Arc<str>) -> Option<AnalyzedFile> + Sync,
114) -> Option<Vec<AnalyzedFile>> {
115    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
116    use std::sync::Mutex;
117
118    let threads = std::thread::available_parallelism()
119        .map_or(1, |n| n.get())
120        .min(files.len());
121    if !parallel || threads < 2 || files.len() < MIN_PARALLEL_FILES {
122        let mut analyzed = Vec::with_capacity(files.len());
123        for path in files {
124            if cancel.is_cancelled() {
125                return None;
126            }
127            analyzed.extend(stage(db, path));
128        }
129        return Some(analyzed);
130    }
131
132    let next = AtomicUsize::new(0);
133    let stop = AtomicBool::new(false);
134    let panic = Mutex::new(None::<Box<dyn std::any::Any + Send>>);
135    let slots: Vec<Mutex<Option<AnalyzedFile>>> = files.iter().map(|_| Mutex::new(None)).collect();
136
137    let work = |db: MirDbStorage| loop {
138        if stop.load(Ordering::Relaxed) {
139            return;
140        }
141        if cancel.is_cancelled() {
142            stop.store(true, Ordering::Relaxed);
143            return;
144        }
145        let i = next.fetch_add(1, Ordering::Relaxed);
146        let Some(path) = files.get(i) else { return };
147        match std::panic::catch_unwind(AssertUnwindSafe(|| stage(&db, path))) {
148            Ok(staged) => *slots[i].lock().unwrap() = staged,
149            Err(payload) => {
150                stop.store(true, Ordering::Relaxed);
151                panic.lock().unwrap().get_or_insert(payload);
152                return;
153            }
154        }
155    };
156    std::thread::scope(|scope| {
157        let handles: Vec<_> = (0..threads)
158            .filter_map(|n| {
159                let db = db.clone();
160                std::thread::Builder::new()
161                    .name(format!("mir-warm-{n}"))
162                    .stack_size(crate::recursion::STACK_SIZE)
163                    .spawn_scoped(scope, || work(db))
164                    .ok()
165            })
166            .collect();
167        if handles.is_empty() {
168            work(db.clone());
169        }
170        for h in handles {
171            let _ = h.join();
172        }
173    });
174
175    if let Some(payload) = panic.into_inner().unwrap() {
176        std::panic::resume_unwind(payload);
177    }
178    if stop.load(Ordering::Relaxed) {
179        return None;
180    }
181    Some(
182        slots
183            .into_iter()
184            .filter_map(|s| s.into_inner().unwrap())
185            .collect(),
186    )
187}
188
189pub(super) fn catch<T>(f: impl FnOnce() -> T) -> Result<T, Cancelled> {
190    Cancelled::catch(AssertUnwindSafe(f))
191}
192
193/// Raise `cancelled` the way salsa does, for code already inside [`catch`].
194pub(super) fn unwind(cancelled: Cancelled) -> ! {
195    std::panic::resume_unwind(Box::new(cancelled))
196}
197
198impl AnalysisSnapshot {
199    /// The salsa database this snapshot reads.
200    ///
201    /// **Internal API — exposes Salsa types.** Subject to change without
202    /// notice. Queries on it unwind with [`Cancelled`] rather than returning
203    /// it; use [`Self::read`] to catch that.
204    #[doc(hidden)]
205    pub fn db(&self) -> &MirDbStorage {
206        &self.db
207    }
208
209    /// Run `f` against the database, catching cancellation.
210    ///
211    /// **Internal API — exposes Salsa types.** Subject to change without
212    /// notice.
213    #[doc(hidden)]
214    pub fn read<R>(&self, f: impl FnOnce(&dyn MirDatabase) -> R) -> Result<R, Cancelled> {
215        catch(|| f(&self.db))
216    }
217
218    pub(crate) fn index(&self) -> &IndexState {
219        &self.index
220    }
221
222    pub(crate) fn stamp(&self) -> ViewStamp {
223        ViewStamp {
224            generation: self.index_generation,
225            retire_epoch: self.retire_epoch,
226        }
227    }
228
229    pub fn php_version(&self) -> PhpVersion {
230        self.php_version
231    }
232
233    /// See [`super::AnalysisSession::index_generation`].
234    pub fn index_generation(&self) -> u64 {
235        self.index_generation
236    }
237
238    /// See [`super::AnalysisSession::text_revision`].
239    pub fn text_revision(&self) -> salsa::Revision {
240        self.db.current_revision()
241    }
242
243    /// The combined generation the query memos key on: salsa's text revision
244    /// plus the off-salsa subtype-edge epoch and retirement seq. The epoch
245    /// covers what the revision can't — subtype edges and anonymous-class
246    /// `impl:` postings committed *within* one revision (e.g. a subtype BFS
247    /// admitting a file the reference gate skipped), which change a member
248    /// query's hierarchy fan-out without any text write. The seq covers a
249    /// retire whose ingest writes nothing, so a query that read the cleared
250    /// postings is never memoized.
251    pub(crate) fn query_cache_generation(&self) -> QueryGeneration {
252        (
253            self.db.current_revision(),
254            self.db.subtype_edges_epoch(),
255            self.index.retire_seq(),
256        )
257    }
258
259    pub fn find_class_like(&self, fqcn: &str) -> Result<Option<crate::db::ClassLike>, Cancelled> {
260        self.read(|db| crate::db::find_class_like(db, crate::db::Fqcn::from_str(db, fqcn)))
261    }
262
263    /// Whether `fqcn` resolves to a class-like declared by an embedded
264    /// built-in stub. A workspace class sharing a stub's name (a polyfill's
265    /// `Stringable`) shadows it and answers `false`, where
266    /// [`crate::stub_path_for_class`] tests the name alone.
267    pub fn is_builtin_class(&self, fqcn: &str) -> Result<bool, Cancelled> {
268        self.read(|db| {
269            let fqcn = fqcn.trim_start_matches('\\');
270            crate::db::class_like_decl_file(db, crate::db::Fqcn::from_str(db, fqcn))
271                .is_some_and(|file| crate::stubs::is_stub_path(&file))
272        })
273    }
274
275    pub fn find_function(&self, fqn: &str) -> Result<Option<Arc<crate::FunctionDef>>, Cancelled> {
276        self.read(|db| crate::db::find_function(db, crate::db::Fqcn::from_str(db, fqn)))
277    }
278
279    /// `method` as resolved along `class`'s ancestor chain (then mixins), as
280    /// `(declaring class, method)`.
281    #[allow(clippy::type_complexity)]
282    pub fn find_method_in_chain(
283        &self,
284        class: &str,
285        method: &str,
286    ) -> Result<Option<(Arc<str>, Arc<mir_codebase::definitions::MethodDef>)>, Cancelled> {
287        self.read(|db| {
288            crate::db::find_method_in_chain(db, crate::db::Fqcn::from_str(db, class), method)
289        })
290    }
291
292    /// The interface or abstract method that `method` implements: the first
293    /// ancestor of its class declaring the same-named method as abstract or
294    /// as an interface member. `None` for non-methods and for methods that
295    /// implement nothing.
296    pub fn implemented_method(
297        &self,
298        method: &crate::Name,
299    ) -> Result<Option<crate::Name>, Cancelled> {
300        let crate::Name::Method { class, name } = method else {
301            return Ok(None);
302        };
303        self.read(|db| {
304            let here = crate::db::Fqcn::from_str(db, class.as_ref());
305            crate::db::class_ancestors_by_fqcn(db, here)
306                .iter()
307                .skip(1)
308                .find_map(|ancestor| {
309                    let fqcn = crate::db::Fqcn::from_str(db, ancestor.as_ref());
310                    let is_interface = matches!(
311                        crate::db::find_class_like(db, fqcn)?,
312                        crate::db::ClassLike::Interface(_)
313                    );
314                    let m = crate::db::find_method_in_class(db, fqcn, name)?;
315                    (is_interface || m.is_abstract)
316                        .then(|| crate::Name::method(ancestor.clone(), name))
317                })
318        })
319    }
320
321    /// Declaration location of `symbol`. Symbols the index lacks load on
322    /// demand; no input is written.
323    pub fn definition_of_cached(
324        &self,
325        symbol: &crate::Name,
326    ) -> Result<Result<mir_types::Location, crate::SymbolLookupError>, Cancelled> {
327        catch(|| self.definition_of_unguarded(symbol))
328    }
329
330    fn definition_of_unguarded(
331        &self,
332        symbol: &crate::Name,
333    ) -> Result<mir_types::Location, crate::SymbolLookupError> {
334        let db = &self.db;
335        match symbol {
336            crate::Name::Class(fqcn) => {
337                let here = crate::db::Fqcn::from_str(db, fqcn.as_ref());
338                let class = crate::db::find_class_like(db, here)
339                    .ok_or(crate::SymbolLookupError::NotFound)?;
340                class
341                    .location()
342                    .cloned()
343                    .ok_or(crate::SymbolLookupError::NoSourceLocation)
344            }
345            crate::Name::Function(fqn) => {
346                let here = crate::db::Fqcn::from_str(db, fqn.as_ref());
347                let f =
348                    crate::db::find_function(db, here).ok_or(crate::SymbolLookupError::NotFound)?;
349                f.location
350                    .clone()
351                    .ok_or(crate::SymbolLookupError::NoSourceLocation)
352            }
353            crate::Name::Method { .. }
354            | crate::Name::Property { .. }
355            | crate::Name::ClassConstant { .. } => {
356                crate::db::member_location(db, symbol).ok_or(crate::SymbolLookupError::NotFound)
357            }
358            crate::Name::GlobalConstant(fqn) => self
359                .global_constant_location(fqn)
360                .ok_or(crate::SymbolLookupError::NoSourceLocation),
361        }
362    }
363
364    /// Which of `files` currently mention `class_name` as a whole identifier
365    /// (case-insensitive) — the same persistent, incrementally-maintained
366    /// mechanism [`Self::indexed_references_to`]'s own gate uses (see
367    /// `db::class_mention_index`), exposed so a host needn't re-implement a
368    /// from-scratch text scan for its own narrowing. A file already scanned
369    /// against a universe that included `class_name` answers via an O(log n)
370    /// lookup; only a never-scanned or since-edited file pays a scan, and
371    /// that scan is recorded for every other name in the universe too.
372    pub fn files_mentioning_class(
373        &self,
374        files: &[Arc<str>],
375        class_name: &str,
376    ) -> Result<Vec<Arc<str>>, Cancelled> {
377        self.files_mentioning_any(files, &[class_name])
378    }
379
380    /// Which of `files` currently mention ANY of `needles` as a whole
381    /// identifier (case-insensitive). Every needle is admitted into the
382    /// shared universe verbatim (no short-name stripping) before querying,
383    /// so the answer is never conservative on account of an unknown needle.
384    pub fn files_mentioning_any(
385        &self,
386        files: &[Arc<str>],
387        needles: &[&str],
388    ) -> Result<Vec<Arc<str>>, Cancelled> {
389        catch(|| self.mentioning_any_unguarded(files, needles))
390    }
391
392    fn mentioning_any_unguarded(&self, files: &[Arc<str>], needles: &[&str]) -> Vec<Arc<str>> {
393        use rayon::prelude::*;
394
395        if needles.is_empty() {
396            return files.to_vec();
397        }
398        let db = self.db.clone();
399        db.add_literal_mention_names(needles.iter().copied());
400        // Admission just grew the universe, so these only fail defensively.
401        let queries: Vec<_> = needles
402            .iter()
403            .filter_map(|n| db.prepare_class_mention_query(n))
404            .collect();
405        if queries.is_empty() {
406            return files.to_vec();
407        }
408        let Some(scanner) = db.class_mention_scanner() else {
409            return files.to_vec();
410        };
411
412        files
413            .par_iter()
414            .map_with(db, |db, f| -> Option<Arc<str>> {
415                let sf = db.lookup_source_file(f.as_ref())?;
416                let text = sf.text(&*db as &dyn MirDatabase).clone();
417                for q in &queries {
418                    match db.class_mention_answer(f.as_ref(), q, &text) {
419                        Some(true) => return Some(f.clone()),
420                        Some(false) => continue,
421                        None => {
422                            let names = scanner.scan(text.as_ref());
423                            let hit = queries
424                                .iter()
425                                .any(|q2| names.binary_search(&q2.name).is_ok());
426                            db.set_file_class_mentions(f, &text, scanner.epoch(), names);
427                            return hit.then(|| f.clone());
428                        }
429                    }
430                }
431                None
432            })
433            .flatten()
434            .collect()
435    }
436
437    /// Inverted-index find-references: posting-list lookup plus an on-demand
438    /// freshness/completeness pass over `files` (the host's candidate scope
439    /// — passing the whole workspace is fine; see the gate in
440    /// `stale_reference_candidates_unguarded`).
441    ///
442    /// A candidate whose postings were committed from its current text (Arc
443    /// identity) is answered from the index with no salsa work. Stale or
444    /// never-committed candidates are analyzed via the memoized
445    /// `analyze_file` query and committed, so each file pays that cost once
446    /// per text change — after a background warm sweep the steady state is a
447    /// pure lookup, O(results) instead of O(candidates). Hosts need no text
448    /// prefilter of their own, and must not use one: a host-side filter
449    /// cannot know the gate's matching semantics.
450    ///
451    /// Results are filtered to `files`. With `include_declaration`, the
452    /// symbol's declaration name span is appended when it lies inside the
453    /// scope.
454    ///
455    /// Returned ranges use mir's native coordinates: 1-based lines and
456    /// 0-based Unicode code-point columns (UTF-32/LSP `positionEncoding`
457    /// `"utf-32"`).
458    ///
459    /// Stale candidates are analyzed against whatever the owner has loaded.
460    /// Classes load on demand, but Composer `autoload.files` functions only
461    /// load in the owner's warm-up: a candidate analyzed before it records
462    /// unresolved names and is re-verified after the owner's next load bumps
463    /// the generation. Run [`super::AnalysisSession::prepare_references_query`]
464    /// before taking the snapshot to warm the stale candidates first.
465    ///
466    /// Memoized per `(symbol, files, include_declaration, includes,
467    /// query generation)` — see `RefQueryCacheKey` — so a repeat query
468    /// against unchanged state (a code-lens refresh) is one hashmap lookup
469    /// instead of an O(candidates) freshness scan.
470    pub fn indexed_references_to(
471        &self,
472        symbol: &crate::Name,
473        files: &[Arc<str>],
474        include_declaration: bool,
475        includes: crate::ReferenceIncludes,
476    ) -> Result<Vec<(Arc<str>, crate::Range)>, Cancelled> {
477        let key = self.reference_query_key(symbol, files, include_declaration, includes);
478        if let Some(hit) = self.index.ref_queries.get(&key) {
479            return Ok(hit);
480        }
481        let stale = catch(|| self.stale_reference_candidates_unguarded(symbol, files))?;
482        if !stale.is_empty() {
483            self.commit_reference_candidates(&stale)?;
484        }
485        let out = catch(|| self.read_references(symbol, files, include_declaration, includes))?;
486        self.ensure_no_retire_since(key.generation)?;
487        self.memoize_references(key, &out);
488        Ok(out)
489    }
490
491    /// `Err` when the owner retired a file since `generation`: postings read
492    /// in between may lack that file, and its replacing write is under way.
493    pub(super) fn ensure_no_retire_since(
494        &self,
495        generation: QueryGeneration,
496    ) -> Result<(), Cancelled> {
497        if self.index.retire_seq() == generation.2 {
498            Ok(())
499        } else {
500            Err(Cancelled::PendingWrite)
501        }
502    }
503
504    /// `use` import items (`use Foo\Bar;`, `use function ...;`,
505    /// `use const ...;`) for `symbol` — [`Self::indexed_references_to`] with
506    /// [`crate::ReferenceIncludes::UseImports`].
507    pub fn indexed_use_import_locations(
508        &self,
509        symbol: &crate::Name,
510        files: &[Arc<str>],
511    ) -> Result<Vec<(Arc<str>, crate::Range)>, Cancelled> {
512        self.indexed_references_to(symbol, files, false, crate::ReferenceIncludes::UseImports)
513    }
514
515    pub(super) fn reference_query_key(
516        &self,
517        symbol: &crate::Name,
518        files: &[Arc<str>],
519        include_declaration: bool,
520        includes: crate::ReferenceIncludes,
521    ) -> RefQueryCacheKey {
522        RefQueryCacheKey {
523            symbol: symbol.codebase_key(),
524            include_declaration,
525            includes,
526            generation: self.query_cache_generation(),
527            files_hash: hash_files(files),
528        }
529    }
530
531    /// Cache `out` unless the generation moved while computing it (an edit,
532    /// or a defs commit growing the subtype index — possibly this query's
533    /// own): such a key can never be looked up again, and the next identical
534    /// query recomputes once against settled state and caches then.
535    pub(super) fn memoize_references(
536        &self,
537        key: RefQueryCacheKey,
538        out: &[(Arc<str>, crate::Range)],
539    ) {
540        let generation = self.query_cache_generation();
541        if generation == key.generation {
542            self.index.ref_queries.insert(generation, key, out);
543        }
544    }
545
546    /// The candidates in `files` that [`Self::indexed_references_to`] would
547    /// analyze and commit: those whose postings are not exact for their
548    /// current text. Empty means the owner-side prelude
549    /// ([`super::AnalysisSession::prepare_references_query`]) has nothing to
550    /// warm, so a host can check this off its lock and skip taking it; when
551    /// not empty it passes this list to the prelude, so the O(candidates)
552    /// scan never runs under the lock.
553    pub fn stale_reference_candidates(
554        &self,
555        symbol: &crate::Name,
556        files: &[Arc<str>],
557    ) -> Result<Vec<Arc<str>>, Cancelled> {
558        catch(|| self.stale_reference_candidates_unguarded(symbol, files))
559    }
560
561    /// Freshness pass: candidates whose postings are not exact for their
562    /// current text. Files not registered as `SourceFile` inputs are
563    /// skipped. Never-committed files — no commit mark, hence no postings at
564    /// all (every mark drop accompanies a posting clear) — are further gated
565    /// on their text mentioning the symbol's name: such a file can neither
566    /// hold stale postings nor produce new ones, so a cold query on a common
567    /// name skips the bulk of the workspace instead of analyzing it. A
568    /// LIVE-analyzed file stale only by generation gets the same gate.
569    /// Everything else — a genuinely edited file, or a commit seeded by an
570    /// unverified disk-cache replay — re-analyzes unconditionally. Same
571    /// discipline as [`Self::commit_defs_for_matching`] on the defs index.
572    ///
573    /// The whole gate answers from the persistent mention index: every
574    /// needle is admitted, so a recorded mention set answers with lookups
575    /// and only a never-scanned or since-edited file pays one scan against
576    /// the whole universe, recorded for every later consumer. A needle new
577    /// to the universe epoch-invalidates older recordings for itself only.
578    ///
579    /// Intentionally serial: each request already runs on its caller
580    /// thread, and putting every concurrent request back onto the shared
581    /// rayon pool lets an index batch monopolize the workers.
582    pub(super) fn stale_reference_candidates_unguarded(
583        &self,
584        symbol: &crate::Name,
585        files: &[Arc<str>],
586    ) -> Vec<Arc<str>> {
587        let db = &self.db;
588        let gate = self.reference_gate(symbol);
589        let has_needles = !gate.idents.is_empty() || !gate.raw.is_empty();
590        let (mention_queries, mention_scanner) = if has_needles {
591            db.add_literal_mention_names(gate.idents.iter().map(|s| s.as_str()));
592            db.add_raw_mention_needles(gate.raw.iter().map(|s| s.as_str()));
593            let queries: Vec<_> = gate
594                .idents
595                .iter()
596                .chain(gate.raw.iter())
597                .filter_map(|s| db.prepare_class_mention_query(s))
598                .collect();
599            (queries, db.class_mention_scanner())
600        } else {
601            (Vec::new(), None)
602        };
603        // Admission guarantees a query per needle and a non-empty universe;
604        // anything else is defensive — the gate then admits every candidate
605        // (analyze rather than skip, the conservative direction).
606        let gate_complete = mention_queries.len() == gate.idents.len() + gate.raw.len()
607            && mention_scanner.is_some();
608        let current_gen = self.index_generation;
609        let mut stale = Vec::new();
610        let mut scanned: Vec<MentionScanRecord> = Vec::new();
611        for f in files {
612            let Some(sf) = db.lookup_source_file(f.as_ref()) else {
613                continue;
614            };
615            let text = sf.text(db as &dyn MirDatabase);
616            match self.index.ref_freshness(sf, text, current_gen) {
617                RefFreshness::Fresh => continue,
618                RefFreshness::Stale => {
619                    stale.push(f.clone());
620                    continue;
621                }
622                RefFreshness::StaleByGenerationOnly | RefFreshness::Uncommitted => {}
623            }
624            if has_needles && gate_complete {
625                // Any needle answering `true` admits the file; an
626                // unanswerable one forces the single recorded scan, which
627                // settles every needle at once.
628                let mut answer = Some(false);
629                for q in &mention_queries {
630                    match db.class_mention_answer(f.as_ref(), q, text) {
631                        Some(true) => {
632                            answer = Some(true);
633                            break;
634                        }
635                        Some(false) => {}
636                        None => answer = None,
637                    }
638                }
639                match answer {
640                    Some(true) => {}
641                    Some(false) => continue,
642                    None => {
643                        let scanner = mention_scanner
644                            .as_ref()
645                            .expect("gate_complete implies a scanner");
646                        let names = scanner.scan(text);
647                        let hit = mention_queries
648                            .iter()
649                            .any(|q| names.binary_search(&q.name).is_ok());
650                        scanned.push((f.clone(), text.clone(), names));
651                        if hit {
652                            stale.push(f.clone());
653                        }
654                        continue;
655                    }
656                }
657            }
658            stale.push(f.clone());
659        }
660        // Recorded regardless of how the query proceeds: each is a complete,
661        // current mention set for its file.
662        if let Some(scanner) = &mention_scanner {
663            for (file, text, names) in scanned {
664                db.set_file_class_mentions(&file, &text, scanner.epoch(), names);
665            }
666        }
667        stale
668    }
669
670    /// Analyze `stale` and commit their postings and class edges. Serial: a
671    /// request runs on its caller's thread, and concurrent requests fanning
672    /// out workers would starve one another and the background warm sweep.
673    pub(super) fn commit_reference_candidates(&self, stale: &[Arc<str>]) -> Result<(), Cancelled> {
674        self.warm_pass(stale, &crate::IndexCancel::new(), false)
675            .map(drop)
676    }
677
678    /// Analyze `files` and commit their reference postings, class edges and
679    /// mention scans to the index shared with the owner, so later queries
680    /// find them fresh. `Ok(false)` when `cancel` stopped the pass first.
681    ///
682    /// A commit with unresolved names stays tied to this snapshot's
683    /// [`Self::index_generation`], so the next workspace change re-opens it.
684    pub fn warm_files(
685        &self,
686        files: &[Arc<str>],
687        cancel: &crate::IndexCancel,
688    ) -> Result<bool, Cancelled> {
689        Ok(self.warm_pass(files, cancel, true)?.is_some())
690    }
691
692    /// The read half of [`super::AnalysisSession::reanalyze_files_cancellable`]:
693    /// analyze `files`, commit their reference postings and class edges, and
694    /// return each file's diagnostics (`symbols` are empty). Files without
695    /// registered text are absent. `Ok(None)` when `cancel` stopped the pass
696    /// first, with nothing committed.
697    ///
698    /// Run the owner-side prelude first: `prepare_for_query(None)`, then
699    /// `prepare_file_for_analysis` per file, a map lookup for files ingested
700    /// with `ingest_file_prepared`.
701    #[allow(clippy::type_complexity)]
702    pub fn reanalyze_files(
703        &self,
704        files: &[Arc<str>],
705        cancel: &crate::IndexCancel,
706    ) -> Result<Option<Vec<(Arc<str>, crate::FileAnalysis)>>, Cancelled> {
707        Ok(self.warm_pass(files, cancel, true)?.map(|analyzed| {
708            analyzed
709                .into_iter()
710                .map(|a| {
711                    (
712                        a.file,
713                        crate::FileAnalysis {
714                            issues: a.out.issues.to_vec(),
715                            symbols: Vec::new(),
716                        },
717                    )
718                })
719                .collect()
720        }))
721    }
722
723    pub(super) fn warm_pass(
724        &self,
725        files: &[Arc<str>],
726        cancel: &crate::IndexCancel,
727        parallel: bool,
728    ) -> Result<Option<Vec<AnalyzedFile>>, Cancelled> {
729        let Some(mut pass) = self.stage_warm(files, cancel, parallel)? else {
730            return Ok(None);
731        };
732        self.commit_warm(&mut pass)?;
733        Ok(Some(pass.analyzed))
734    }
735
736    /// Freezes the workspace index on a pass-scoped clone (borrow-only
737    /// symbol lookups + pass-shared subtype cache). On-demand loads leave the
738    /// index untouched and an owner write cancels the pass, so the frozen
739    /// view can't go stale. Same discipline as the batch body pass.
740    fn stage_warm(
741        &self,
742        files: &[Arc<str>],
743        cancel: &crate::IndexCancel,
744        parallel: bool,
745    ) -> Result<Option<WarmPass>, Cancelled> {
746        catch(|| {
747            let mut db = self.db.clone();
748            db.freeze_workspace_index();
749            let mention_scanner = db.class_mention_scanner_reusing_cached();
750            let cache = self.cache.as_deref();
751            let index = &self.index;
752            let scanner = mention_scanner.as_deref();
753            let stage =
754                |db: &MirDbStorage, path: &Arc<str>| index.stage_analyzed(db, cache, scanner, path);
755            let analyzed = stage_in_parallel(&db, files, cancel, parallel, stage)?;
756            Some(WarmPass {
757                mention_scanner,
758                analyzed,
759            })
760        })
761    }
762
763    /// Marks record the exact text Arc each file was analyzed against, so an
764    /// owner text write racing the pass leaves the file stale, never fresh.
765    fn commit_warm(&self, pass: &mut WarmPass) -> Result<(), Cancelled> {
766        self.index.commit_analyzed(
767            &self.db,
768            self.cache.as_deref(),
769            pass.mention_scanner.as_deref(),
770            &mut pass.analyzed,
771            self.stamp(),
772        )
773    }
774
775    /// Posting lookup for `symbol`, filtered to the candidate scope.
776    ///
777    /// Member symbols resolve against the queried class plus its hierarchy
778    /// (mir records member refs under the *declaring* class, so a query on
779    /// an interface method must include implementor keys and vice versa).
780    /// Name-only fallback postings — receivers whose type couldn't be
781    /// resolved — are consulted only when the typed keys produce nothing:
782    /// exact results when resolution succeeds, by-name matches when nothing
783    /// resolves. `__construct` stays exact: `new Sub()` invokes
784    /// `Sub::__construct` even when only a parent declares one, so hierarchy
785    /// fan-out would wrongly return subtype instantiation sites for a parent
786    /// query.
787    pub(super) fn read_references(
788        &self,
789        symbol: &crate::Name,
790        files: &[Arc<str>],
791        include_declaration: bool,
792        includes: crate::ReferenceIncludes,
793    ) -> Vec<(Arc<str>, crate::Range)> {
794        let key = symbol.codebase_key();
795        let hierarchy: Vec<String> = match symbol {
796            crate::Name::Method { class, name } => {
797                if class.is_empty() {
798                    Vec::new()
799                } else if name.as_ref() == "__construct" {
800                    vec![class.trim_start_matches('\\').to_string()]
801                } else {
802                    self.member_hierarchy_classes(class.as_ref())
803                }
804            }
805            crate::Name::Property { class, .. } | crate::Name::ClassConstant { class, .. } => {
806                if class.is_empty() {
807                    Vec::new()
808                } else {
809                    self.member_hierarchy_classes(class.as_ref())
810                }
811            }
812            _ => Vec::new(),
813        };
814        let scope: HashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
815        let read_symbol_key = |symbol_key: &str| -> Vec<(Arc<str>, crate::Range)> {
816            self.db
817                .reference_locations(symbol_key)
818                .into_iter()
819                .filter(|(file, ..)| scope.contains(file.as_ref()))
820                .map(|(file, line, col_start, col_end)| {
821                    (file, span_range(line, col_start as u32, col_end as u32))
822                })
823                .collect()
824        };
825        let mut scratch_key = String::new();
826        let mut read_composed_key = |prefix: &str, middle: &str, separator: &str, suffix: &str| {
827            scratch_key.clear();
828            scratch_key.reserve(prefix.len() + middle.len() + separator.len() + suffix.len());
829            scratch_key.push_str(prefix);
830            scratch_key.push_str(middle);
831            scratch_key.push_str(separator);
832            scratch_key.push_str(suffix);
833            read_symbol_key(&scratch_key)
834        };
835        let mut out: Vec<(Arc<str>, crate::Range)> = Vec::new();
836        if !matches!(includes, crate::ReferenceIncludes::UseImports) {
837            let member_prefix = match symbol {
838                crate::Name::Method { name, .. } => Some(("meth:", name)),
839                crate::Name::Property { name, .. } => Some(("prop:", name)),
840                crate::Name::ClassConstant { name, .. } => Some(("cnst:", name)),
841                _ => None,
842            };
843            match member_prefix {
844                Some((prefix, name)) => {
845                    for class in &hierarchy {
846                        out.extend(read_composed_key(prefix, class, "::", name));
847                    }
848                }
849                None => out.extend(read_symbol_key(key.as_str())),
850            }
851        }
852        if !matches!(includes, crate::ReferenceIncludes::Plain) {
853            out.extend(read_composed_key("use:", key.as_str(), "", ""));
854        }
855        if matches!(includes, crate::ReferenceIncludes::Plain) && out.is_empty() {
856            match symbol {
857                crate::Name::Method { name, .. } => {
858                    out = read_composed_key("methname:", name.as_ref(), "", "");
859                }
860                crate::Name::Property { name, .. } => {
861                    out = read_composed_key("propname:", name.as_ref(), "", "");
862                }
863                _ => {}
864            }
865        }
866        out.sort_by(|a, b| {
867            a.0.cmp(&b.0)
868                .then(a.1.start.line.cmp(&b.1.start.line))
869                .then(a.1.start.column.cmp(&b.1.start.column))
870        });
871        out.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
872
873        if include_declaration {
874            let decls: Vec<(Arc<str>, crate::Range)> = match symbol {
875                crate::Name::Method { class, name }
876                | crate::Name::Property { class, name }
877                | crate::Name::ClassConstant { class, name } => {
878                    if class.is_empty() {
879                        // Unknown owner: declarations by name, recorded as
880                        // `methdecl:`/`propdecl:`/`cnstdecl:` postings during
881                        // class/trait/interface/enum analysis.
882                        let prefix = match symbol {
883                            crate::Name::Method { .. } => "methdecl:",
884                            crate::Name::Property { .. } => "propdecl:",
885                            _ => "cnstdecl:",
886                        };
887                        read_composed_key(prefix, name.as_ref(), "", "")
888                    } else {
889                        self.member_decl_sites(&hierarchy, symbol)
890                    }
891                }
892                _ => self.declaration_name_range(symbol).into_iter().collect(),
893            };
894            for (file, range) in decls {
895                if scope.contains(file.as_ref())
896                    && !out.iter().any(|(f, r)| *f == file && *r == range)
897                {
898                    out.push((file, range));
899                }
900            }
901        }
902        out
903    }
904
905    /// The queried class plus every class its members' references could be
906    /// keyed under: resolved ancestors (a call on a subtype instance records
907    /// the declaring ancestor) and transitive subtypes including trait users
908    /// (a call on a subtype that overrides records the subtype). Display-form
909    /// FQCNs, deduplicated case-insensitively.
910    fn member_hierarchy_classes(&self, class_fqn: &str) -> Vec<String> {
911        let target = class_fqn.trim_start_matches('\\');
912        let mut out = vec![target.to_string()];
913        let db = &self.db;
914        let here = crate::db::Fqcn::from_str(db, target);
915        // Ancestors include the class itself first.
916        let ancestors = crate::db::class_ancestors_by_fqcn(db, here)
917            .iter()
918            .skip(1)
919            .map(|a| a.trim_start_matches('\\').to_string())
920            .collect::<Vec<_>>();
921        out.extend(ancestors);
922        out.extend(
923            db.subtype_sites_of(target, true, false)
924                .into_iter()
925                .map(|s| s.fqcn.trim_start_matches('\\').to_string()),
926        );
927        let mut seen: HashSet<String> = HashSet::default();
928        out.retain(|c| seen.insert(c.to_ascii_lowercase()));
929        out
930    }
931
932    /// Own-member declaration sites for `symbol` across `classes`: each class
933    /// that itself declares the member (not inherited) contributes its name
934    /// token. Kind-specific lookups — a class often declares a property and a
935    /// method with the same short name, and `member_location` can't tell them
936    /// apart.
937    fn member_decl_sites(
938        &self,
939        classes: &[String],
940        symbol: &crate::Name,
941    ) -> Vec<(Arc<str>, crate::Range)> {
942        let mut out: Vec<(Arc<str>, crate::Range)> = Vec::new();
943        let db = &self.db;
944        for class in classes {
945            let here = crate::db::Fqcn::from_str(db, class);
946            let (loc, needle) = match symbol {
947                crate::Name::Method { name, .. } => {
948                    let Some(m) = crate::db::find_method_in_class(db, here, name) else {
949                        continue;
950                    };
951                    (m.location.clone(), name.to_string())
952                }
953                crate::Name::Property { name, .. } => {
954                    let Some(p) = crate::db::find_property_in_class(db, here, name) else {
955                        continue;
956                    };
957                    (p.location.clone(), name.to_string())
958                }
959                crate::Name::ClassConstant { name, .. } => {
960                    let Some(c) = crate::db::find_class_constant_in_class(db, here, name) else {
961                        continue;
962                    };
963                    (c.location.clone(), name.to_string())
964                }
965                _ => continue,
966            };
967            let Some(loc) = loc else { continue };
968            let range = self.refine_location_to_name(&loc, &needle);
969            out.push((loc.file.clone(), range));
970        }
971        out
972    }
973
974    /// The symbol's declaration site, narrowed to the declared name's own
975    /// token — the lock-free read half of
976    /// [`crate::AnalysisSession::declaration_name_range`]. Symbols the index lacks
977    /// load on demand; no input is written.
978    pub fn declaration_name_range_cached(
979        &self,
980        symbol: &crate::Name,
981    ) -> Result<Option<(Arc<str>, crate::Range)>, Cancelled> {
982        catch(|| self.declaration_name_range(symbol))
983    }
984
985    /// The symbol's declaration site among already-loaded state, narrowed
986    /// from the collector's whole-declaration span to the declared name's
987    /// own token (matching the span shape of recorded references).
988    pub(super) fn declaration_name_range(
989        &self,
990        symbol: &crate::Name,
991    ) -> Option<(Arc<str>, crate::Range)> {
992        if let crate::Name::GlobalConstant(fqn) = symbol {
993            return self.global_constant_decl_range(fqn);
994        }
995        let loc = self.definition_of_unguarded(symbol).ok()?;
996        let declared_method_name: Arc<str>;
997        let short = match symbol {
998            crate::Name::Class(f) | crate::Name::Function(f) | crate::Name::GlobalConstant(f) => {
999                crate::db::subtype_index::short_name_of(f)
1000            }
1001            crate::Name::Method { class, name } => {
1002                declared_method_name = crate::db::trait_alias_original_name(&self.db, class, name)
1003                    .unwrap_or_else(|| name.clone());
1004                declared_method_name.as_ref()
1005            }
1006            crate::Name::Property { name, .. } | crate::Name::ClassConstant { name, .. } => {
1007                name.as_ref()
1008            }
1009        };
1010        // Property declarations carry a `$` sigil in source, but reference
1011        // ranges cover the bare name; the word-boundary search below lands on
1012        // the name right after the sigil.
1013        let range = self.refine_location_to_name(&loc, short);
1014        Some((loc.file.clone(), range))
1015    }
1016
1017    /// Narrow a whole-declaration [`mir_types::Location`] to the first
1018    /// word-boundary occurrence of `needle` inside its line span. Falls back
1019    /// to the location's own coordinates when the text is unavailable or the
1020    /// name doesn't appear (e.g. stub-only declarations).
1021    fn refine_location_to_name(&self, loc: &mir_types::Location, needle: &str) -> crate::Range {
1022        let fallback = span_range(loc.line, loc.col_start as u32, loc.col_end as u32);
1023        let Some(text) = self
1024            .db
1025            .lookup_source_file(loc.file.as_ref())
1026            .map(|sf| sf.text(&self.db as &dyn MirDatabase).clone())
1027        else {
1028            return fallback;
1029        };
1030        let needle_chars = needle.chars().count() as u32;
1031        let first_line = loc.line.saturating_sub(1) as usize;
1032        // Exact-case first: PHP property/constant names are case-sensitive
1033        // and an early case-insensitive hit can land on an unrelated token
1034        // (a type hint sharing the name). Case-insensitive second, for
1035        // method/class needles that arrive lowercase-normalized.
1036        for case_insensitive in [false, true] {
1037            for (idx, line_text) in text.lines().enumerate().skip(first_line) {
1038                let line_no = idx as u32 + 1;
1039                if line_no > loc.line_end {
1040                    break;
1041                }
1042                let min_col = if line_no == loc.line {
1043                    loc.col_start as usize
1044                } else {
1045                    0
1046                };
1047                if let Some(col) = identifier_char_col(line_text, needle, min_col, case_insensitive)
1048                {
1049                    return span_range(line_no, col, col + needle_chars);
1050                }
1051            }
1052        }
1053        fallback
1054    }
1055
1056    /// Declaring line of a global constant. Constant slices carry no stored
1057    /// location, so this finds the declaring file via the workspace constants
1058    /// index and locates the `const NAME` / `define('NAME'` line textually.
1059    fn global_constant_decl_site(&self, fqn: &str) -> Option<ConstantDeclSite> {
1060        let short = crate::db::subtype_index::short_name_of(fqn);
1061        let db = &self.db;
1062        let index = crate::db::workspace_index(db);
1063        let loc = index.constant_loc(mir_types::Name::from(fqn.trim_start_matches('\\')))?;
1064        let file = loc.file().path(db).clone();
1065        let sf = db.lookup_source_file(file.as_ref())?;
1066        let text = sf.text(db as &dyn MirDatabase);
1067        for (idx, line) in text.lines().enumerate() {
1068            let trimmed = line.trim_start();
1069            let is_decl_line = trimmed.starts_with("const ")
1070                || trimmed.contains("define(")
1071                || trimmed.contains("define (");
1072            if !is_decl_line {
1073                continue;
1074            }
1075            if let Some(name_col) = identifier_char_col(line, short, 0, false) {
1076                let stmt_end = match line.find(';') {
1077                    Some(byte) => line[..=byte].chars().count(),
1078                    None => line.chars().count(),
1079                };
1080                return Some(ConstantDeclSite {
1081                    file,
1082                    line: idx as u32 + 1,
1083                    stmt_col: (line.chars().count() - trimmed.chars().count()) as u32,
1084                    stmt_end: stmt_end as u32,
1085                    name_col,
1086                    name_len: short.chars().count() as u32,
1087                });
1088            }
1089        }
1090        None
1091    }
1092
1093    fn global_constant_decl_range(&self, fqn: &str) -> Option<(Arc<str>, crate::Range)> {
1094        let site = self.global_constant_decl_site(fqn)?;
1095        let range = span_range(site.line, site.name_col, site.name_col + site.name_len);
1096        Some((site.file, range))
1097    }
1098
1099    /// Whole-statement location of a user global constant's declaration.
1100    fn global_constant_location(&self, fqn: &str) -> Option<mir_types::Location> {
1101        let site = self.global_constant_decl_site(fqn)?;
1102        Some(mir_types::Location {
1103            file: site.file,
1104            line: site.line,
1105            line_end: site.line,
1106            col_start: site.stmt_col as u16,
1107            col_end: site.stmt_end as u16,
1108        })
1109    }
1110
1111    /// Files declaring transitive subclasses of `class_fqn` anywhere in the
1112    /// workspace, excluding `class_fqn`'s own declaring file — the caller
1113    /// adds it. Subclasses are matched by resolved FQCN, so `extends \Ns\Base`
1114    /// and aliased `use` forms are all found. `include_trait_users` also
1115    /// counts files whose classes `use` the trait `class_fqn`.
1116    pub fn subtype_files(
1117        &self,
1118        class_fqn: &str,
1119        include_trait_users: bool,
1120    ) -> Result<Vec<Arc<str>>, Cancelled> {
1121        let files = self.db.source_file_paths();
1122        let mut out: Vec<Arc<str>> = self
1123            .indexed_subtype_classes(class_fqn, &files, include_trait_users)?
1124            .into_iter()
1125            .map(|s| s.file)
1126            .collect();
1127        out.sort();
1128        out.dedup();
1129        Ok(out)
1130    }
1131
1132    /// Transitive subtypes of `class_fqn` (classes/interfaces/enums whose
1133    /// resolved ancestor chain reaches it), answered from the maintained
1134    /// subtype edge index.
1135    ///
1136    /// `files` is the host's candidate scope for the on-demand completeness
1137    /// pass: per BFS round, not-yet-committed files whose text mentions a
1138    /// frontier short name get their definitions committed, so results are
1139    /// complete even before a background sweep has covered the workspace.
1140    /// That short-name gate is only candidate discovery; subtype identity is
1141    /// resolved from the edge index by exact canonical FQCN. Committed files
1142    /// answer from the index with no parsing at all.
1143    ///
1144    /// `include_trait_users` also counts `use Trait;` composition as a
1145    /// subtype edge (visibility-scoping semantics); leave it off for
1146    /// goto-implementation semantics (extends/implements only).
1147    ///
1148    /// Memoized per `(class_fqn, include_trait_users, files, query
1149    /// generation)`, same rationale as [`Self::indexed_references_to`].
1150    pub fn indexed_subtype_classes(
1151        &self,
1152        class_fqn: &str,
1153        files: &[Arc<str>],
1154        include_trait_users: bool,
1155    ) -> Result<Vec<SubtypeClassSite>, Cancelled> {
1156        self.subtype_classes_memoized(class_fqn, files, include_trait_users, false)
1157    }
1158
1159    /// Like [`Self::indexed_subtype_classes`], restricted to subtypes that
1160    /// name `class_fqn` directly in `extends`/`implements` (or `use` when
1161    /// `include_trait_users`), resolved by canonical FQCN.
1162    pub fn indexed_direct_subtype_classes(
1163        &self,
1164        class_fqn: &str,
1165        files: &[Arc<str>],
1166        include_trait_users: bool,
1167    ) -> Result<Vec<SubtypeClassSite>, Cancelled> {
1168        self.subtype_classes_memoized(class_fqn, files, include_trait_users, true)
1169    }
1170
1171    fn subtype_classes_memoized(
1172        &self,
1173        class_fqn: &str,
1174        files: &[Arc<str>],
1175        include_trait_users: bool,
1176        direct_only: bool,
1177    ) -> Result<Vec<SubtypeClassSite>, Cancelled> {
1178        catch(|| {
1179            let key = SubtypeQueryCacheKey {
1180                class_fqn: class_fqn.trim_start_matches('\\').to_ascii_lowercase(),
1181                include_trait_users,
1182                direct_only,
1183                generation: self.query_cache_generation(),
1184                files_hash: hash_files(files),
1185            };
1186            if let Some(hit) = self.index.subtype_queries.get(&key) {
1187                return hit;
1188            }
1189            let out =
1190                self.subtype_classes_uncached(class_fqn, files, include_trait_users, direct_only);
1191            if let Err(cancelled) = self.ensure_no_retire_since(key.generation) {
1192                unwind(cancelled);
1193            }
1194            // See `memoize_references` for why a moved generation skips.
1195            let generation = self.query_cache_generation();
1196            if generation == key.generation {
1197                self.index.subtype_queries.insert(generation, key, &out);
1198            }
1199            out
1200        })
1201    }
1202
1203    fn subtype_classes_uncached(
1204        &self,
1205        class_fqn: &str,
1206        files: &[Arc<str>],
1207        include_trait_users: bool,
1208        direct_only: bool,
1209    ) -> Vec<SubtypeClassSite> {
1210        let db = &self.db;
1211        let mut scanned: HashSet<String> = HashSet::default();
1212        let mut pending: Vec<String> = vec![class_fqn.trim_start_matches('\\').to_string()];
1213        let mut sites: Vec<crate::db::SubtypeSite> = Vec::new();
1214        while !pending.is_empty() {
1215            // Short names only discover stale/uncommitted files worth
1216            // collecting; the result itself comes from the edge index.
1217            let needles: Vec<String> = pending
1218                .drain(..)
1219                .filter(|f| scanned.insert(f.clone()))
1220                .map(|f| crate::db::subtype_index::short_name_of(&f).to_string())
1221                .collect();
1222            if !needles.is_empty() {
1223                self.commit_defs_for_matching(files, &needles);
1224            }
1225            sites = db.subtype_sites_of(class_fqn, include_trait_users, direct_only);
1226            if direct_only {
1227                break;
1228            }
1229            pending = sites
1230                .iter()
1231                .map(|s| s.fqcn.trim_start_matches('\\').to_string())
1232                .filter(|f| !scanned.contains(f))
1233                .collect();
1234        }
1235        let mut out: Vec<SubtypeClassSite> = sites
1236            .into_iter()
1237            .filter_map(|s| {
1238                let loc = s.location.as_ref()?;
1239                let short = crate::db::subtype_index::short_name_of(&s.fqcn).to_string();
1240                let range = self.refine_location_to_name(loc, &short);
1241                Some(SubtypeClassSite {
1242                    fqcn: s.fqcn,
1243                    kind: s.kind,
1244                    is_abstract: s.is_abstract,
1245                    file: s.file,
1246                    range,
1247                })
1248            })
1249            .collect();
1250        // Anonymous classes never reach the definition collector; their
1251        // `new class implements X {}` sites are recorded under the resolved
1252        // canonical parent FQCN during body analysis.
1253        let root_lc = class_fqn.trim_start_matches('\\').to_ascii_lowercase();
1254        let scope: HashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
1255        for (file, line, cs, ce) in db.reference_locations(&format!("impl:{root_lc}")) {
1256            if !scope.contains(file.as_ref()) {
1257                continue;
1258            }
1259            let range = span_range(line, cs as u32, ce as u32);
1260            if out.iter().any(|s| s.file == file && s.range == range) {
1261                continue;
1262            }
1263            out.push(SubtypeClassSite {
1264                fqcn: Arc::from("class@anonymous"),
1265                kind: crate::db::ClassLikeKind::Class,
1266                is_abstract: false,
1267                file,
1268                range,
1269            });
1270        }
1271        out
1272    }
1273
1274    /// Concrete implementations of `class_fqn::method` across its transitive
1275    /// subtypes: the same-named non-abstract method available to each subtype
1276    /// (its own declaration, or one inherited/composed from a parent, trait,
1277    /// or mixin), as `(subtype fqcn, file, name range)`. Subtypes resolving to
1278    /// the same declaring location collapse to a single entry.
1279    #[allow(clippy::type_complexity)]
1280    pub fn indexed_method_implementations(
1281        &self,
1282        class_fqn: &str,
1283        method: &str,
1284        files: &[Arc<str>],
1285    ) -> Result<Vec<(Arc<str>, Arc<str>, crate::Range)>, Cancelled> {
1286        let subs = self.indexed_subtype_classes(class_fqn, files, false)?;
1287        catch(|| {
1288            let db = &self.db;
1289            let mut out: Vec<(Arc<str>, Arc<str>, crate::Range)> = Vec::new();
1290            for sub in &subs {
1291                let here = crate::db::Fqcn::from_str(db, sub.fqcn.as_ref());
1292                let Some((_, m)) = crate::db::find_method_in_chain(db, here, method) else {
1293                    continue;
1294                };
1295                if m.is_abstract {
1296                    continue;
1297                }
1298                let Some(loc) = m.location.as_ref() else {
1299                    continue;
1300                };
1301                let range = self.refine_location_to_name(loc, method);
1302                out.push((sub.fqcn.clone(), loc.file.clone(), range));
1303            }
1304            out.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.start.line.cmp(&b.2.start.line)));
1305            out.dedup_by(|a, b| a.1 == b.1 && a.2 == b.2);
1306            out
1307        })
1308    }
1309
1310    /// Commit definitions (class edges + freshness) for every file in `files`
1311    /// that is stale (committed against older text) or that has never been
1312    /// committed and mentions one of `shorts` as a whole identifier.
1313    ///
1314    /// The textual gate answers from the shared per-file mention cache — the
1315    /// same one [`Self::indexed_references_to`]'s gate populates — so a file
1316    /// scanned by either consumer answers the other with a set lookup. A file
1317    /// the cache can't answer for is scanned once against the whole name
1318    /// universe and recorded.
1319    ///
1320    /// Unwinds with [`Cancelled`] when a file was retired after this
1321    /// snapshot, once every other file is committed.
1322    fn commit_defs_for_matching(&self, files: &[Arc<str>], shorts: &[String]) {
1323        use rayon::prelude::*;
1324
1325        // Admit the frontier names before preparing, so every needle gets a
1326        // real query (a declared class's short name is already in the
1327        // universe from indexing — admission then changes nothing).
1328        self.db
1329            .add_literal_mention_names(shorts.iter().map(|s| s.as_str()));
1330        let queries: Vec<_> = shorts
1331            .iter()
1332            .filter_map(|s| self.db.prepare_class_mention_query(s))
1333            .collect();
1334        let mention_scanner = self.db.class_mention_scanner();
1335        // Admission guarantees a query per needle and a non-empty universe;
1336        // anything else is defensive — the gate then admits every candidate
1337        // (recommit rather than skip, the conservative direction).
1338        let use_mentions = queries.len() == shorts.len() && mention_scanner.is_some();
1339        type Work = (Arc<str>, Arc<str>, Vec<crate::db::SubtypeEntry>);
1340        let index = &*self.index;
1341        let results: Vec<(Option<Work>, Option<MentionScanRecord>)> = files
1342            .par_iter()
1343            .map_with(self.db.clone(), |db, path| {
1344                let Some(sf) = db.lookup_source_file(path.as_ref()) else {
1345                    return (None, None);
1346                };
1347                let text = sf.text(&*db as &dyn MirDatabase).clone();
1348                let freshness = index.defs_freshness(sf, &text);
1349                if freshness == DefsFreshness::Fresh {
1350                    return (None, None);
1351                }
1352                // Never-committed files must mention a frontier name; stale
1353                // (previously committed) files recommit unconditionally —
1354                // their classes may have re-parented.
1355                let mut scan_rec: Option<MentionScanRecord> = None;
1356                if use_mentions && freshness == DefsFreshness::Uncommitted {
1357                    let mut answer = Some(false);
1358                    for q in &queries {
1359                        match db.class_mention_answer(path.as_ref(), q, &text) {
1360                            Some(true) => {
1361                                answer = Some(true);
1362                                break;
1363                            }
1364                            Some(false) => {}
1365                            None => answer = None,
1366                        }
1367                    }
1368                    let hit = match answer {
1369                        Some(hit) => hit,
1370                        None => {
1371                            // Uncoverable entry: one scan answers every
1372                            // query and is recorded below.
1373                            let scanner = mention_scanner.as_ref().unwrap();
1374                            let names = scanner.scan(&text);
1375                            let hit = queries.iter().any(|q| names.binary_search(&q.name).is_ok());
1376                            scan_rec = Some((path.clone(), text.clone(), names));
1377                            hit
1378                        }
1379                    };
1380                    if !hit {
1381                        return (None, scan_rec);
1382                    }
1383                }
1384                let defs = crate::db::collect_file_definitions(&*db as &dyn MirDatabase, sf);
1385                let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
1386                (Some((path.clone(), text, entries)), scan_rec)
1387            })
1388            .collect();
1389        // Record the fallback scans regardless of hit/miss: each is a
1390        // complete, current mention set for its file, so the next round's
1391        // (and the references gate's) checks become set lookups.
1392        let mut refused = Ok(());
1393        for (work, scan_rec) in results {
1394            if let (Some(scanner), Some((file, text, names))) = (&mention_scanner, scan_rec) {
1395                self.db
1396                    .set_file_class_mentions(&file, &text, scanner.epoch(), names);
1397            }
1398            if let Some((file, text, entries)) = work {
1399                if let Err(cancelled) = self.index.commit_class_edges(
1400                    &self.db,
1401                    &file,
1402                    &text,
1403                    entries,
1404                    self.retire_epoch,
1405                ) {
1406                    refused = Err(cancelled);
1407                }
1408            }
1409        }
1410        if let Err(cancelled) = refused {
1411            unwind(cancelled);
1412        }
1413    }
1414
1415    /// Class-level issues (inheritance violations, abstract-method gaps,
1416    /// override incompatibilities) for `files`.
1417    ///
1418    /// These checks are cross-file by nature and are not emitted by
1419    /// [`Self::analyze`]. Circular-inheritance checks always run against the
1420    /// full workspace graph regardless of the `files` filter — a cycle is a
1421    /// workspace-wide problem.
1422    pub fn class_issues(&self, files: &[Arc<str>]) -> Result<Vec<crate::Issue>, Cancelled> {
1423        catch(|| {
1424            let db = &self.db;
1425            let file_set: HashSet<Arc<str>> = files.iter().cloned().collect();
1426            let file_data: Vec<(Arc<str>, Arc<str>)> = files
1427                .iter()
1428                .filter_map(|f| {
1429                    let sf = db.lookup_source_file(f)?;
1430                    Some((f.clone(), sf.text(db as &dyn MirDatabase).clone()))
1431                })
1432                .collect();
1433            crate::class::ClassAnalyzer::with_files(db, file_set, &file_data).analyze_all()
1434        })
1435    }
1436
1437    /// Collector-phase issues (e.g. `BackedEnumCaseTypeMismatch`,
1438    /// `InvalidReadonlyPropertyDeclaration`, `InvalidDocblock`, and raw parse
1439    /// errors) for `files`.
1440    ///
1441    /// These are found while building a file's declaration slice, before body
1442    /// analysis or cross-file class checks run — neither [`Self::analyze`]
1443    /// nor [`Self::class_issues`] reports them, so a caller merging just those
1444    /// two sources silently drops every collector-time diagnostic.
1445    pub fn collector_issues(&self, files: &[Arc<str>]) -> Result<Vec<crate::Issue>, Cancelled> {
1446        catch(|| {
1447            files
1448                .iter()
1449                .filter_map(|f| self.db.lookup_source_file(f))
1450                .flat_map(|sf| {
1451                    crate::db::collect_file_definitions(&self.db, sf)
1452                        .issues
1453                        .as_ref()
1454                        .clone()
1455                })
1456                .collect()
1457        })
1458    }
1459
1460    /// Admission predicate for [`Self::stale_reference_candidates_unguarded`]; see
1461    /// [`ReferenceGate`] and `reference_gate_needles`.
1462    ///
1463    /// Instance/static methods other than `__construct`/`__invoke` on a
1464    /// resolvable owner gate on the member name alone: every call site
1465    /// spells it (`->name(`, `::name(`), while the owner's short name would
1466    /// admit every file mentioning the class for any reason.
1467    /// `__construct` gates on the owner's short name plus the raw call tokens
1468    /// `->__construct` / `::__construct`. `__invoke` keeps the general
1469    /// owner/name gate because `$obj()` call sites do not spell `__invoke`.
1470    pub(super) fn reference_gate(&self, symbol: &crate::Name) -> ReferenceGate {
1471        ReferenceGate::for_symbol(&self.db, symbol)
1472    }
1473}
1474
1475#[cfg(test)]
1476mod tests {
1477    use super::*;
1478    use crate::{AnalysisSession, IndexCancel, Name, ReferenceIncludes};
1479    use salsa::Database as _;
1480
1481    const BASE: &str =
1482        "<?php\nclass Base { public function run(): void {} public function stop(): void {} }\n";
1483    const CALLS_RUN: &str = "<?php\nfunction go(Base $b): void { $b->run(); }\n";
1484    const CALLS_STOP: &str = "<?php\nfunction go(Base $b): void { $b->stop(); }\n";
1485
1486    /// Pins the warm pass's commit landing after an owner text write has
1487    /// started but before it applies: the commit must not leave the file
1488    /// fresh for the incoming text.
1489    #[test]
1490    fn warm_commit_racing_a_text_write_leaves_the_file_stale() {
1491        let (mut session, files) = prepared_workspace(CALLS_RUN);
1492        let snap = session.snapshot();
1493        let mut pass = snap
1494            .stage_warm(&files[1..], &IndexCancel::new(), false)
1495            .unwrap()
1496            .unwrap();
1497
1498        let caller = files[1].clone();
1499        let writer = std::thread::spawn(move || {
1500            session.upsert_source_file(caller, Arc::from(CALLS_STOP), salsa::Durability::LOW);
1501            session
1502        });
1503        await_pending_write(&snap);
1504        snap.commit_warm(&mut pass).unwrap();
1505        drop(snap);
1506        let mut session = writer.join().unwrap();
1507
1508        assert_eq!(callers_of(&mut session, &files, "stop"), [files[1].clone()]);
1509        assert!(callers_of(&mut session, &files, "run").is_empty());
1510    }
1511
1512    fn many_callers_workspace(count: usize) -> (AnalysisSession, Vec<Arc<str>>) {
1513        let mut session = AnalysisSession::new(PhpVersion::LATEST);
1514        session.ingest_file(Arc::from("base.php"), Arc::from(BASE));
1515        let callers: Vec<Arc<str>> = (0..count)
1516            .map(|i| Arc::from(format!("caller{i}.php")))
1517            .collect();
1518        for caller in &callers {
1519            session.ingest_file(caller.clone(), Arc::from(CALLS_RUN));
1520        }
1521        session.prepare_for_query(None);
1522        (session, callers)
1523    }
1524
1525    #[test]
1526    fn parallel_warm_keeps_file_order_and_commits_every_file() {
1527        let (mut session, callers) = many_callers_workspace(24);
1528        let snap = session.snapshot();
1529        let analyzed = snap
1530            .reanalyze_files(&callers, &IndexCancel::new())
1531            .unwrap()
1532            .unwrap();
1533        drop(snap);
1534
1535        let order: Vec<_> = analyzed.into_iter().map(|(file, _)| file).collect();
1536        assert_eq!(order, callers);
1537        let mut found = callers_of(&mut session, &callers, "run");
1538        found.sort();
1539        let mut expected = callers.clone();
1540        expected.sort();
1541        assert_eq!(found, expected);
1542    }
1543
1544    #[test]
1545    fn worker_panic_stops_the_pass_and_reaches_the_caller() {
1546        use std::sync::atomic::{AtomicUsize, Ordering};
1547
1548        let files: Vec<Arc<str>> = (0..4000).map(|i| Arc::from(format!("f{i}.php"))).collect();
1549        let started = AtomicUsize::new(0);
1550        let db = MirDbStorage::default();
1551        let outcome = std::panic::catch_unwind(AssertUnwindSafe(|| {
1552            stage_in_parallel(&db, &files, &IndexCancel::new(), true, |_, _| {
1553                if started.fetch_add(1, Ordering::Relaxed) == 2 {
1554                    std::panic::resume_unwind(Box::new("worker boom"));
1555                }
1556                std::thread::sleep(std::time::Duration::from_micros(200));
1557                None
1558            })
1559        }));
1560
1561        let Err(payload) = outcome else {
1562            panic!("panic must reach the caller");
1563        };
1564        assert_eq!(payload.downcast_ref::<&str>(), Some(&"worker boom"));
1565        assert!(started.load(Ordering::Relaxed) < files.len());
1566    }
1567
1568    #[test]
1569    fn serial_mode_never_spawns_workers() {
1570        let files: Vec<Arc<str>> = (0..16).map(|i| Arc::from(format!("f{i}.php"))).collect();
1571        let caller = std::thread::current().id();
1572        let db = MirDbStorage::default();
1573        let staged = stage_in_parallel(&db, &files, &IndexCancel::new(), false, |_, _| {
1574            assert_eq!(std::thread::current().id(), caller);
1575            None
1576        });
1577        assert!(staged.is_some());
1578    }
1579
1580    #[test]
1581    fn parallel_warm_stops_when_cancelled() {
1582        let (session, callers) = many_callers_workspace(24);
1583        let cancel = IndexCancel::new();
1584        cancel.cancel();
1585        let snap = session.snapshot();
1586        assert!(!snap.warm_files(&callers, &cancel).unwrap());
1587    }
1588
1589    #[test]
1590    fn warm_commit_after_an_ingest_retired_the_file_is_refused() {
1591        let (session, files) = prepared_workspace(CALLS_RUN);
1592        let snap = session.snapshot();
1593        let mut pass = snap
1594            .stage_warm(&files[1..], &IndexCancel::new(), false)
1595            .unwrap()
1596            .unwrap();
1597
1598        let caller = files[1].clone();
1599        let writer = std::thread::spawn(move || {
1600            let mut session = session;
1601            session.ingest_file(caller, Arc::from(CALLS_STOP));
1602            session
1603        });
1604        await_pending_write(&snap);
1605        assert!(snap.commit_warm(&mut pass).is_err());
1606        drop(snap);
1607        let mut session = writer.join().unwrap();
1608
1609        assert!(callers_of(&mut session, &files, "run").is_empty());
1610        assert_eq!(callers_of(&mut session, &files, "stop"), [files[1].clone()]);
1611    }
1612
1613    #[test]
1614    fn commits_after_invalidate_file_retired_the_file_are_refused() {
1615        const CHILD_CALLS_RUN: &str =
1616            "<?php\nclass Child extends Base {}\nfunction go(Base $b): void { $b->run(); }\n";
1617        let (session, files) = prepared_workspace(CHILD_CALLS_RUN);
1618        let snap = session.snapshot();
1619        let mut pass = snap
1620            .stage_warm(&files[1..], &IndexCancel::new(), false)
1621            .unwrap()
1622            .unwrap();
1623        let parsed = php_rs_parser::parse(CHILD_CALLS_RUN);
1624
1625        let caller = files[1].clone();
1626        let writer = std::thread::spawn(move || {
1627            let mut session = session;
1628            session.invalidate_file(&caller);
1629            session
1630        });
1631        await_pending_write(&snap);
1632        assert!(snap.commit_warm(&mut pass).is_err());
1633        assert!(snap
1634            .analyze(
1635                files[1].clone(),
1636                CHILD_CALLS_RUN,
1637                &parsed.program,
1638                &parsed.source_map
1639            )
1640            .is_err());
1641        drop(snap);
1642        let mut session = writer.join().unwrap();
1643
1644        assert!(callers_of(&mut session, &files, "run").is_empty());
1645        assert!(session
1646            .indexed_subtype_classes("Base", &files, false)
1647            .is_empty());
1648    }
1649
1650    #[test]
1651    fn cancelled_reference_commit_reports_cancellation() {
1652        let (mut session, files) = prepared_workspace(CALLS_RUN);
1653        let snap = session.snapshot();
1654        let stale = snap.stale_reference_candidates_unguarded(&Name::method("Base", "run"), &files);
1655        assert!(stale.contains(&files[1]));
1656
1657        let caller = files[1].clone();
1658        let writer = std::thread::spawn(move || {
1659            session.upsert_source_file(caller, Arc::from(CALLS_STOP), salsa::Durability::LOW);
1660            session
1661        });
1662        await_pending_write(&snap);
1663        assert!(snap.commit_reference_candidates(&stale).is_err());
1664        drop(snap);
1665        writer.join().unwrap();
1666    }
1667
1668    /// Top-level code only: ingesting it defines no symbols, so the ingest
1669    /// itself writes nothing to salsa.
1670    const ROUTES_CALLS_RUN: &str = "<?php\n(new Base())->run();\n";
1671
1672    /// A workspace whose `routes.php` is registered and committed by a
1673    /// references query but was never passed through `ingest_file` — the
1674    /// state of a file an editor opens after the initial scan.
1675    fn registered_routes_workspace() -> (AnalysisSession, Vec<Arc<str>>) {
1676        let mut session = AnalysisSession::new(PhpVersion::LATEST);
1677        let files: Vec<Arc<str>> = vec![Arc::from("base.php"), Arc::from("routes.php")];
1678        session.ingest_file(files[0].clone(), Arc::from(BASE));
1679        session.upsert_source_file(
1680            files[1].clone(),
1681            Arc::from(ROUTES_CALLS_RUN),
1682            salsa::Durability::LOW,
1683        );
1684        session.prepare_for_query(None);
1685        assert_eq!(callers_of(&mut session, &files, "run"), [files[1].clone()]);
1686        (session, files)
1687    }
1688
1689    /// Opening the file lands between a query's freshness pass and its
1690    /// posting read. Nothing cancels the query, so it must still see the
1691    /// file's postings — and must not memoize a result without them.
1692    #[test]
1693    fn ingest_of_unchanged_text_mid_query_keeps_references() {
1694        let (mut session, files) = registered_routes_workspace();
1695        let symbol = Name::method("Base", "run");
1696        let revision = session.text_revision();
1697
1698        let snap = session.snapshot();
1699        let key = snap.reference_query_key(&symbol, &files, false, ReferenceIncludes::Plain);
1700        assert!(snap
1701            .stale_reference_candidates_unguarded(&symbol, &files)
1702            .is_empty());
1703        session.ingest_file(files[1].clone(), Arc::from(ROUTES_CALLS_RUN));
1704        let out = snap.read_references(&symbol, &files, false, ReferenceIncludes::Plain);
1705        assert!(snap.ensure_no_retire_since(key.generation).is_ok());
1706        snap.memoize_references(key, &out);
1707        drop(snap);
1708
1709        assert_eq!(session.text_revision(), revision, "ingest wrote salsa");
1710        assert_eq!(
1711            out.into_iter().map(|(file, _)| file).collect::<Vec<_>>(),
1712            [files[1].clone()]
1713        );
1714        assert_eq!(callers_of(&mut session, &files, "run"), [files[1].clone()]);
1715    }
1716
1717    /// Any retire without a salsa write (e.g. `re_analyze_file` on unchanged
1718    /// text, between its clear and re-record) must cancel a query that read
1719    /// the cleared postings and keep its result out of the memo.
1720    #[test]
1721    fn query_spanning_a_retirement_is_cancelled_and_not_memoized() {
1722        let (mut session, files) = registered_routes_workspace();
1723        let symbol = Name::method("Base", "run");
1724
1725        let snap = session.snapshot();
1726        let key = snap.reference_query_key(&symbol, &files, false, ReferenceIncludes::Plain);
1727        assert!(snap
1728            .stale_reference_candidates_unguarded(&symbol, &files)
1729            .is_empty());
1730        session
1731            .index
1732            .retire_references(&session.db.salsa, files[1].as_ref());
1733        let out = snap.read_references(&symbol, &files, false, ReferenceIncludes::Plain);
1734        assert!(out.is_empty());
1735        assert!(snap.ensure_no_retire_since(key.generation).is_err());
1736        snap.memoize_references(key, &out);
1737        drop(snap);
1738
1739        let hits = session.ref_query_cache_hits();
1740        assert_eq!(callers_of(&mut session, &files, "run"), [files[1].clone()]);
1741        assert_eq!(session.ref_query_cache_hits(), hits);
1742    }
1743
1744    /// The cache-hit fast path replaces postings without a salsa write, so
1745    /// it must retire like the analysis path: a query spanning it cancels.
1746    #[test]
1747    fn re_analyze_file_cache_hit_cancels_spanning_queries() {
1748        let dir = tempfile::tempdir().unwrap();
1749        let mut session = AnalysisSession::new(PhpVersion::LATEST).with_cache_dir(dir.path());
1750        let files: Vec<Arc<str>> = vec![Arc::from("base.php"), Arc::from("caller.php")];
1751        let opts = crate::BatchOptions::new();
1752        session.ingest_file(files[0].clone(), Arc::from(BASE));
1753        session.re_analyze_file(&files[1], CALLS_RUN, &opts);
1754        session.prepare_for_query(None);
1755        assert_eq!(callers_of(&mut session, &files, "run"), [files[1].clone()]);
1756
1757        let symbol = Name::method("Base", "run");
1758        let snap = session.snapshot();
1759        let key = snap.reference_query_key(&symbol, &files, false, ReferenceIncludes::Plain);
1760        let revision = session.text_revision();
1761        session.re_analyze_file(&files[1], CALLS_RUN, &opts);
1762        assert_eq!(session.text_revision(), revision, "took the analysis path");
1763        assert!(snap.ensure_no_retire_since(key.generation).is_err());
1764        drop(snap);
1765
1766        assert_eq!(callers_of(&mut session, &files, "run"), [files[1].clone()]);
1767    }
1768
1769    fn prepared_workspace(caller_text: &str) -> (AnalysisSession, Vec<Arc<str>>) {
1770        let mut session = AnalysisSession::new(PhpVersion::LATEST);
1771        let files: Vec<Arc<str>> = vec![Arc::from("base.php"), Arc::from("caller.php")];
1772        session.ingest_file(files[0].clone(), Arc::from(BASE));
1773        session.ingest_file(files[1].clone(), Arc::from(caller_text));
1774        session.prepare_for_query(Some(&files[1]));
1775        (session, files)
1776    }
1777
1778    /// Spins until an owner write has tripped cancellation; the writer then
1779    /// waits for `snap` to drop before applying.
1780    fn await_pending_write(snap: &AnalysisSnapshot) {
1781        while catch(|| snap.db.unwind_if_revision_cancelled()).is_ok() {
1782            std::thread::yield_now();
1783        }
1784    }
1785
1786    fn callers_of(
1787        session: &mut AnalysisSession,
1788        files: &[Arc<str>],
1789        method: &str,
1790    ) -> Vec<Arc<str>> {
1791        session
1792            .indexed_references_to(
1793                &Name::method("Base", method),
1794                files,
1795                false,
1796                ReferenceIncludes::Plain,
1797                &|| false,
1798            )
1799            .unwrap()
1800            .into_iter()
1801            .map(|(file, _)| file)
1802            .collect()
1803    }
1804
1805    /// Everything a whole-file analysis leaves behind that a later one could
1806    /// observe: loaded dependencies, the warm-up skip mark, and the result.
1807    #[derive(Debug, PartialEq)]
1808    struct AnalysisOutcome {
1809        issues: Vec<String>,
1810        symbols: usize,
1811        vendor_class_loaded: bool,
1812        vendor_function_loaded: bool,
1813        eager_files_drained: bool,
1814        stubs_loaded: usize,
1815        prepared: bool,
1816        prepare_generation: u64,
1817        references: Vec<String>,
1818    }
1819
1820    const DEPENDENT: &str = "<?php\nnamespace App;\nfunction go(\\Vendor\\Base $b): void { vendor_helper('x'); new \\ArrayObject(); }\n";
1821
1822    fn composer_project(root: &std::path::Path) -> AnalysisSession {
1823        let vendor = root.join("vendor");
1824        std::fs::create_dir_all(vendor.join("composer")).unwrap();
1825        std::fs::create_dir_all(vendor.join("helpers")).unwrap();
1826        std::fs::create_dir_all(root.join("lib")).unwrap();
1827        std::fs::write(
1828            vendor.join("composer/autoload_files.php"),
1829            "<?php\n$vendorDir = dirname(__DIR__);\n$baseDir = dirname($vendorDir);\nreturn array(\n    'abc123' => $vendorDir . '/helpers/functions.php',\n);\n",
1830        )
1831        .unwrap();
1832        for empty in ["psr4", "classmap", "namespaces"] {
1833            std::fs::write(
1834                vendor.join(format!("composer/autoload_{empty}.php")),
1835                "<?php\nreturn [];\n",
1836            )
1837            .unwrap();
1838        }
1839        std::fs::write(
1840            vendor.join("helpers/functions.php"),
1841            "<?php\nfunction vendor_helper(string $s): string { return $s; }\n",
1842        )
1843        .unwrap();
1844        std::fs::write(
1845            root.join("lib/Base.php"),
1846            "<?php\nnamespace Vendor;\nclass Base {}\n",
1847        )
1848        .unwrap();
1849        std::fs::write(
1850            root.join("composer.json"),
1851            r#"{"autoload":{"psr-4":{"App\\":"src/","Vendor\\":"lib/"}}}"#,
1852        )
1853        .unwrap();
1854        let psr4 = crate::composer::Psr4Map::from_composer(root).unwrap();
1855        AnalysisSession::new(PhpVersion::LATEST).with_psr4(Arc::new(psr4))
1856    }
1857
1858    fn outcome(
1859        session: &AnalysisSession,
1860        file: &Arc<str>,
1861        analysis: crate::FileAnalysis,
1862    ) -> AnalysisOutcome {
1863        let text = session.source_of(file).unwrap();
1864        AnalysisOutcome {
1865            issues: analysis
1866                .issues
1867                .iter()
1868                .map(|i| format!("{:?}", i.kind))
1869                .collect(),
1870            symbols: analysis.symbols.len(),
1871            vendor_class_loaded: {
1872                let view = session.db_view();
1873                let db = view.db();
1874                crate::db::class_like_indexed(db, crate::db::Fqcn::from_str(db, "Vendor\\Base"))
1875            },
1876            vendor_function_loaded: session.contains_function("vendor_helper"),
1877            eager_files_drained: session.pending_eager_function_files.is_none(),
1878            stubs_loaded: session.loaded_stub_count(),
1879            prepared: session.is_prepared_for_analysis(
1880                file,
1881                &text,
1882                session.prepare_generation_snapshot(),
1883            ),
1884            prepare_generation: session.prepare_generation_snapshot(),
1885            references: ["cls:Vendor\\Base", "fn:vendor_helper", "cls:ArrayObject"]
1886                .into_iter()
1887                .map(|key| format!("{key}={:?}", session.reference_locations(key)))
1888                .collect(),
1889        }
1890    }
1891
1892    /// A host that ingests + `prepare_for_query(Some(file))` under its lock and
1893    /// then runs `AnalysisSnapshot::analyze` leaves the session as
1894    /// `FileAnalyzer::analyze` does, including the warm-up skip mark.
1895    #[test]
1896    fn prepare_for_query_then_snapshot_analyze_matches_file_analyzer() {
1897        let file: Arc<str> = Arc::from("consumer.php");
1898        let parsed = php_rs_parser::parse(DEPENDENT);
1899
1900        let root = tempfile::tempdir().unwrap();
1901        let mut via_analyzer = composer_project(root.path());
1902        via_analyzer.ingest_file(file.clone(), Arc::from(DEPENDENT));
1903        let analysis = crate::FileAnalyzer::new(&mut via_analyzer).analyze(
1904            file.clone(),
1905            DEPENDENT,
1906            &parsed.program,
1907            &parsed.source_map,
1908        );
1909        let expected = outcome(&via_analyzer, &file, analysis);
1910
1911        let root = tempfile::tempdir().unwrap();
1912        let mut via_snapshot = composer_project(root.path());
1913        via_snapshot.ingest_file(file.clone(), Arc::from(DEPENDENT));
1914        via_snapshot.prepare_for_query(Some(&file));
1915        let analysis = via_snapshot
1916            .snapshot()
1917            .analyze(file.clone(), DEPENDENT, &parsed.program, &parsed.source_map)
1918            .unwrap();
1919        let actual = outcome(&via_snapshot, &file, analysis);
1920
1921        assert!(
1922            expected.prepared && expected.vendor_function_loaded,
1923            "{expected:#?}"
1924        );
1925        assert_eq!(actual, expected);
1926    }
1927
1928    /// `FileAnalyzer::analyze` warms up from the AST it is handed, so it works
1929    /// on a file never ingested; `prepare_for_query` finds no input to warm
1930    /// from, so the host must ingest before the snapshot analysis.
1931    #[test]
1932    fn prepare_for_query_warms_nothing_for_a_file_that_was_never_ingested() {
1933        let file: Arc<str> = Arc::from("consumer.php");
1934        let parsed = php_rs_parser::parse(DEPENDENT);
1935        let unresolved = |analysis: &crate::FileAnalysis| {
1936            analysis
1937                .issues
1938                .iter()
1939                .any(|i| matches!(i.kind, crate::IssueKind::UndefinedFunction { .. }))
1940        };
1941
1942        let root = tempfile::tempdir().unwrap();
1943        let mut via_analyzer = composer_project(root.path());
1944        let analysis = crate::FileAnalyzer::new(&mut via_analyzer).analyze(
1945            file.clone(),
1946            DEPENDENT,
1947            &parsed.program,
1948            &parsed.source_map,
1949        );
1950        assert!(!unresolved(&analysis), "{:?}", analysis.issues);
1951
1952        let root = tempfile::tempdir().unwrap();
1953        let mut via_snapshot = composer_project(root.path());
1954        via_snapshot.prepare_for_query(Some(&file));
1955        let analysis = via_snapshot
1956            .snapshot()
1957            .analyze(file, DEPENDENT, &parsed.program, &parsed.source_map)
1958            .unwrap();
1959        assert!(unresolved(&analysis), "{:?}", analysis.issues);
1960    }
1961}