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, IndexState, QueryGeneration, RefQueryCacheKey, RetireEpoch,
9    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
100pub(super) fn catch<T>(f: impl FnOnce() -> T) -> Result<T, Cancelled> {
101    Cancelled::catch(AssertUnwindSafe(f))
102}
103
104/// Raise `cancelled` the way salsa does, for code already inside [`catch`].
105pub(super) fn unwind(cancelled: Cancelled) -> ! {
106    std::panic::resume_unwind(Box::new(cancelled))
107}
108
109impl AnalysisSnapshot {
110    /// The salsa database this snapshot reads.
111    ///
112    /// **Internal API — exposes Salsa types.** Subject to change without
113    /// notice. Queries on it unwind with [`Cancelled`] rather than returning
114    /// it; use [`Self::read`] to catch that.
115    #[doc(hidden)]
116    pub fn db(&self) -> &MirDbStorage {
117        &self.db
118    }
119
120    /// Run `f` against the database, catching cancellation.
121    ///
122    /// **Internal API — exposes Salsa types.** Subject to change without
123    /// notice.
124    #[doc(hidden)]
125    pub fn read<R>(&self, f: impl FnOnce(&dyn MirDatabase) -> R) -> Result<R, Cancelled> {
126        catch(|| f(&self.db))
127    }
128
129    pub(crate) fn index(&self) -> &IndexState {
130        &self.index
131    }
132
133    pub(crate) fn stamp(&self) -> ViewStamp {
134        ViewStamp {
135            generation: self.index_generation,
136            retire_epoch: self.retire_epoch,
137        }
138    }
139
140    pub fn php_version(&self) -> PhpVersion {
141        self.php_version
142    }
143
144    /// See [`super::AnalysisSession::index_generation`].
145    pub fn index_generation(&self) -> u64 {
146        self.index_generation
147    }
148
149    /// See [`super::AnalysisSession::text_revision`].
150    pub fn text_revision(&self) -> salsa::Revision {
151        self.db.current_revision()
152    }
153
154    /// The combined generation the query memos key on: salsa's text revision
155    /// plus the off-salsa subtype-edge epoch and retirement seq. The epoch
156    /// covers what the revision can't — subtype edges and anonymous-class
157    /// `impl:` postings committed *within* one revision (e.g. a subtype BFS
158    /// admitting a file the reference gate skipped), which change a member
159    /// query's hierarchy fan-out without any text write. The seq covers a
160    /// retire whose ingest writes nothing, so a query that read the cleared
161    /// postings is never memoized.
162    pub(crate) fn query_cache_generation(&self) -> QueryGeneration {
163        (
164            self.db.current_revision(),
165            self.db.subtype_edges_epoch(),
166            self.index.retire_seq(),
167        )
168    }
169
170    pub fn find_class_like(&self, fqcn: &str) -> Result<Option<crate::db::ClassLike>, Cancelled> {
171        self.read(|db| crate::db::find_class_like(db, crate::db::Fqcn::from_str(db, fqcn)))
172    }
173
174    pub fn find_function(&self, fqn: &str) -> Result<Option<Arc<crate::FunctionDef>>, Cancelled> {
175        self.read(|db| crate::db::find_function(db, crate::db::Fqcn::from_str(db, fqn)))
176    }
177
178    /// `method` as resolved along `class`'s ancestor chain (then mixins), as
179    /// `(declaring class, method)`.
180    #[allow(clippy::type_complexity)]
181    pub fn find_method_in_chain(
182        &self,
183        class: &str,
184        method: &str,
185    ) -> Result<Option<(Arc<str>, Arc<mir_codebase::definitions::MethodDef>)>, Cancelled> {
186        self.read(|db| {
187            crate::db::find_method_in_chain(db, crate::db::Fqcn::from_str(db, class), method)
188        })
189    }
190
191    /// Declaration location of `symbol`. Symbols the index lacks load on
192    /// demand; no input is written.
193    pub fn definition_of_cached(
194        &self,
195        symbol: &crate::Name,
196    ) -> Result<Result<mir_types::Location, crate::SymbolLookupError>, Cancelled> {
197        catch(|| self.definition_of_unguarded(symbol))
198    }
199
200    fn definition_of_unguarded(
201        &self,
202        symbol: &crate::Name,
203    ) -> Result<mir_types::Location, crate::SymbolLookupError> {
204        let db = &self.db;
205        match symbol {
206            crate::Name::Class(fqcn) => {
207                let here = crate::db::Fqcn::from_str(db, fqcn.as_ref());
208                let class = crate::db::find_class_like(db, here)
209                    .ok_or(crate::SymbolLookupError::NotFound)?;
210                class
211                    .location()
212                    .cloned()
213                    .ok_or(crate::SymbolLookupError::NoSourceLocation)
214            }
215            crate::Name::Function(fqn) => {
216                let here = crate::db::Fqcn::from_str(db, fqn.as_ref());
217                let f =
218                    crate::db::find_function(db, here).ok_or(crate::SymbolLookupError::NotFound)?;
219                f.location
220                    .clone()
221                    .ok_or(crate::SymbolLookupError::NoSourceLocation)
222            }
223            crate::Name::Method { .. }
224            | crate::Name::Property { .. }
225            | crate::Name::ClassConstant { .. } => {
226                crate::db::member_location(db, symbol).ok_or(crate::SymbolLookupError::NotFound)
227            }
228            crate::Name::GlobalConstant(fqn) => self
229                .global_constant_location(fqn)
230                .ok_or(crate::SymbolLookupError::NoSourceLocation),
231        }
232    }
233
234    /// Which of `files` currently mention `class_name` as a whole identifier
235    /// (case-insensitive) — the same persistent, incrementally-maintained
236    /// mechanism [`Self::indexed_references_to`]'s own gate uses (see
237    /// `db::class_mention_index`), exposed so a host needn't re-implement a
238    /// from-scratch text scan for its own narrowing. A file already scanned
239    /// against a universe that included `class_name` answers via an O(log n)
240    /// lookup; only a never-scanned or since-edited file pays a scan, and
241    /// that scan is recorded for every other name in the universe too.
242    pub fn files_mentioning_class(
243        &self,
244        files: &[Arc<str>],
245        class_name: &str,
246    ) -> Result<Vec<Arc<str>>, Cancelled> {
247        self.files_mentioning_any(files, &[class_name])
248    }
249
250    /// Which of `files` currently mention ANY of `needles` as a whole
251    /// identifier (case-insensitive). Every needle is admitted into the
252    /// shared universe verbatim (no short-name stripping) before querying,
253    /// so the answer is never conservative on account of an unknown needle.
254    pub fn files_mentioning_any(
255        &self,
256        files: &[Arc<str>],
257        needles: &[&str],
258    ) -> Result<Vec<Arc<str>>, Cancelled> {
259        catch(|| self.mentioning_any_unguarded(files, needles))
260    }
261
262    fn mentioning_any_unguarded(&self, files: &[Arc<str>], needles: &[&str]) -> Vec<Arc<str>> {
263        use rayon::prelude::*;
264
265        if needles.is_empty() {
266            return files.to_vec();
267        }
268        let db = self.db.clone();
269        db.add_literal_mention_names(needles.iter().copied());
270        // Admission just grew the universe, so these only fail defensively.
271        let queries: Vec<_> = needles
272            .iter()
273            .filter_map(|n| db.prepare_class_mention_query(n))
274            .collect();
275        if queries.is_empty() {
276            return files.to_vec();
277        }
278        let Some(scanner) = db.class_mention_scanner() else {
279            return files.to_vec();
280        };
281
282        files
283            .par_iter()
284            .map_with(db, |db, f| -> Option<Arc<str>> {
285                let sf = db.lookup_source_file(f.as_ref())?;
286                let text = sf.text(&*db as &dyn MirDatabase).clone();
287                for q in &queries {
288                    match db.class_mention_answer(f.as_ref(), q, &text) {
289                        Some(true) => return Some(f.clone()),
290                        Some(false) => continue,
291                        None => {
292                            let names = scanner.scan(text.as_ref());
293                            let hit = queries
294                                .iter()
295                                .any(|q2| names.binary_search(&q2.name).is_ok());
296                            db.set_file_class_mentions(f, &text, scanner.epoch(), names);
297                            return hit.then(|| f.clone());
298                        }
299                    }
300                }
301                None
302            })
303            .flatten()
304            .collect()
305    }
306
307    /// Inverted-index find-references: posting-list lookup plus an on-demand
308    /// freshness/completeness pass over `files` (the host's candidate scope
309    /// — passing the whole workspace is fine; see the gate in
310    /// `stale_reference_candidates`).
311    ///
312    /// A candidate whose postings were committed from its current text (Arc
313    /// identity) is answered from the index with no salsa work. Stale or
314    /// never-committed candidates are analyzed via the memoized
315    /// `analyze_file` query and committed, so each file pays that cost once
316    /// per text change — after a background warm sweep the steady state is a
317    /// pure lookup, O(results) instead of O(candidates). Hosts need no text
318    /// prefilter of their own, and must not use one: a host-side filter
319    /// cannot know the gate's matching semantics.
320    ///
321    /// Results are filtered to `files`. With `include_declaration`, the
322    /// symbol's declaration name span is appended when it lies inside the
323    /// scope.
324    ///
325    /// Returned ranges use mir's native coordinates: 1-based lines and
326    /// 0-based Unicode code-point columns (UTF-32/LSP `positionEncoding`
327    /// `"utf-32"`).
328    ///
329    /// Stale candidates are analyzed against whatever the owner has loaded.
330    /// A candidate whose warm-up the owner never ran may reference a class
331    /// that isn't loaded yet; its commit then records unresolved names and
332    /// is re-verified after the owner's next load bumps the generation.
333    ///
334    /// Memoized per `(symbol, files, include_declaration, includes,
335    /// query generation)` — see `RefQueryCacheKey` — so a repeat query
336    /// against unchanged state (a code-lens refresh) is one hashmap lookup
337    /// instead of an O(candidates) freshness scan.
338    pub fn indexed_references_to(
339        &self,
340        symbol: &crate::Name,
341        files: &[Arc<str>],
342        include_declaration: bool,
343        includes: crate::ReferenceIncludes,
344    ) -> Result<Vec<(Arc<str>, crate::Range)>, Cancelled> {
345        let key = self.reference_query_key(symbol, files, include_declaration, includes);
346        if let Some(hit) = self.index.ref_queries.get(&key) {
347            return Ok(hit);
348        }
349        let stale = catch(|| self.stale_reference_candidates(symbol, files))?;
350        if !stale.is_empty() {
351            self.commit_reference_candidates(&stale)?;
352        }
353        let out = catch(|| self.read_references(symbol, files, include_declaration, includes))?;
354        self.ensure_no_retire_since(key.generation)?;
355        self.memoize_references(key, &out);
356        Ok(out)
357    }
358
359    /// `Err` when the owner retired a file since `generation`: postings read
360    /// in between may lack that file, and its replacing write is under way.
361    pub(super) fn ensure_no_retire_since(
362        &self,
363        generation: QueryGeneration,
364    ) -> Result<(), Cancelled> {
365        if self.index.retire_seq() == generation.2 {
366            Ok(())
367        } else {
368            Err(Cancelled::PendingWrite)
369        }
370    }
371
372    /// `use` import items (`use Foo\Bar;`, `use function ...;`,
373    /// `use const ...;`) for `symbol` — [`Self::indexed_references_to`] with
374    /// [`crate::ReferenceIncludes::UseImports`].
375    pub fn indexed_use_import_locations(
376        &self,
377        symbol: &crate::Name,
378        files: &[Arc<str>],
379    ) -> Result<Vec<(Arc<str>, crate::Range)>, Cancelled> {
380        self.indexed_references_to(symbol, files, false, crate::ReferenceIncludes::UseImports)
381    }
382
383    pub(super) fn reference_query_key(
384        &self,
385        symbol: &crate::Name,
386        files: &[Arc<str>],
387        include_declaration: bool,
388        includes: crate::ReferenceIncludes,
389    ) -> RefQueryCacheKey {
390        RefQueryCacheKey {
391            symbol: symbol.codebase_key(),
392            include_declaration,
393            includes,
394            generation: self.query_cache_generation(),
395            files_hash: hash_files(files),
396        }
397    }
398
399    /// Cache `out` unless the generation moved while computing it (an edit,
400    /// or a defs commit growing the subtype index — possibly this query's
401    /// own): such a key can never be looked up again, and the next identical
402    /// query recomputes once against settled state and caches then.
403    pub(super) fn memoize_references(
404        &self,
405        key: RefQueryCacheKey,
406        out: &[(Arc<str>, crate::Range)],
407    ) {
408        let generation = self.query_cache_generation();
409        if generation == key.generation {
410            self.index.ref_queries.insert(generation, key, out);
411        }
412    }
413
414    /// Freshness pass: candidates whose postings are not exact for their
415    /// current text. Files not registered as `SourceFile` inputs are
416    /// skipped. Never-committed files — no commit mark, hence no postings at
417    /// all (every mark drop accompanies a posting clear) — are further gated
418    /// on their text mentioning the symbol's name: such a file can neither
419    /// hold stale postings nor produce new ones, so a cold query on a common
420    /// name skips the bulk of the workspace instead of analyzing it. A
421    /// LIVE-analyzed file stale only by generation gets the same gate.
422    /// Everything else — a genuinely edited file, or a commit seeded by an
423    /// unverified disk-cache replay — re-analyzes unconditionally. Same
424    /// discipline as [`Self::commit_defs_for_matching`] on the defs index.
425    ///
426    /// The whole gate answers from the persistent mention index: every
427    /// needle is admitted, so a recorded mention set answers with lookups
428    /// and only a never-scanned or since-edited file pays one scan against
429    /// the whole universe, recorded for every later consumer. A needle new
430    /// to the universe epoch-invalidates older recordings for itself only.
431    ///
432    /// Intentionally serial: each request already runs on its caller
433    /// thread, and putting every concurrent request back onto the shared
434    /// rayon pool lets an index batch monopolize the workers.
435    pub(super) fn stale_reference_candidates(
436        &self,
437        symbol: &crate::Name,
438        files: &[Arc<str>],
439    ) -> Vec<Arc<str>> {
440        let db = &self.db;
441        let gate = self.reference_gate(symbol);
442        let has_needles = !gate.idents.is_empty() || !gate.raw.is_empty();
443        let (mention_queries, mention_scanner) = if has_needles {
444            db.add_literal_mention_names(gate.idents.iter().map(|s| s.as_str()));
445            db.add_raw_mention_needles(gate.raw.iter().map(|s| s.as_str()));
446            let queries: Vec<_> = gate
447                .idents
448                .iter()
449                .chain(gate.raw.iter())
450                .filter_map(|s| db.prepare_class_mention_query(s))
451                .collect();
452            (queries, db.class_mention_scanner())
453        } else {
454            (Vec::new(), None)
455        };
456        // Admission guarantees a query per needle and a non-empty universe;
457        // anything else is defensive — the gate then admits every candidate
458        // (analyze rather than skip, the conservative direction).
459        let gate_complete = mention_queries.len() == gate.idents.len() + gate.raw.len()
460            && mention_scanner.is_some();
461        let committed_any: HashSet<Arc<str>> =
462            self.index.ref_committed_keys().into_iter().collect();
463        let current_gen = self.index_generation;
464        let mut stale = Vec::new();
465        let mut scanned: Vec<MentionScanRecord> = Vec::new();
466        for f in files {
467            let Some(sf) = db.lookup_source_file(f.as_ref()) else {
468                continue;
469            };
470            let text = sf.text(db as &dyn MirDatabase);
471            if self.index.is_ref_committed(f.as_ref(), text, current_gen) {
472                continue;
473            }
474            if committed_any.contains(f.as_ref())
475                && !self
476                    .index
477                    .ref_commit_stale_by_generation_only(f.as_ref(), text)
478            {
479                stale.push(f.clone());
480                continue;
481            }
482            if has_needles && gate_complete {
483                // Any needle answering `true` admits the file; an
484                // unanswerable one forces the single recorded scan, which
485                // settles every needle at once.
486                let mut answer = Some(false);
487                for q in &mention_queries {
488                    match db.class_mention_answer(f.as_ref(), q, text) {
489                        Some(true) => {
490                            answer = Some(true);
491                            break;
492                        }
493                        Some(false) => {}
494                        None => answer = None,
495                    }
496                }
497                match answer {
498                    Some(true) => {}
499                    Some(false) => continue,
500                    None => {
501                        let scanner = mention_scanner
502                            .as_ref()
503                            .expect("gate_complete implies a scanner");
504                        let names = scanner.scan(text);
505                        let hit = mention_queries
506                            .iter()
507                            .any(|q| names.binary_search(&q.name).is_ok());
508                        scanned.push((f.clone(), text.clone(), names));
509                        if hit {
510                            stale.push(f.clone());
511                        }
512                        continue;
513                    }
514                }
515            }
516            stale.push(f.clone());
517        }
518        // Recorded regardless of how the query proceeds: each is a complete,
519        // current mention set for its file.
520        if let Some(scanner) = &mention_scanner {
521            for (file, text, names) in scanned {
522                db.set_file_class_mentions(&file, &text, scanner.epoch(), names);
523            }
524        }
525        stale
526    }
527
528    /// Analyze `stale` and commit their postings and class edges.
529    pub(super) fn commit_reference_candidates(&self, stale: &[Arc<str>]) -> Result<(), Cancelled> {
530        self.warm_pass(stale, &crate::IndexCancel::new()).map(drop)
531    }
532
533    /// Analyze `files` and commit their reference postings, class edges and
534    /// mention scans to the index shared with the owner, so later queries
535    /// find them fresh. `Ok(false)` when `cancel` stopped the pass first.
536    ///
537    /// A commit with unresolved names stays tied to this snapshot's
538    /// [`Self::index_generation`], so the next workspace change re-opens it.
539    pub fn warm_files(
540        &self,
541        files: &[Arc<str>],
542        cancel: &crate::IndexCancel,
543    ) -> Result<bool, Cancelled> {
544        Ok(self.warm_pass(files, cancel)?.is_some())
545    }
546
547    pub(super) fn warm_pass(
548        &self,
549        files: &[Arc<str>],
550        cancel: &crate::IndexCancel,
551    ) -> Result<Option<Vec<AnalyzedFile>>, Cancelled> {
552        let Some(mut pass) = self.stage_warm(files, cancel)? else {
553            return Ok(None);
554        };
555        self.commit_warm(&mut pass)?;
556        Ok(Some(pass.analyzed))
557    }
558
559    /// Freezes the workspace index on a pass-scoped clone (borrow-only
560    /// symbol lookups + pass-shared subtype cache). On-demand loads leave the
561    /// index untouched and an owner write cancels the pass, so the frozen
562    /// view can't go stale. Same discipline as the batch body pass.
563    fn stage_warm(
564        &self,
565        files: &[Arc<str>],
566        cancel: &crate::IndexCancel,
567    ) -> Result<Option<WarmPass>, Cancelled> {
568        catch(|| {
569            let mut db = self.db.clone();
570            db.freeze_workspace_index();
571            let mention_scanner = db.class_mention_scanner();
572            let cache = self.cache.as_deref();
573            let mut analyzed = Vec::with_capacity(files.len());
574            for path in files {
575                if cancel.is_cancelled() {
576                    return None;
577                }
578                analyzed.extend(self.index.stage_analyzed(
579                    &db,
580                    cache,
581                    mention_scanner.as_deref(),
582                    path,
583                ));
584            }
585            Some(WarmPass {
586                mention_scanner,
587                analyzed,
588            })
589        })
590    }
591
592    /// Marks record the exact text Arc each file was analyzed against, so an
593    /// owner text write racing the pass leaves the file stale, never fresh.
594    fn commit_warm(&self, pass: &mut WarmPass) -> Result<(), Cancelled> {
595        self.index.commit_analyzed(
596            &self.db,
597            self.cache.as_deref(),
598            pass.mention_scanner.as_deref(),
599            &mut pass.analyzed,
600            self.stamp(),
601        )
602    }
603
604    /// Posting lookup for `symbol`, filtered to the candidate scope.
605    ///
606    /// Member symbols resolve against the queried class plus its hierarchy
607    /// (mir records member refs under the *declaring* class, so a query on
608    /// an interface method must include implementor keys and vice versa).
609    /// Name-only fallback postings — receivers whose type couldn't be
610    /// resolved — are consulted only when the typed keys produce nothing:
611    /// exact results when resolution succeeds, by-name matches when nothing
612    /// resolves. `__construct` stays exact: `new Sub()` invokes
613    /// `Sub::__construct` even when only a parent declares one, so hierarchy
614    /// fan-out would wrongly return subtype instantiation sites for a parent
615    /// query.
616    pub(super) fn read_references(
617        &self,
618        symbol: &crate::Name,
619        files: &[Arc<str>],
620        include_declaration: bool,
621        includes: crate::ReferenceIncludes,
622    ) -> Vec<(Arc<str>, crate::Range)> {
623        let key = symbol.codebase_key();
624        let hierarchy: Vec<String> = match symbol {
625            crate::Name::Method { class, name } => {
626                if class.is_empty() {
627                    Vec::new()
628                } else if name.as_ref() == "__construct" {
629                    vec![class.trim_start_matches('\\').to_string()]
630                } else {
631                    self.member_hierarchy_classes(class.as_ref())
632                }
633            }
634            crate::Name::Property { class, .. } | crate::Name::ClassConstant { class, .. } => {
635                if class.is_empty() {
636                    Vec::new()
637                } else {
638                    self.member_hierarchy_classes(class.as_ref())
639                }
640            }
641            _ => Vec::new(),
642        };
643        let scope: HashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
644        let read_symbol_key = |symbol_key: &str| -> Vec<(Arc<str>, crate::Range)> {
645            self.db
646                .reference_locations(symbol_key)
647                .into_iter()
648                .filter(|(file, ..)| scope.contains(file.as_ref()))
649                .map(|(file, line, col_start, col_end)| {
650                    (file, span_range(line, col_start as u32, col_end as u32))
651                })
652                .collect()
653        };
654        let mut scratch_key = String::new();
655        let mut read_composed_key = |prefix: &str, middle: &str, separator: &str, suffix: &str| {
656            scratch_key.clear();
657            scratch_key.reserve(prefix.len() + middle.len() + separator.len() + suffix.len());
658            scratch_key.push_str(prefix);
659            scratch_key.push_str(middle);
660            scratch_key.push_str(separator);
661            scratch_key.push_str(suffix);
662            read_symbol_key(&scratch_key)
663        };
664        let mut out: Vec<(Arc<str>, crate::Range)> = Vec::new();
665        if !matches!(includes, crate::ReferenceIncludes::UseImports) {
666            let member_prefix = match symbol {
667                crate::Name::Method { name, .. } => Some(("meth:", name)),
668                crate::Name::Property { name, .. } => Some(("prop:", name)),
669                crate::Name::ClassConstant { name, .. } => Some(("cnst:", name)),
670                _ => None,
671            };
672            match member_prefix {
673                Some((prefix, name)) => {
674                    for class in &hierarchy {
675                        out.extend(read_composed_key(prefix, class, "::", name));
676                    }
677                }
678                None => out.extend(read_symbol_key(key.as_str())),
679            }
680        }
681        if !matches!(includes, crate::ReferenceIncludes::Plain) {
682            out.extend(read_composed_key("use:", key.as_str(), "", ""));
683        }
684        if matches!(includes, crate::ReferenceIncludes::Plain) && out.is_empty() {
685            match symbol {
686                crate::Name::Method { name, .. } => {
687                    out = read_composed_key("methname:", name.as_ref(), "", "");
688                }
689                crate::Name::Property { name, .. } => {
690                    out = read_composed_key("propname:", name.as_ref(), "", "");
691                }
692                _ => {}
693            }
694        }
695        out.sort_by(|a, b| {
696            a.0.cmp(&b.0)
697                .then(a.1.start.line.cmp(&b.1.start.line))
698                .then(a.1.start.column.cmp(&b.1.start.column))
699        });
700        out.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
701
702        if include_declaration {
703            let decls: Vec<(Arc<str>, crate::Range)> = match symbol {
704                crate::Name::Method { class, name }
705                | crate::Name::Property { class, name }
706                | crate::Name::ClassConstant { class, name } => {
707                    if class.is_empty() {
708                        // Unknown owner: declarations by name, recorded as
709                        // `methdecl:`/`propdecl:`/`cnstdecl:` postings during
710                        // class/trait/interface/enum analysis.
711                        let prefix = match symbol {
712                            crate::Name::Method { .. } => "methdecl:",
713                            crate::Name::Property { .. } => "propdecl:",
714                            _ => "cnstdecl:",
715                        };
716                        read_composed_key(prefix, name.as_ref(), "", "")
717                    } else {
718                        self.member_decl_sites(&hierarchy, symbol)
719                    }
720                }
721                _ => self.declaration_name_range(symbol).into_iter().collect(),
722            };
723            for (file, range) in decls {
724                if scope.contains(file.as_ref())
725                    && !out.iter().any(|(f, r)| *f == file && *r == range)
726                {
727                    out.push((file, range));
728                }
729            }
730        }
731        out
732    }
733
734    /// The queried class plus every class its members' references could be
735    /// keyed under: resolved ancestors (a call on a subtype instance records
736    /// the declaring ancestor) and transitive subtypes including trait users
737    /// (a call on a subtype that overrides records the subtype). Display-form
738    /// FQCNs, deduplicated case-insensitively.
739    fn member_hierarchy_classes(&self, class_fqn: &str) -> Vec<String> {
740        let target = class_fqn.trim_start_matches('\\');
741        let mut out = vec![target.to_string()];
742        let db = &self.db;
743        let here = crate::db::Fqcn::from_str(db, target);
744        // Ancestors include the class itself first.
745        let ancestors = crate::db::class_ancestors_by_fqcn(db, here)
746            .iter()
747            .skip(1)
748            .map(|a| a.trim_start_matches('\\').to_string())
749            .collect::<Vec<_>>();
750        out.extend(ancestors);
751        out.extend(
752            db.subtype_sites_of(target, true)
753                .into_iter()
754                .map(|s| s.fqcn.trim_start_matches('\\').to_string()),
755        );
756        let mut seen: HashSet<String> = HashSet::default();
757        out.retain(|c| seen.insert(c.to_ascii_lowercase()));
758        out
759    }
760
761    /// Own-member declaration sites for `symbol` across `classes`: each class
762    /// that itself declares the member (not inherited) contributes its name
763    /// token. Kind-specific lookups — a class often declares a property and a
764    /// method with the same short name, and `member_location` can't tell them
765    /// apart.
766    fn member_decl_sites(
767        &self,
768        classes: &[String],
769        symbol: &crate::Name,
770    ) -> Vec<(Arc<str>, crate::Range)> {
771        let mut out: Vec<(Arc<str>, crate::Range)> = Vec::new();
772        let db = &self.db;
773        for class in classes {
774            let here = crate::db::Fqcn::from_str(db, class);
775            let (loc, needle) = match symbol {
776                crate::Name::Method { name, .. } => {
777                    let Some(m) = crate::db::find_method_in_class(db, here, name) else {
778                        continue;
779                    };
780                    (m.location.clone(), name.to_string())
781                }
782                crate::Name::Property { name, .. } => {
783                    let Some(p) = crate::db::find_property_in_class(db, here, name) else {
784                        continue;
785                    };
786                    (p.location.clone(), name.to_string())
787                }
788                crate::Name::ClassConstant { name, .. } => {
789                    let Some(c) = crate::db::find_class_constant_in_class(db, here, name) else {
790                        continue;
791                    };
792                    (c.location.clone(), name.to_string())
793                }
794                _ => continue,
795            };
796            let Some(loc) = loc else { continue };
797            let range = self.refine_location_to_name(&loc, &needle);
798            out.push((loc.file.clone(), range));
799        }
800        out
801    }
802
803    /// The symbol's declaration site among already-loaded state, narrowed
804    /// from the collector's whole-declaration span to the declared name's
805    /// own token (matching the span shape of recorded references).
806    pub(super) fn declaration_name_range(
807        &self,
808        symbol: &crate::Name,
809    ) -> Option<(Arc<str>, crate::Range)> {
810        if let crate::Name::GlobalConstant(fqn) = symbol {
811            return self.global_constant_decl_range(fqn);
812        }
813        let loc = self.definition_of_unguarded(symbol).ok()?;
814        let short = match symbol {
815            crate::Name::Class(f) | crate::Name::Function(f) | crate::Name::GlobalConstant(f) => {
816                crate::db::subtype_index::short_name_of(f)
817            }
818            crate::Name::Method { name, .. }
819            | crate::Name::Property { name, .. }
820            | crate::Name::ClassConstant { name, .. } => name.as_ref(),
821        };
822        // Property declarations carry a `$` sigil in source, but reference
823        // ranges cover the bare name; the word-boundary search below lands on
824        // the name right after the sigil.
825        let range = self.refine_location_to_name(&loc, short);
826        Some((loc.file.clone(), range))
827    }
828
829    /// Narrow a whole-declaration [`mir_types::Location`] to the first
830    /// word-boundary occurrence of `needle` inside its line span. Falls back
831    /// to the location's own coordinates when the text is unavailable or the
832    /// name doesn't appear (e.g. stub-only declarations).
833    fn refine_location_to_name(&self, loc: &mir_types::Location, needle: &str) -> crate::Range {
834        let fallback = span_range(loc.line, loc.col_start as u32, loc.col_end as u32);
835        let Some(text) = self
836            .db
837            .lookup_source_file(loc.file.as_ref())
838            .map(|sf| sf.text(&self.db as &dyn MirDatabase).clone())
839        else {
840            return fallback;
841        };
842        let needle_chars = needle.chars().count() as u32;
843        let first_line = loc.line.saturating_sub(1) as usize;
844        // Exact-case first: PHP property/constant names are case-sensitive
845        // and an early case-insensitive hit can land on an unrelated token
846        // (a type hint sharing the name). Case-insensitive second, for
847        // method/class needles that arrive lowercase-normalized.
848        for case_insensitive in [false, true] {
849            for (idx, line_text) in text.lines().enumerate().skip(first_line) {
850                let line_no = idx as u32 + 1;
851                if line_no > loc.line_end {
852                    break;
853                }
854                let min_col = if line_no == loc.line {
855                    loc.col_start as usize
856                } else {
857                    0
858                };
859                if let Some(col) = identifier_char_col(line_text, needle, min_col, case_insensitive)
860                {
861                    return span_range(line_no, col, col + needle_chars);
862                }
863            }
864        }
865        fallback
866    }
867
868    /// Declaring line of a global constant. Constant slices carry no stored
869    /// location, so this finds the declaring file via the workspace constants
870    /// index and locates the `const NAME` / `define('NAME'` line textually.
871    fn global_constant_decl_site(&self, fqn: &str) -> Option<ConstantDeclSite> {
872        let short = crate::db::subtype_index::short_name_of(fqn);
873        let db = &self.db;
874        let index = crate::db::workspace_index(db);
875        let loc = index.constant_loc(mir_types::Name::from(fqn.trim_start_matches('\\')))?;
876        let file = loc.file().path(db).clone();
877        let sf = db.lookup_source_file(file.as_ref())?;
878        let text = sf.text(db as &dyn MirDatabase);
879        for (idx, line) in text.lines().enumerate() {
880            let trimmed = line.trim_start();
881            let is_decl_line = trimmed.starts_with("const ")
882                || trimmed.contains("define(")
883                || trimmed.contains("define (");
884            if !is_decl_line {
885                continue;
886            }
887            if let Some(name_col) = identifier_char_col(line, short, 0, false) {
888                let stmt_end = match line.find(';') {
889                    Some(byte) => line[..=byte].chars().count(),
890                    None => line.chars().count(),
891                };
892                return Some(ConstantDeclSite {
893                    file,
894                    line: idx as u32 + 1,
895                    stmt_col: (line.chars().count() - trimmed.chars().count()) as u32,
896                    stmt_end: stmt_end as u32,
897                    name_col,
898                    name_len: short.chars().count() as u32,
899                });
900            }
901        }
902        None
903    }
904
905    fn global_constant_decl_range(&self, fqn: &str) -> Option<(Arc<str>, crate::Range)> {
906        let site = self.global_constant_decl_site(fqn)?;
907        let range = span_range(site.line, site.name_col, site.name_col + site.name_len);
908        Some((site.file, range))
909    }
910
911    /// Whole-statement location of a user global constant's declaration.
912    fn global_constant_location(&self, fqn: &str) -> Option<mir_types::Location> {
913        let site = self.global_constant_decl_site(fqn)?;
914        Some(mir_types::Location {
915            file: site.file,
916            line: site.line,
917            line_end: site.line,
918            col_start: site.stmt_col as u16,
919            col_end: site.stmt_end as u16,
920        })
921    }
922
923    /// Files declaring transitive subclasses of `class_fqn` anywhere in the
924    /// workspace, excluding `class_fqn`'s own declaring file — the caller
925    /// adds it. Subclasses are matched by resolved FQCN, so `extends \Ns\Base`
926    /// and aliased `use` forms are all found.
927    pub fn subtype_files(&self, class_fqn: &str) -> Result<Vec<Arc<str>>, Cancelled> {
928        let files = self.db.source_file_paths();
929        let mut out: Vec<Arc<str>> = self
930            .indexed_subtype_classes(class_fqn, &files, false)?
931            .into_iter()
932            .map(|s| s.file)
933            .collect();
934        out.sort();
935        out.dedup();
936        Ok(out)
937    }
938
939    /// Transitive subtypes of `class_fqn` (classes/interfaces/enums whose
940    /// resolved ancestor chain reaches it), answered from the maintained
941    /// subtype edge index.
942    ///
943    /// `files` is the host's candidate scope for the on-demand completeness
944    /// pass: per BFS round, not-yet-committed files whose text mentions a
945    /// frontier short name get their definitions committed, so results are
946    /// complete even before a background sweep has covered the workspace.
947    /// That short-name gate is only candidate discovery; subtype identity is
948    /// resolved from the edge index by exact canonical FQCN. Committed files
949    /// answer from the index with no parsing at all.
950    ///
951    /// `include_trait_users` also counts `use Trait;` composition as a
952    /// subtype edge (visibility-scoping semantics); leave it off for
953    /// goto-implementation semantics (extends/implements only).
954    ///
955    /// Memoized per `(class_fqn, include_trait_users, files, query
956    /// generation)`, same rationale as [`Self::indexed_references_to`].
957    pub fn indexed_subtype_classes(
958        &self,
959        class_fqn: &str,
960        files: &[Arc<str>],
961        include_trait_users: bool,
962    ) -> Result<Vec<SubtypeClassSite>, Cancelled> {
963        catch(|| {
964            let key = SubtypeQueryCacheKey {
965                class_fqn: class_fqn.trim_start_matches('\\').to_ascii_lowercase(),
966                include_trait_users,
967                generation: self.query_cache_generation(),
968                files_hash: hash_files(files),
969            };
970            if let Some(hit) = self.index.subtype_queries.get(&key) {
971                return hit;
972            }
973            let out = self.subtype_classes_uncached(class_fqn, files, include_trait_users);
974            if let Err(cancelled) = self.ensure_no_retire_since(key.generation) {
975                unwind(cancelled);
976            }
977            // See `memoize_references` for why a moved generation skips.
978            let generation = self.query_cache_generation();
979            if generation == key.generation {
980                self.index.subtype_queries.insert(generation, key, &out);
981            }
982            out
983        })
984    }
985
986    fn subtype_classes_uncached(
987        &self,
988        class_fqn: &str,
989        files: &[Arc<str>],
990        include_trait_users: bool,
991    ) -> Vec<SubtypeClassSite> {
992        let db = &self.db;
993        let mut scanned: HashSet<String> = HashSet::default();
994        let mut pending: Vec<String> = vec![class_fqn.trim_start_matches('\\').to_string()];
995        let mut sites: Vec<crate::db::SubtypeSite> = Vec::new();
996        while !pending.is_empty() {
997            // Short names only discover stale/uncommitted files worth
998            // collecting; the result itself comes from the edge index.
999            let needles: Vec<String> = pending
1000                .drain(..)
1001                .filter(|f| scanned.insert(f.clone()))
1002                .map(|f| crate::db::subtype_index::short_name_of(&f).to_string())
1003                .collect();
1004            if !needles.is_empty() {
1005                self.commit_defs_for_matching(files, &needles);
1006            }
1007            sites = db.subtype_sites_of(class_fqn, include_trait_users);
1008            pending = sites
1009                .iter()
1010                .map(|s| s.fqcn.trim_start_matches('\\').to_string())
1011                .filter(|f| !scanned.contains(f))
1012                .collect();
1013        }
1014        let mut out: Vec<SubtypeClassSite> = sites
1015            .into_iter()
1016            .filter_map(|s| {
1017                let loc = s.location.as_ref()?;
1018                let short = crate::db::subtype_index::short_name_of(&s.fqcn).to_string();
1019                let range = self.refine_location_to_name(loc, &short);
1020                Some(SubtypeClassSite {
1021                    fqcn: s.fqcn,
1022                    kind: s.kind,
1023                    is_abstract: s.is_abstract,
1024                    file: s.file,
1025                    range,
1026                })
1027            })
1028            .collect();
1029        // Anonymous classes never reach the definition collector; their
1030        // `new class implements X {}` sites are recorded under the resolved
1031        // canonical parent FQCN during body analysis.
1032        let root_lc = class_fqn.trim_start_matches('\\').to_ascii_lowercase();
1033        let scope: HashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
1034        for (file, line, cs, ce) in db.reference_locations(&format!("impl:{root_lc}")) {
1035            if !scope.contains(file.as_ref()) {
1036                continue;
1037            }
1038            let range = span_range(line, cs as u32, ce as u32);
1039            if out.iter().any(|s| s.file == file && s.range == range) {
1040                continue;
1041            }
1042            out.push(SubtypeClassSite {
1043                fqcn: Arc::from("class@anonymous"),
1044                kind: crate::db::ClassLikeKind::Class,
1045                is_abstract: false,
1046                file,
1047                range,
1048            });
1049        }
1050        out
1051    }
1052
1053    /// Concrete implementations of `class_fqn::method` across its transitive
1054    /// subtypes: the same-named non-abstract method available to each subtype
1055    /// (its own declaration, or one inherited/composed from a parent, trait,
1056    /// or mixin), as `(subtype fqcn, file, name range)`. Subtypes resolving to
1057    /// the same declaring location collapse to a single entry.
1058    #[allow(clippy::type_complexity)]
1059    pub fn indexed_method_implementations(
1060        &self,
1061        class_fqn: &str,
1062        method: &str,
1063        files: &[Arc<str>],
1064    ) -> Result<Vec<(Arc<str>, Arc<str>, crate::Range)>, Cancelled> {
1065        let subs = self.indexed_subtype_classes(class_fqn, files, false)?;
1066        catch(|| {
1067            let db = &self.db;
1068            let mut out: Vec<(Arc<str>, Arc<str>, crate::Range)> = Vec::new();
1069            for sub in &subs {
1070                let here = crate::db::Fqcn::from_str(db, sub.fqcn.as_ref());
1071                let Some((_, m)) = crate::db::find_method_in_chain(db, here, method) else {
1072                    continue;
1073                };
1074                if m.is_abstract {
1075                    continue;
1076                }
1077                let Some(loc) = m.location.as_ref() else {
1078                    continue;
1079                };
1080                let range = self.refine_location_to_name(loc, method);
1081                out.push((sub.fqcn.clone(), loc.file.clone(), range));
1082            }
1083            out.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.start.line.cmp(&b.2.start.line)));
1084            out.dedup_by(|a, b| a.1 == b.1 && a.2 == b.2);
1085            out
1086        })
1087    }
1088
1089    /// Commit definitions (class edges + freshness) for every file in `files`
1090    /// that is stale (committed against older text) or that has never been
1091    /// committed and mentions one of `shorts` as a whole identifier.
1092    ///
1093    /// The textual gate answers from the shared per-file mention cache — the
1094    /// same one [`Self::indexed_references_to`]'s gate populates — so a file
1095    /// scanned by either consumer answers the other with a set lookup. A file
1096    /// the cache can't answer for is scanned once against the whole name
1097    /// universe and recorded.
1098    ///
1099    /// Unwinds with [`Cancelled`] when a file was retired after this
1100    /// snapshot, once every other file is committed.
1101    fn commit_defs_for_matching(&self, files: &[Arc<str>], shorts: &[String]) {
1102        use rayon::prelude::*;
1103
1104        let committed_any: HashSet<Arc<str>> =
1105            self.index.defs_committed_keys().into_iter().collect();
1106        // Admit the frontier names before preparing, so every needle gets a
1107        // real query (a declared class's short name is already in the
1108        // universe from indexing — admission then changes nothing).
1109        self.db
1110            .add_literal_mention_names(shorts.iter().map(|s| s.as_str()));
1111        let queries: Vec<_> = shorts
1112            .iter()
1113            .filter_map(|s| self.db.prepare_class_mention_query(s))
1114            .collect();
1115        let mention_scanner = self.db.class_mention_scanner();
1116        // Admission guarantees a query per needle and a non-empty universe;
1117        // anything else is defensive — the gate then admits every candidate
1118        // (recommit rather than skip, the conservative direction).
1119        let use_mentions = queries.len() == shorts.len() && mention_scanner.is_some();
1120        type Work = (Arc<str>, Arc<str>, Vec<crate::db::SubtypeEntry>);
1121        let index = &*self.index;
1122        let results: Vec<(Option<Work>, Option<MentionScanRecord>)> = files
1123            .par_iter()
1124            .map_with(self.db.clone(), |db, path| {
1125                let Some(sf) = db.lookup_source_file(path.as_ref()) else {
1126                    return (None, None);
1127                };
1128                let text = sf.text(&*db as &dyn MirDatabase).clone();
1129                if index.is_defs_committed(path.as_ref(), &text) {
1130                    return (None, None);
1131                }
1132                // Never-committed files must mention a frontier name; stale
1133                // (previously committed) files recommit unconditionally —
1134                // their classes may have re-parented.
1135                let mut scan_rec: Option<MentionScanRecord> = None;
1136                if use_mentions && !committed_any.contains(path.as_ref()) {
1137                    let mut answer = Some(false);
1138                    for q in &queries {
1139                        match db.class_mention_answer(path.as_ref(), q, &text) {
1140                            Some(true) => {
1141                                answer = Some(true);
1142                                break;
1143                            }
1144                            Some(false) => {}
1145                            None => answer = None,
1146                        }
1147                    }
1148                    let hit = match answer {
1149                        Some(hit) => hit,
1150                        None => {
1151                            // Uncoverable entry: one scan answers every
1152                            // query and is recorded below.
1153                            let scanner = mention_scanner.as_ref().unwrap();
1154                            let names = scanner.scan(&text);
1155                            let hit = queries.iter().any(|q| names.binary_search(&q.name).is_ok());
1156                            scan_rec = Some((path.clone(), text.clone(), names));
1157                            hit
1158                        }
1159                    };
1160                    if !hit {
1161                        return (None, scan_rec);
1162                    }
1163                }
1164                let defs = crate::db::collect_file_definitions(&*db as &dyn MirDatabase, sf);
1165                let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
1166                (Some((path.clone(), text, entries)), scan_rec)
1167            })
1168            .collect();
1169        // Record the fallback scans regardless of hit/miss: each is a
1170        // complete, current mention set for its file, so the next round's
1171        // (and the references gate's) checks become set lookups.
1172        let mut refused = Ok(());
1173        for (work, scan_rec) in results {
1174            if let (Some(scanner), Some((file, text, names))) = (&mention_scanner, scan_rec) {
1175                self.db
1176                    .set_file_class_mentions(&file, &text, scanner.epoch(), names);
1177            }
1178            if let Some((file, text, entries)) = work {
1179                if let Err(cancelled) = self.index.commit_class_edges(
1180                    &self.db,
1181                    &file,
1182                    &text,
1183                    entries,
1184                    self.retire_epoch,
1185                ) {
1186                    refused = Err(cancelled);
1187                }
1188            }
1189        }
1190        if let Err(cancelled) = refused {
1191            unwind(cancelled);
1192        }
1193    }
1194
1195    /// Class-level issues (inheritance violations, abstract-method gaps,
1196    /// override incompatibilities) for `files`.
1197    ///
1198    /// These checks are cross-file by nature and are not emitted by
1199    /// [`Self::analyze`]. Circular-inheritance checks always run against the
1200    /// full workspace graph regardless of the `files` filter — a cycle is a
1201    /// workspace-wide problem.
1202    pub fn class_issues(&self, files: &[Arc<str>]) -> Result<Vec<crate::Issue>, Cancelled> {
1203        catch(|| {
1204            let db = &self.db;
1205            let file_set: HashSet<Arc<str>> = files.iter().cloned().collect();
1206            let file_data: Vec<(Arc<str>, Arc<str>)> = files
1207                .iter()
1208                .filter_map(|f| {
1209                    let sf = db.lookup_source_file(f)?;
1210                    Some((f.clone(), sf.text(db as &dyn MirDatabase).clone()))
1211                })
1212                .collect();
1213            crate::class::ClassAnalyzer::with_files(db, file_set, &file_data).analyze_all()
1214        })
1215    }
1216
1217    /// Collector-phase issues (e.g. `BackedEnumCaseTypeMismatch`,
1218    /// `InvalidReadonlyPropertyDeclaration`, `InvalidDocblock`, and raw parse
1219    /// errors) for `files`.
1220    ///
1221    /// These are found while building a file's declaration slice, before body
1222    /// analysis or cross-file class checks run — neither [`Self::analyze`]
1223    /// nor [`Self::class_issues`] reports them, so a caller merging just those
1224    /// two sources silently drops every collector-time diagnostic.
1225    pub fn collector_issues(&self, files: &[Arc<str>]) -> Result<Vec<crate::Issue>, Cancelled> {
1226        catch(|| {
1227            files
1228                .iter()
1229                .filter_map(|f| self.db.lookup_source_file(f))
1230                .flat_map(|sf| {
1231                    crate::db::collect_file_definitions(&self.db, sf)
1232                        .issues
1233                        .as_ref()
1234                        .clone()
1235                })
1236                .collect()
1237        })
1238    }
1239
1240    /// Admission predicate for [`Self::stale_reference_candidates`]; see
1241    /// [`ReferenceGate`] and `reference_gate_needles`.
1242    ///
1243    /// Instance/static methods other than `__construct`/`__invoke` on a
1244    /// resolvable owner gate on the member name alone: every call site
1245    /// spells it (`->name(`, `::name(`), while the owner's short name would
1246    /// admit every file mentioning the class for any reason.
1247    /// `__construct` gates on the owner's short name plus the raw call tokens
1248    /// `->__construct` / `::__construct`. `__invoke` keeps the general
1249    /// owner/name gate because `$obj()` call sites do not spell `__invoke`.
1250    pub(super) fn reference_gate(&self, symbol: &crate::Name) -> ReferenceGate {
1251        ReferenceGate::for_symbol(&self.db, symbol)
1252    }
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257    use super::*;
1258    use crate::{AnalysisSession, IndexCancel, Name, ReferenceIncludes};
1259    use salsa::Database as _;
1260
1261    const BASE: &str =
1262        "<?php\nclass Base { public function run(): void {} public function stop(): void {} }\n";
1263    const CALLS_RUN: &str = "<?php\nfunction go(Base $b): void { $b->run(); }\n";
1264    const CALLS_STOP: &str = "<?php\nfunction go(Base $b): void { $b->stop(); }\n";
1265
1266    /// Pins the warm pass's commit landing after an owner text write has
1267    /// started but before it applies: the commit must not leave the file
1268    /// fresh for the incoming text.
1269    #[test]
1270    fn warm_commit_racing_a_text_write_leaves_the_file_stale() {
1271        let (mut session, files) = prepared_workspace(CALLS_RUN);
1272        let snap = session.snapshot();
1273        let mut pass = snap
1274            .stage_warm(&files[1..], &IndexCancel::new())
1275            .unwrap()
1276            .unwrap();
1277
1278        let caller = files[1].clone();
1279        let writer = std::thread::spawn(move || {
1280            session.upsert_source_file(caller, Arc::from(CALLS_STOP), salsa::Durability::LOW);
1281            session
1282        });
1283        await_pending_write(&snap);
1284        snap.commit_warm(&mut pass).unwrap();
1285        drop(snap);
1286        let mut session = writer.join().unwrap();
1287
1288        assert_eq!(callers_of(&mut session, &files, "stop"), [files[1].clone()]);
1289        assert!(callers_of(&mut session, &files, "run").is_empty());
1290    }
1291
1292    #[test]
1293    fn warm_commit_after_an_ingest_retired_the_file_is_refused() {
1294        let (session, files) = prepared_workspace(CALLS_RUN);
1295        let snap = session.snapshot();
1296        let mut pass = snap
1297            .stage_warm(&files[1..], &IndexCancel::new())
1298            .unwrap()
1299            .unwrap();
1300
1301        let caller = files[1].clone();
1302        let writer = std::thread::spawn(move || {
1303            let mut session = session;
1304            session.ingest_file(caller, Arc::from(CALLS_STOP));
1305            session
1306        });
1307        await_pending_write(&snap);
1308        assert!(snap.commit_warm(&mut pass).is_err());
1309        drop(snap);
1310        let mut session = writer.join().unwrap();
1311
1312        assert!(callers_of(&mut session, &files, "run").is_empty());
1313        assert_eq!(callers_of(&mut session, &files, "stop"), [files[1].clone()]);
1314    }
1315
1316    #[test]
1317    fn commits_after_invalidate_file_retired_the_file_are_refused() {
1318        const CHILD_CALLS_RUN: &str =
1319            "<?php\nclass Child extends Base {}\nfunction go(Base $b): void { $b->run(); }\n";
1320        let (session, files) = prepared_workspace(CHILD_CALLS_RUN);
1321        let snap = session.snapshot();
1322        let mut pass = snap
1323            .stage_warm(&files[1..], &IndexCancel::new())
1324            .unwrap()
1325            .unwrap();
1326        let parsed = php_rs_parser::parse(CHILD_CALLS_RUN);
1327
1328        let caller = files[1].clone();
1329        let writer = std::thread::spawn(move || {
1330            let mut session = session;
1331            session.invalidate_file(&caller);
1332            session
1333        });
1334        await_pending_write(&snap);
1335        assert!(snap.commit_warm(&mut pass).is_err());
1336        assert!(snap
1337            .analyze(
1338                files[1].clone(),
1339                CHILD_CALLS_RUN,
1340                &parsed.program,
1341                &parsed.source_map
1342            )
1343            .is_err());
1344        drop(snap);
1345        let mut session = writer.join().unwrap();
1346
1347        assert!(callers_of(&mut session, &files, "run").is_empty());
1348        assert!(session
1349            .indexed_subtype_classes("Base", &files, false)
1350            .is_empty());
1351    }
1352
1353    #[test]
1354    fn cancelled_reference_commit_reports_cancellation() {
1355        let (mut session, files) = prepared_workspace(CALLS_RUN);
1356        let snap = session.snapshot();
1357        let stale = snap.stale_reference_candidates(&Name::method("Base", "run"), &files);
1358        assert!(stale.contains(&files[1]));
1359
1360        let caller = files[1].clone();
1361        let writer = std::thread::spawn(move || {
1362            session.upsert_source_file(caller, Arc::from(CALLS_STOP), salsa::Durability::LOW);
1363            session
1364        });
1365        await_pending_write(&snap);
1366        assert!(snap.commit_reference_candidates(&stale).is_err());
1367        drop(snap);
1368        writer.join().unwrap();
1369    }
1370
1371    /// Top-level code only: ingesting it defines no symbols, so the ingest
1372    /// itself writes nothing to salsa.
1373    const ROUTES_CALLS_RUN: &str = "<?php\n(new Base())->run();\n";
1374
1375    /// A workspace whose `routes.php` is registered and committed by a
1376    /// references query but was never passed through `ingest_file` — the
1377    /// state of a file an editor opens after the initial scan.
1378    fn registered_routes_workspace() -> (AnalysisSession, Vec<Arc<str>>) {
1379        let mut session = AnalysisSession::new(PhpVersion::LATEST);
1380        let files: Vec<Arc<str>> = vec![Arc::from("base.php"), Arc::from("routes.php")];
1381        session.ingest_file(files[0].clone(), Arc::from(BASE));
1382        session.upsert_source_file(
1383            files[1].clone(),
1384            Arc::from(ROUTES_CALLS_RUN),
1385            salsa::Durability::LOW,
1386        );
1387        session.prepare_for_query(None);
1388        assert_eq!(callers_of(&mut session, &files, "run"), [files[1].clone()]);
1389        (session, files)
1390    }
1391
1392    /// Opening the file lands between a query's freshness pass and its
1393    /// posting read. Nothing cancels the query, so it must still see the
1394    /// file's postings — and must not memoize a result without them.
1395    #[test]
1396    fn ingest_of_unchanged_text_mid_query_keeps_references() {
1397        let (mut session, files) = registered_routes_workspace();
1398        let symbol = Name::method("Base", "run");
1399        let revision = session.text_revision();
1400
1401        let snap = session.snapshot();
1402        let key = snap.reference_query_key(&symbol, &files, false, ReferenceIncludes::Plain);
1403        assert!(snap.stale_reference_candidates(&symbol, &files).is_empty());
1404        session.ingest_file(files[1].clone(), Arc::from(ROUTES_CALLS_RUN));
1405        let out = snap.read_references(&symbol, &files, false, ReferenceIncludes::Plain);
1406        assert!(snap.ensure_no_retire_since(key.generation).is_ok());
1407        snap.memoize_references(key, &out);
1408        drop(snap);
1409
1410        assert_eq!(session.text_revision(), revision, "ingest wrote salsa");
1411        assert_eq!(
1412            out.into_iter().map(|(file, _)| file).collect::<Vec<_>>(),
1413            [files[1].clone()]
1414        );
1415        assert_eq!(callers_of(&mut session, &files, "run"), [files[1].clone()]);
1416    }
1417
1418    /// Any retire without a salsa write (e.g. `re_analyze_file` on unchanged
1419    /// text, between its clear and re-record) must cancel a query that read
1420    /// the cleared postings and keep its result out of the memo.
1421    #[test]
1422    fn query_spanning_a_retirement_is_cancelled_and_not_memoized() {
1423        let (mut session, files) = registered_routes_workspace();
1424        let symbol = Name::method("Base", "run");
1425
1426        let snap = session.snapshot();
1427        let key = snap.reference_query_key(&symbol, &files, false, ReferenceIncludes::Plain);
1428        assert!(snap.stale_reference_candidates(&symbol, &files).is_empty());
1429        session
1430            .index
1431            .retire_references(&session.db.salsa, files[1].as_ref());
1432        let out = snap.read_references(&symbol, &files, false, ReferenceIncludes::Plain);
1433        assert!(out.is_empty());
1434        assert!(snap.ensure_no_retire_since(key.generation).is_err());
1435        snap.memoize_references(key, &out);
1436        drop(snap);
1437
1438        let hits = session.ref_query_cache_hits();
1439        assert_eq!(callers_of(&mut session, &files, "run"), [files[1].clone()]);
1440        assert_eq!(session.ref_query_cache_hits(), hits);
1441    }
1442
1443    /// The cache-hit fast path replaces postings without a salsa write, so
1444    /// it must retire like the analysis path: a query spanning it cancels.
1445    #[test]
1446    fn re_analyze_file_cache_hit_cancels_spanning_queries() {
1447        let dir = tempfile::tempdir().unwrap();
1448        let mut session = AnalysisSession::new(PhpVersion::LATEST).with_cache_dir(dir.path());
1449        let files: Vec<Arc<str>> = vec![Arc::from("base.php"), Arc::from("caller.php")];
1450        let opts = crate::BatchOptions::new();
1451        session.ingest_file(files[0].clone(), Arc::from(BASE));
1452        session.re_analyze_file(&files[1], CALLS_RUN, &opts);
1453        session.prepare_for_query(None);
1454        assert_eq!(callers_of(&mut session, &files, "run"), [files[1].clone()]);
1455
1456        let symbol = Name::method("Base", "run");
1457        let snap = session.snapshot();
1458        let key = snap.reference_query_key(&symbol, &files, false, ReferenceIncludes::Plain);
1459        let revision = session.text_revision();
1460        session.re_analyze_file(&files[1], CALLS_RUN, &opts);
1461        assert_eq!(session.text_revision(), revision, "took the analysis path");
1462        assert!(snap.ensure_no_retire_since(key.generation).is_err());
1463        drop(snap);
1464
1465        assert_eq!(callers_of(&mut session, &files, "run"), [files[1].clone()]);
1466    }
1467
1468    fn prepared_workspace(caller_text: &str) -> (AnalysisSession, Vec<Arc<str>>) {
1469        let mut session = AnalysisSession::new(PhpVersion::LATEST);
1470        let files: Vec<Arc<str>> = vec![Arc::from("base.php"), Arc::from("caller.php")];
1471        session.ingest_file(files[0].clone(), Arc::from(BASE));
1472        session.ingest_file(files[1].clone(), Arc::from(caller_text));
1473        session.prepare_for_query(Some(&files[1]));
1474        (session, files)
1475    }
1476
1477    /// Spins until an owner write has tripped cancellation; the writer then
1478    /// waits for `snap` to drop before applying.
1479    fn await_pending_write(snap: &AnalysisSnapshot) {
1480        while catch(|| snap.db.unwind_if_revision_cancelled()).is_ok() {
1481            std::thread::yield_now();
1482        }
1483    }
1484
1485    fn callers_of(
1486        session: &mut AnalysisSession,
1487        files: &[Arc<str>],
1488        method: &str,
1489    ) -> Vec<Arc<str>> {
1490        session
1491            .indexed_references_to(
1492                &Name::method("Base", method),
1493                files,
1494                false,
1495                ReferenceIncludes::Plain,
1496                &|| false,
1497            )
1498            .unwrap()
1499            .into_iter()
1500            .map(|(file, _)| file)
1501            .collect()
1502    }
1503}