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