Skip to main content

mir_analyzer/session/
queries.rs

1use super::*;
2
3impl AnalysisSession {
4    /// Resolve a top-level symbol (class or function) to its declaration
5    /// location. Powers go-to-definition.
6    ///
7    /// **Side effects:** if the symbol isn't yet known, this may invoke the
8    /// configured [`crate::SourceProvider`] to fault in additional files and
9    /// mutate the salsa input set. Use [`Self::definition_of_cached`] for a
10    /// pure variant that only consults already-loaded state.
11    ///
12    /// Returns:
13    /// - `Ok(Location)` — symbol found with a source location
14    /// - `Err(NotFound)` — no such symbol in the codebase
15    /// - `Err(NoSourceLocation)` — symbol exists but has no recorded span
16    ///   (e.g. some stub-only declarations)
17    pub fn definition_of(
18        &self,
19        symbol: &crate::Name,
20    ) -> Result<mir_types::Location, crate::SymbolLookupError> {
21        // Trigger any necessary lazy-load mutations before snapshotting.
22        match symbol {
23            crate::Name::Class(fqcn) => {
24                let _ = self.load_class(fqcn.as_ref());
25            }
26            crate::Name::Function(fqn) => {
27                let _ = self.load_class(fqn.as_ref());
28            }
29            crate::Name::Method { class, .. }
30            | crate::Name::Property { class, .. }
31            | crate::Name::ClassConstant { class, .. } => {
32                let _ = self.load_class(class.as_ref());
33            }
34            _ => {}
35        }
36        self.definition_of_cached(symbol)
37    }
38
39    /// Pure variant of [`Self::definition_of`]. Never invokes the
40    /// [`crate::SourceProvider`] and never mutates salsa inputs; resolves
41    /// only against state already loaded by `set_file_text` / `ingest_file`.
42    /// Returns `Err(NotFound)` when the symbol isn't in the loaded set, even
43    /// if a resolver could in principle map it.
44    pub fn definition_of_cached(
45        &self,
46        symbol: &crate::Name,
47    ) -> Result<mir_types::Location, crate::SymbolLookupError> {
48        let db = self.snapshot_db();
49        match symbol {
50            crate::Name::Class(fqcn) => {
51                let here = crate::db::Fqcn::from_str(&db, fqcn.as_ref());
52                let class = crate::db::find_class_like(&db, here)
53                    .ok_or(crate::SymbolLookupError::NotFound)?;
54                class
55                    .location()
56                    .cloned()
57                    .ok_or(crate::SymbolLookupError::NoSourceLocation)
58            }
59            crate::Name::Function(fqn) => {
60                let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
61                let f = crate::db::find_function(&db, here)
62                    .ok_or(crate::SymbolLookupError::NotFound)?;
63                f.location
64                    .clone()
65                    .ok_or(crate::SymbolLookupError::NoSourceLocation)
66            }
67            crate::Name::Method { class, name }
68            | crate::Name::Property { class, name }
69            | crate::Name::ClassConstant { class, name } => {
70                crate::db::member_location(&db, class, name)
71                    .ok_or(crate::SymbolLookupError::NotFound)
72            }
73            crate::Name::GlobalConstant(_) => Err(crate::SymbolLookupError::NoSourceLocation),
74        }
75    }
76
77    /// Hover information for a symbol: type, docstring, and definition location.
78    ///
79    /// Use [`crate::FileAnalysis::symbol_at`] to find the symbol at a cursor
80    /// position, then build a [`crate::Name`] from its `kind`. This method
81    /// assembles the displayable hover data.
82    ///
83    /// **Side effects:** when `symbol`'s owning class isn't yet loaded, this
84    /// may invoke the configured [`crate::SourceProvider`] to fault in
85    /// dependencies. Use [`Self::hover_cached`] for a pure variant.
86    ///
87    /// Returns `Err(NotFound)` if the symbol doesn't exist. May still return
88    /// `Ok` with `docstring: None` or `definition: None` if those specific
89    /// pieces aren't available.
90    pub fn hover(
91        &self,
92        symbol: &crate::Name,
93    ) -> Result<crate::HoverInfo, crate::SymbolLookupError> {
94        // Trigger lazy loading for class-rooted symbols before snapshotting.
95        // No-op when the class is already known; ensures inherited member
96        // lookups have the chain present.
97        match symbol {
98            crate::Name::Class(fqcn) => {
99                self.load_class(fqcn.as_ref());
100            }
101            crate::Name::Method { class, .. }
102            | crate::Name::Property { class, .. }
103            | crate::Name::ClassConstant { class, .. } => {
104                // Fault in the owning class for navigation if the background
105                // indexer hasn't reached it yet. Its inheritance ancestors
106                // resolve through the (eagerly-built) workspace symbol index.
107                self.load_class(class.as_ref());
108            }
109            _ => {}
110        }
111        self.hover_cached(symbol)
112    }
113
114    /// Pure variant of [`Self::hover`]. Never invokes the
115    /// [`crate::SourceProvider`]; consults only the already-loaded db.
116    pub fn hover_cached(
117        &self,
118        symbol: &crate::Name,
119    ) -> Result<crate::HoverInfo, crate::SymbolLookupError> {
120        use mir_types::{Atomic, Type};
121        let db = self.snapshot_db();
122        match symbol {
123            crate::Name::Function(fqn) => {
124                let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
125                let f = crate::db::find_function(&db, here)
126                    .ok_or(crate::SymbolLookupError::NotFound)?;
127                let ty = f
128                    .return_type
129                    .as_deref()
130                    .cloned()
131                    .unwrap_or_else(Type::mixed);
132                let docstring = f.docstring.as_ref().map(|s| s.to_string());
133                Ok(crate::HoverInfo {
134                    ty,
135                    docstring,
136                    definition: f.location.clone(),
137                })
138            }
139            crate::Name::Method { class, name } => {
140                let here = crate::db::Fqcn::from_str(&db, class.as_ref());
141                let (_, m) = crate::db::find_method_in_chain(&db, here, name)
142                    .ok_or(crate::SymbolLookupError::NotFound)?;
143                let ty = m
144                    .return_type
145                    .as_deref()
146                    .cloned()
147                    .unwrap_or_else(Type::mixed);
148                let docstring = m.docstring.as_ref().map(|s| s.to_string());
149                Ok(crate::HoverInfo {
150                    ty,
151                    docstring,
152                    definition: m.location.clone(),
153                })
154            }
155            crate::Name::Class(fqcn) => {
156                let here = crate::db::Fqcn::from_str(&db, fqcn.as_ref());
157                let class = crate::db::find_class_like(&db, here)
158                    .ok_or(crate::SymbolLookupError::NotFound)?;
159                let ty = Type::single(Atomic::TNamedObject {
160                    fqcn: mir_types::Name::from(fqcn.as_ref()),
161                    type_params: mir_types::union::empty_type_params(),
162                });
163                Ok(crate::HoverInfo {
164                    ty,
165                    docstring: None,
166                    definition: class.location().cloned(),
167                })
168            }
169            crate::Name::Property { class, name } => {
170                let here = crate::db::Fqcn::from_str(&db, class.as_ref());
171                let (_, p) = crate::db::find_property_in_chain(&db, here, name)
172                    .ok_or(crate::SymbolLookupError::NotFound)?;
173                let ty = p.ty.as_deref().cloned().unwrap_or_else(Type::mixed);
174                Ok(crate::HoverInfo {
175                    ty,
176                    docstring: None,
177                    definition: p.location.clone(),
178                })
179            }
180            crate::Name::ClassConstant { class, name } => {
181                let here = crate::db::Fqcn::from_str(&db, class.as_ref());
182                let (_, c) = crate::db::find_class_constant_in_chain(&db, here, name)
183                    .ok_or(crate::SymbolLookupError::NotFound)?;
184                Ok(crate::HoverInfo {
185                    ty: c.ty.clone(),
186                    docstring: None,
187                    definition: c.location.clone(),
188                })
189            }
190            crate::Name::GlobalConstant(fqn) => {
191                let here = crate::db::Fqcn::from_str(&db, fqn.as_ref());
192                let ty = crate::db::find_global_constant(&db, here)
193                    .ok_or(crate::SymbolLookupError::NotFound)?;
194                Ok(crate::HoverInfo {
195                    ty: (*ty).clone(),
196                    docstring: None,
197                    definition: None,
198                })
199            }
200        }
201    }
202
203    /// Raw reference locations indexed by string symbol key, kept for tests
204    /// that use the legacy stringly-typed API. Prefer [`Self::indexed_references_to`]
205    /// with a typed [`crate::Name`].
206    #[doc(hidden)]
207    pub fn reference_locations(&self, symbol: &str) -> Vec<(Arc<str>, u32, u16, u16)> {
208        use crate::db::MirDatabase;
209        let db = self.snapshot_db();
210        db.reference_locations(symbol)
211    }
212
213    /// Files declaring transitive subclasses of `class_fqn`, backed by the
214    /// maintained subtype index (see [`Self::indexed_subtype_classes`]).
215    /// Excludes `class_fqn`'s own declaring file — the caller adds it.
216    ///
217    /// Lets a reference-search caller scope a `protected` member to its class
218    /// hierarchy without reconstructing that hierarchy from declaration text:
219    /// subclasses are matched by resolved FQCN, so `extends \Ns\Base` and
220    /// aliased `use` forms are all found. Read-only from the caller's
221    /// perspective; may trigger an on-demand commit of stale/uncommitted
222    /// candidates' class edges (same self-heal `indexed_subtype_classes` uses).
223    pub fn subtype_files(&self, class_fqn: &str) -> Vec<Arc<str>> {
224        self.settle_workspace_index();
225        let files = self.snapshot_db().source_file_paths();
226        let mut out: Vec<Arc<str>> = self
227            .indexed_subtype_classes(class_fqn, &files, false)
228            .into_iter()
229            .map(|s| s.file)
230            .collect();
231        out.sort();
232        out.dedup();
233        out
234    }
235
236    /// Compatibility wrapper for callers that only want `use` import items.
237    /// Delegates to [`Self::indexed_references_to`] so import references share
238    /// the same freshness/self-heal path, scope filtering, and memoization
239    /// boundary as every other reference query.
240    pub fn indexed_use_import_locations(
241        &self,
242        symbol: &crate::Name,
243        files: &[Arc<str>],
244    ) -> Vec<(Arc<str>, crate::Range)> {
245        self.indexed_references_to(
246            symbol,
247            files,
248            false,
249            crate::ReferenceIncludes::UseImports,
250            &|| false,
251        )
252        .unwrap_or_default()
253    }
254
255    /// Inverted-index find-references: posting-list lookup plus an on-demand
256    /// freshness/completeness pass over `files` (the host's candidate scope
257    /// — passing the whole workspace is fine; see the gate below).
258    ///
259    /// A candidate whose postings were committed from its current input text
260    /// (Arc identity) is answered from the index with no salsa work at all.
261    /// Stale or never-committed candidates are analyzed via the memoized
262    /// `analyze_file` query and committed, so each file pays that cost once
263    /// per text change — after a background warm sweep the steady state is a
264    /// pure lookup, O(results) instead of O(candidates). Never-committed
265    /// candidates are additionally gated on their raw text mentioning the
266    /// symbol's name (whole-identifier, ASCII-case-insensitive), so hosts
267    /// need no text prefilter of their own — and must not use one, since a
268    /// host-side filter cannot know these matching semantics.
269    ///
270    /// Results are filtered to `files` (the host controls scope — e.g.
271    /// workspace files only, excluding stubs/vendor). With
272    /// `include_declaration`, the symbol's declaration name span is appended
273    /// when it lies inside the scope.
274    ///
275    /// Returned ranges use mir's native coordinates: 1-based lines and
276    /// 0-based Unicode code-point columns (UTF-32/LSP `positionEncoding`
277    /// `"utf-32"`), not UTF-8 byte offsets or UTF-16 code units.
278    ///
279    /// `should_cancel` is polled at phase boundaries and between
280    /// cancellation retries; `true` aborts with `None`.
281    ///
282    /// Memoized per `(symbol, files, include_declaration, includes,
283    /// text_revision)` — see [`RefQueryCacheKey`]. The freshness scan below
284    /// still costs O(candidates) even when every candidate is already
285    /// committed (it has to check), so a caller re-running the same query
286    /// against unchanged state (e.g. a host recomputing reference counts for
287    /// a code-lens refresh) would otherwise re-pay that scan on every call;
288    /// this makes the repeat a single hashmap lookup instead.
289    pub fn indexed_references_to(
290        &self,
291        symbol: &crate::Name,
292        files: &[Arc<str>],
293        include_declaration: bool,
294        includes: crate::ReferenceIncludes,
295        should_cancel: &(dyn Fn() -> bool + Sync),
296    ) -> Option<Vec<(Arc<str>, crate::Range)>> {
297        // No `should_cancel()` check here: a cache hit does no analysis
298        // work, so there's nothing to cancel, and calling it would consume
299        // one of the caller's cancellation-probe invocations before the
300        // uncached path's own checks ever run (some callers, e.g.
301        // `session_sweep_persists_postings_for_next_launch`, count these to
302        // prove a warm query needed no re-analysis).
303        let cache_key = RefQueryCacheKey {
304            symbol: symbol.codebase_key(),
305            include_declaration,
306            includes,
307            generation: self.query_cache_generation(),
308            files_hash: hash_files(files),
309        };
310        if let Some(cached) = self.ref_query_cache.read().get(&cache_key) {
311            self.ref_query_cache_hits
312                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
313            return Some((**cached).clone());
314        }
315        let result = self.indexed_references_to_uncached(
316            symbol,
317            files,
318            include_declaration,
319            includes,
320            should_cancel,
321        )?;
322        if self.query_cache_generation() != cache_key.generation {
323            // The generation moved while computing (an edit, or a defs
324            // commit growing the subtype index — possibly this query's own).
325            // The key can never be looked up again, so don't cache it; the
326            // next identical query recomputes once against settled state
327            // and caches then.
328            return Some(result);
329        }
330        let mut cache = self.ref_query_cache.write();
331        if !cache.advance_to(cache_key.generation, || {
332            self.ref_query_cache_locations
333                .store(0, std::sync::atomic::Ordering::Relaxed);
334        }) {
335            return Some(result);
336        }
337        let new_len = result.len();
338        let prior = self
339            .ref_query_cache_locations
340            .fetch_add(new_len, std::sync::atomic::Ordering::Relaxed);
341        if prior + new_len > REF_QUERY_CACHE_LOCATION_CAP {
342            cache.map.clear();
343            self.ref_query_cache_locations
344                .store(new_len, std::sync::atomic::Ordering::Relaxed);
345        }
346        cache.map.insert(cache_key, Arc::new(result.clone()));
347        Some(result)
348    }
349
350    /// Uncached implementation of [`Self::indexed_references_to`]. Callers
351    /// should use the memoizing wrapper; this is split out only so the cache
352    /// check/populate logic doesn't have to interleave with the retry loops
353    /// below.
354    fn indexed_references_to_uncached(
355        &self,
356        symbol: &crate::Name,
357        files: &[Arc<str>],
358        include_declaration: bool,
359        includes: crate::ReferenceIncludes,
360        should_cancel: &(dyn Fn() -> bool + Sync),
361    ) -> Option<Vec<(Arc<str>, crate::Range)>> {
362        self.settle_workspace_index();
363        use std::panic::AssertUnwindSafe;
364
365        use rayon::prelude::*;
366
367        let key = symbol.codebase_key();
368
369        // Freshness pass: candidates whose postings are not exact for their
370        // current text. Files not registered as `SourceFile` inputs are
371        // skipped. Never-committed files — no commit mark, hence no postings
372        // at all (every mark drop accompanies a posting clear) — are further
373        // gated on their text mentioning the symbol's name: such a file can
374        // neither hold stale postings nor produce new ones, so a cold query
375        // on a common name skips the bulk of the workspace instead of
376        // analyzing it. A LIVE-analyzed file stale only by generation
377        // (unresolved-name commit, text unchanged since) gets the same gate:
378        // the current text is exactly what this session's own analysis
379        // already scanned, so a needle miss is just as conclusive as for a
380        // never-committed file. Everything else — a genuinely edited file, or
381        // a commit seeded by an unverified disk-cache replay
382        // (`warm_start_files`, never scanned by this session) — re-analyzes
383        // unconditionally: an edited file's old postings are for different
384        // text, and a replayed commit's postings are only as trustworthy as
385        // the cache entry, so neither can be cleared by a textual gate alone.
386        // Same discipline as `commit_defs_for_matching` on the defs index.
387        //
388        let gate = self.reference_gate(symbol);
389        // The whole gate — identifier needles and raw call tokens alike —
390        // answers from the persistent mention index: every needle is admitted
391        // (a declared class-like short name is already in the universe;
392        // member/function names and the two `__construct` call tokens enter
393        // verbatim), so a recorded mention set answers with lookups and only
394        // a never-scanned or since-edited file pays one scan against the
395        // whole universe, recorded for every later consumer (including the
396        // subtype-BFS gate). A needle new to the universe epoch-invalidates
397        // older recordings for itself only — the first query on it rescans
398        // uncovered files once, the same cost the per-query scan paid every
399        // time before.
400        let has_needles = !gate.idents.is_empty() || !gate.raw.is_empty();
401        let (mention_queries, mention_scanner) = if has_needles {
402            let guard = self.db.salsa.read();
403            guard.add_literal_mention_names(gate.idents.iter().map(|s| s.as_str()));
404            guard.add_raw_mention_needles(gate.raw.iter().map(|s| s.as_str()));
405            let queries: Vec<_> = gate
406                .idents
407                .iter()
408                .chain(gate.raw.iter())
409                .filter_map(|s| guard.prepare_class_mention_query(s))
410                .collect();
411            (queries, guard.class_mention_scanner())
412        } else {
413            (Vec::new(), None)
414        };
415        // Admission guarantees a query per needle and a non-empty universe;
416        // anything else is defensive — the gate then admits every candidate
417        // (analyze rather than skip, the conservative direction).
418        let gate_complete = mention_queries.len() == gate.idents.len() + gate.raw.len()
419            && mention_scanner.is_some();
420        let committed_any: rustc_hash::FxHashSet<Arc<str>> =
421            self.ref_committed_keys().into_iter().collect();
422        type MentionScanRec = (Arc<str>, Arc<str>, Box<[mir_types::Name]>);
423        let (stale, scanned): (Vec<Arc<str>>, Vec<MentionScanRec>) = loop {
424            if should_cancel() {
425                return None;
426            }
427            let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
428                let current_gen = self.index_generation();
429                let db_main = self.snapshot_db();
430                files
431                    .par_iter()
432                    .map_with(db_main, |db, f| {
433                        let Some(sf) = db.lookup_source_file(f.as_ref()) else {
434                            return (None, None);
435                        };
436                        let text = sf.text(&*db as &dyn MirDatabase);
437                        if self.is_ref_committed(f.as_ref(), text, current_gen) {
438                            return (None, None);
439                        }
440                        if committed_any.contains(f.as_ref())
441                            && !self.ref_commit_stale_by_generation_only(f.as_ref(), text)
442                        {
443                            return (Some(f.clone()), None);
444                        }
445                        if has_needles && gate_complete {
446                            // Any needle answering `true` admits the file; an
447                            // unanswerable one forces the single recorded
448                            // scan, which settles every needle at once.
449                            let mut answer = Some(false);
450                            for q in &mention_queries {
451                                match db.class_mention_answer(f.as_ref(), q, text) {
452                                    Some(true) => {
453                                        answer = Some(true);
454                                        break;
455                                    }
456                                    Some(false) => {}
457                                    None => answer = None,
458                                }
459                            }
460                            match answer {
461                                Some(true) => {}
462                                Some(false) => return (None, None),
463                                None => {
464                                    let scanner = mention_scanner
465                                        .as_ref()
466                                        .expect("gate_complete implies a scanner");
467                                    let names = scanner.scan(text);
468                                    let hit = mention_queries
469                                        .iter()
470                                        .any(|q| names.binary_search(&q.name).is_ok());
471                                    let rec = (f.clone(), text.clone(), names);
472                                    return (hit.then(|| f.clone()), Some(rec));
473                                }
474                            }
475                        }
476                        (Some(f.clone()), None)
477                    })
478                    .collect::<Vec<_>>()
479            }));
480            match attempt {
481                Ok(v) => {
482                    let mut stale = Vec::new();
483                    let mut scanned = Vec::new();
484                    for (s, rec) in v {
485                        if let Some(s) = s {
486                            stale.push(s);
487                        }
488                        if let Some(rec) = rec {
489                            scanned.push(rec);
490                        }
491                    }
492                    break (stale, scanned);
493                }
494                Err(_) if should_cancel() => return None,
495                Err(_) => {}
496            }
497        };
498
499        // Record the fallback scans regardless of how the query proceeds:
500        // each is a complete, current mention set for its file.
501        if let Some(scanner) = &mention_scanner {
502            if !scanned.is_empty() {
503                let guard = self.db.salsa.read();
504                for (file, text, names) in scanned {
505                    guard.set_file_class_mentions(&file, &text, scanner.epoch(), names);
506                }
507            }
508        }
509
510        if !stale.is_empty() {
511            // Phase 1 (serial, no live snapshot held): warm up stale
512            // candidates. `prepare_file_for_analysis` mutates salsa inputs
513            // (via `load_class`), so a concurrent writer — the background
514            // warm sweep, or another request — can raise `salsa::Cancelled`
515            // partway through a file. Catch and retry the SAME file here
516            // rather than letting the panic escape: uncaught, it would force
517            // the caller's outer retry loop (`indexed_references`) to
518            // re-enter from scratch, redoing the freshness pass and
519            // re-walking every already-warmed file in `stale` (cheap no-ops
520            // via the `prepared_files` cache, but not free) before it even
521            // gets back to the file that was interrupted. This doesn't
522            // change how many times a write is ultimately attempted (the
523            // outer loop already retries indefinitely on `Cancelled`); it
524            // only narrows what a single cancellation discards from "the
525            // whole query so far" to "the one file that was mid-flight".
526            //
527            // Tried and reverted TWICE: running this loop itself in parallel
528            // (rayon; per-file and whole-batch retry variants, and again as
529            // a `try_for_each` after the deferred-bump scope landed). Each
530            // file's warm-up is individually safe under concurrent access
531            // (every shared registry it touches — `prepared_files`,
532            // `unresolvable_fqcns`, `pending_eager_function_files`, the
533            // salsa db via `with_db_mut` — is lock-protected), but under the
534            // `concurrent_reference_cancel` stress test (sustained
535            // multi-thread writers + a background indexer, both hammering
536            // the same db while several readers each run this phase
537            // concurrently) every parallel variant deadlocks: CPU usage
538            // drops to ~0 while wall time keeps climbing — OS threads parked
539            // on a lock, most likely the fixed-size rayon pool saturated
540            // with workers blocked on `with_db_mut`'s `RwLock` write lock
541            // (an OS-level block, invisible to rayon's cooperative
542            // scheduler) while the thread that would release it is itself
543            // queued waiting for a free pool worker. Coalescing the
544            // per-load revision bumps into one per pass (the deferred scope
545            // below) did NOT fix it — the re-attempt hung the same way
546            // (>590s for a ~5s test), so the cancellation storm was not the
547            // trigger. Serial execution never contends for the pool this
548            // way, so it stays the safe choice here even though it forgoes
549            // the extra wall-clock parallelism a large stale set could
550            // otherwise use.
551            {
552                // One revision bump for the whole warm-up loop instead of one
553                // per lazily-loaded class: each bump is a salsa input write
554                // that cancels every in-flight reader (a concurrent request's
555                // Phase 2 pass restarts per bump). The scope closes before
556                // Phase 2 reads `index_generation`, so commits below are
557                // stamped with the post-load generation as before.
558                let _deferred_bumps = self.defer_revision_bumps();
559                for path in &stale {
560                    loop {
561                        if should_cancel() {
562                            return None;
563                        }
564                        match salsa::Cancelled::catch(AssertUnwindSafe(|| {
565                            self.prepare_file_for_analysis(path)
566                        })) {
567                            Ok(()) => break,
568                            Err(_) if should_cancel() => return None,
569                            Err(_) => {}
570                        }
571                    }
572                }
573            }
574
575            // Phase 2 (parallel, pure) under a cancellation retry loop, then
576            // a serial commit into both inverted indexes.
577            let (commit_gen, analyzed) = loop {
578                if should_cancel() {
579                    return None;
580                }
581                // Generation before the snapshot: a file add racing the
582                // analysis leaves these commits stale (self-healing on the
583                // next query), never wrongly fresh.
584                let gen = self.index_generation();
585                let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
586                    // Freeze on the pass-scoped snapshot (borrow-only symbol
587                    // lookups + pass-shared subtype cache): all lazy-loading
588                    // finished in Phase 1, and a concurrent index write
589                    // cancels this attempt, so the frozen view is never
590                    // stale. Same discipline as the batch body pass.
591                    let mut db_main = self.snapshot_db();
592                    db_main.freeze_workspace_index();
593                    stale
594                        .iter()
595                        .filter_map(|path| {
596                            let sf = db_main.lookup_source_file(path.as_ref())?;
597                            let text = sf.text(&db_main as &dyn MirDatabase).clone();
598                            let out =
599                                crate::db::analyze_file(&db_main as &dyn MirDatabase, sf).clone();
600                            let defs = crate::db::collect_file_definitions(
601                                &db_main as &dyn MirDatabase,
602                                sf,
603                            );
604                            let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
605                            // Stage the disk-cache write only when the commit
606                            // below will rewrite postings (see the sweep in
607                            // `reanalyze_file_set` for the cost rationale).
608                            let put = if self.ref_commit_is_current(path.as_ref(), &text, &out) {
609                                None
610                            } else {
611                                self.stage_ref_cache_put(
612                                    &db_main as &dyn MirDatabase,
613                                    sf,
614                                    path.as_ref(),
615                                    &text,
616                                    &out,
617                                )
618                            };
619                            // Mention scan piggybacks on the analysis pass
620                            // (pure; committed serially below), skipped when
621                            // the file already holds a current scan.
622                            let mentions = mention_scanner.as_ref().and_then(|s| {
623                                (!db_main.class_mentions_current(path.as_ref(), &text, s.epoch()))
624                                    .then(|| s.scan(&text))
625                            });
626                            Some((path.clone(), text, out, entries, put, mentions))
627                        })
628                        .collect::<Vec<_>>()
629                }));
630                match attempt {
631                    Ok(v) => break (gen, v),
632                    Err(_) if should_cancel() => return None,
633                    Err(_) => {}
634                }
635            };
636            let mut analyzed = analyzed;
637            let guard = self.db.salsa.read();
638            for (file, text, out, entries, put, mentions) in analyzed.iter_mut() {
639                // Pointer-identical memo ⇒ identical postings: skip the
640                // index rewrite and only re-stamp the freshness mark.
641                if !self.ref_commit_is_current(file.as_ref(), text, out) {
642                    guard.set_file_reference_locations(file.as_ref(), out.ref_locs.to_vec());
643                }
644                if let (Some(s), Some(m)) = (&mention_scanner, mentions.take()) {
645                    guard.set_file_class_mentions(file, text, s.epoch(), m);
646                }
647                if let Some(put) = put.take() {
648                    self.apply_ref_cache_put(file.as_ref(), out, put);
649                }
650                self.mark_ref_committed(
651                    file,
652                    text,
653                    Some(out),
654                    commit_gen,
655                    !out.has_unresolved_names(),
656                );
657                if !self.is_defs_committed(file.as_ref(), text) {
658                    guard.set_file_class_edges(file, entries.clone());
659                    self.mark_defs_committed(file, text);
660                }
661            }
662        }
663
664        // Posting lookup, filtered to the candidate scope.
665        //
666        // Member symbols resolve against the queried class plus its hierarchy
667        // (mir records member refs under the *declaring* class, so a query on
668        // an interface method must include implementor keys and vice versa).
669        // Name-only fallback postings — receivers whose type couldn't be
670        // resolved — are consulted only when the typed keys produce nothing,
671        // mirroring the pre-index two-tier behavior: exact results when
672        // resolution succeeds, by-name matches when nothing resolves.
673        // `__construct` stays exact: `new Sub()` invokes `Sub::__construct`
674        // even when only a parent declares one, so hierarchy fan-out would
675        // wrongly return subtype instantiation sites for a parent query.
676        let hierarchy: Vec<String> = match symbol {
677            crate::Name::Method { class, name } => {
678                if name.as_ref() == "__construct" || class.is_empty() {
679                    if class.is_empty() {
680                        Vec::new()
681                    } else {
682                        vec![class.trim_start_matches('\\').to_string()]
683                    }
684                } else {
685                    self.member_hierarchy_classes(class.as_ref())
686                }
687            }
688            crate::Name::Property { class, .. } | crate::Name::ClassConstant { class, .. } => {
689                if class.is_empty() {
690                    Vec::new()
691                } else {
692                    self.member_hierarchy_classes(class.as_ref())
693                }
694            }
695            _ => Vec::new(),
696        };
697        let scope: rustc_hash::FxHashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
698        let read_symbol_key = |symbol_key: &str| -> Vec<(Arc<str>, crate::Range)> {
699            let guard = self.db.salsa.read();
700            guard
701                .reference_locations(symbol_key)
702                .into_iter()
703                .filter(|(file, ..)| scope.contains(file.as_ref()))
704                .map(|(file, line, col_start, col_end)| {
705                    (file, span_range(line, col_start as u32, col_end as u32))
706                })
707                .collect()
708        };
709        let mut scratch_key = String::new();
710        let mut read_composed_key = |prefix: &str, middle: &str, separator: &str, suffix: &str| {
711            scratch_key.clear();
712            scratch_key.reserve(prefix.len() + middle.len() + separator.len() + suffix.len());
713            scratch_key.push_str(prefix);
714            scratch_key.push_str(middle);
715            scratch_key.push_str(separator);
716            scratch_key.push_str(suffix);
717            read_symbol_key(&scratch_key)
718        };
719        let mut out: Vec<(Arc<str>, crate::Range)> = Vec::new();
720        let mut append_plain = || match symbol {
721            crate::Name::Method { name, .. } => {
722                for class in &hierarchy {
723                    out.extend(read_composed_key(
724                        "meth:",
725                        class.as_ref(),
726                        "::",
727                        name.as_ref(),
728                    ));
729                }
730            }
731            crate::Name::Property { name, .. } => {
732                for class in &hierarchy {
733                    out.extend(read_composed_key(
734                        "prop:",
735                        class.as_ref(),
736                        "::",
737                        name.as_ref(),
738                    ));
739                }
740            }
741            crate::Name::ClassConstant { name, .. } => {
742                for class in &hierarchy {
743                    out.extend(read_composed_key(
744                        "cnst:",
745                        class.as_ref(),
746                        "::",
747                        name.as_ref(),
748                    ));
749                }
750            }
751            _ => out.extend(read_symbol_key(key.as_str())),
752        };
753        match includes {
754            crate::ReferenceIncludes::Plain => append_plain(),
755            crate::ReferenceIncludes::UseImports => {
756                out.extend(read_composed_key("use:", key.as_str(), "", ""));
757            }
758            crate::ReferenceIncludes::PlainAndUseImports => {
759                append_plain();
760                out.extend(read_composed_key("use:", key.as_str(), "", ""));
761            }
762        }
763        if matches!(includes, crate::ReferenceIncludes::Plain) && out.is_empty() {
764            match symbol {
765                crate::Name::Method { name, .. } => {
766                    out = read_composed_key("methname:", name.as_ref(), "", "");
767                }
768                crate::Name::Property { name, .. } => {
769                    out = read_composed_key("propname:", name.as_ref(), "", "");
770                }
771                _ => {}
772            }
773        }
774        out.sort_by(|a, b| {
775            a.0.cmp(&b.0)
776                .then(a.1.start.line.cmp(&b.1.start.line))
777                .then(a.1.start.column.cmp(&b.1.start.column))
778        });
779        out.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
780
781        if include_declaration {
782            // Declaration lookup runs salsa queries (and may lazy-load); a
783            // concurrent write cancels it — declarations are then simply
784            // omitted rather than failing the whole request.
785            let decls: Vec<(Arc<str>, crate::Range)> = match symbol {
786                crate::Name::Method { class, .. }
787                | crate::Name::Property { class, .. }
788                | crate::Name::ClassConstant { class, .. } => {
789                    if class.is_empty() {
790                        // Unknown owner: declarations by name, recorded as
791                        // `methdecl:`/`propdecl:`/`cnstdecl:` postings during
792                        // class/trait/interface/enum analysis.
793                        match symbol {
794                            crate::Name::Method { name, .. } => {
795                                read_composed_key("methdecl:", name.as_ref(), "", "")
796                            }
797                            crate::Name::Property { name, .. } => {
798                                read_composed_key("propdecl:", name.as_ref(), "", "")
799                            }
800                            crate::Name::ClassConstant { name, .. } => {
801                                read_composed_key("cnstdecl:", name.as_ref(), "", "")
802                            }
803                            _ => Vec::new(),
804                        }
805                    } else {
806                        salsa::Cancelled::catch(AssertUnwindSafe(|| {
807                            self.member_decl_sites(&hierarchy, symbol)
808                        }))
809                        .unwrap_or_default()
810                    }
811                }
812                _ => salsa::Cancelled::catch(AssertUnwindSafe(|| {
813                    self.declaration_name_range(symbol).into_iter().collect()
814                }))
815                .unwrap_or_default(),
816            };
817            for (file, range) in decls {
818                if scope.contains(file.as_ref())
819                    && !out.iter().any(|(f, r)| *f == file && *r == range)
820                {
821                    out.push((file, range));
822                }
823            }
824        }
825        Some(out)
826    }
827
828    /// The queried class plus every class its members' references could be
829    /// keyed under: resolved ancestors (a call on a subtype instance records
830    /// the declaring ancestor) and transitive subtypes including trait users
831    /// (a call on a subtype that overrides records the subtype). Display-form
832    /// FQCNs, deduplicated case-insensitively.
833    fn member_hierarchy_classes(&self, class_fqn: &str) -> Vec<String> {
834        use std::panic::AssertUnwindSafe;
835        let target = class_fqn.trim_start_matches('\\').to_string();
836        let mut out: Vec<String> = vec![target.clone()];
837        let ancestors = salsa::Cancelled::catch(AssertUnwindSafe(|| {
838            let db = self.snapshot_db();
839            let here = crate::db::Fqcn::from_str(&db, &target);
840            crate::db::class_ancestors_by_fqcn(&db, here)
841                .iter()
842                .skip(1)
843                .map(|a| a.trim_start_matches('\\').to_string())
844                .collect::<Vec<_>>()
845        }))
846        .unwrap_or_default();
847        out.extend(ancestors);
848        let subs = {
849            let guard = self.db.salsa.read();
850            guard.subtype_sites_of(&target, true)
851        };
852        out.extend(
853            subs.into_iter()
854                .map(|s| s.fqcn.trim_start_matches('\\').to_string()),
855        );
856        let mut seen: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
857        out.retain(|c| seen.insert(c.to_ascii_lowercase()));
858        out
859    }
860
861    /// Own-member declaration sites for `symbol` across `classes`: each class
862    /// that itself declares the member (not inherited) contributes its name
863    /// token. Kind-specific lookups — a class often declares a property and a
864    /// method with the same short name, and `member_location` can't tell them
865    /// apart.
866    fn member_decl_sites(
867        &self,
868        classes: &[String],
869        symbol: &crate::Name,
870    ) -> Vec<(Arc<str>, crate::Range)> {
871        let mut out: Vec<(Arc<str>, crate::Range)> = Vec::new();
872        let db = self.snapshot_db();
873        for class in classes {
874            let here = crate::db::Fqcn::from_str(&db, class);
875            let (loc, needle) = match symbol {
876                crate::Name::Method { name, .. } => {
877                    let Some(m) = crate::db::find_method_in_class(&db, here, name) else {
878                        continue;
879                    };
880                    (m.location.clone(), name.to_string())
881                }
882                crate::Name::Property { name, .. } => {
883                    let Some(p) = crate::db::find_property_in_class(&db, here, name) else {
884                        continue;
885                    };
886                    (p.location.clone(), name.to_string())
887                }
888                crate::Name::ClassConstant { name, .. } => {
889                    let Some(c) = crate::db::find_class_constant_in_class(&db, here, name) else {
890                        continue;
891                    };
892                    (c.location.clone(), name.to_string())
893                }
894                _ => continue,
895            };
896            let Some(loc) = loc else { continue };
897            let range = self.refine_location_to_name(&loc, &needle);
898            out.push((loc.file.clone(), range));
899        }
900        out
901    }
902
903    /// The symbol's declaration site, narrowed from the collector's
904    /// whole-declaration span to the declared name's own token (matching the
905    /// span shape of recorded references).
906    pub fn declaration_name_range(&self, symbol: &crate::Name) -> Option<(Arc<str>, crate::Range)> {
907        if let crate::Name::GlobalConstant(fqn) = symbol {
908            return self.global_constant_decl_range(fqn);
909        }
910        let loc = self.definition_of(symbol).ok()?;
911        let short = match symbol {
912            crate::Name::Class(f) | crate::Name::Function(f) | crate::Name::GlobalConstant(f) => {
913                crate::db::subtype_index::short_name_of(f)
914            }
915            crate::Name::Method { name, .. }
916            | crate::Name::Property { name, .. }
917            | crate::Name::ClassConstant { name, .. } => name.as_ref(),
918        };
919        // Property declarations carry a `$` sigil in source, but reference
920        // ranges cover the bare name; the word-boundary search below lands on
921        // the name right after the sigil.
922        let file = loc.file.clone();
923        let range = self.refine_location_to_name(&loc, short);
924        Some((file, range))
925    }
926
927    /// Narrow a whole-declaration [`mir_types::Location`] to the first
928    /// word-boundary occurrence of `needle` inside its line span. Falls back
929    /// to the location's own coordinates when the text is unavailable or the
930    /// name doesn't appear (e.g. stub-only declarations).
931    fn refine_location_to_name(&self, loc: &mir_types::Location, needle: &str) -> crate::Range {
932        let fallback = span_range(loc.line, loc.col_start as u32, loc.col_end as u32);
933        let text = {
934            let db = self.snapshot_db();
935            db.lookup_source_file(loc.file.as_ref())
936                .map(|sf| sf.text(&db as &dyn MirDatabase).clone())
937        };
938        let Some(text) = text else {
939            return fallback;
940        };
941        let needle_chars = needle.chars().count() as u32;
942        let first_line = loc.line.saturating_sub(1) as usize;
943        // Exact-case first: PHP property/constant names are case-sensitive
944        // and an early case-insensitive hit can land on an unrelated token
945        // (a type hint sharing the name). Case-insensitive second, for
946        // method/class needles that arrive lowercase-normalized.
947        for case_insensitive in [false, true] {
948            for (idx, line_text) in text.lines().enumerate().skip(first_line) {
949                let line_no = idx as u32 + 1;
950                if line_no > loc.line_end {
951                    break;
952                }
953                let min_col = if line_no == loc.line {
954                    loc.col_start as usize
955                } else {
956                    0
957                };
958                if let Some(col) = identifier_char_col(line_text, needle, min_col, case_insensitive)
959                {
960                    return span_range(line_no, col, col + needle_chars);
961                }
962            }
963        }
964        fallback
965    }
966
967    /// Transitive subtypes of `class_fqn` (classes/interfaces/enums whose
968    /// resolved ancestor chain reaches it), answered from the maintained
969    /// subtype edge index.
970    ///
971    /// `files` is the host's candidate scope for the on-demand completeness
972    /// pass: per BFS round, not-yet-committed files whose text mentions a
973    /// frontier name get their definitions committed, so results are complete
974    /// even before a background sweep has covered the workspace. Committed
975    /// files answer from the index with no parsing at all.
976    ///
977    /// `include_trait_users` also counts `use Trait;` composition as a
978    /// subtype edge (visibility-scoping semantics); leave it off for
979    /// goto-implementation semantics (extends/implements only).
980    ///
981    /// Memoized per `(class_fqn, include_trait_users, files, text_revision)`
982    /// — same shape and rationale as [`Self::indexed_references_to`]'s
983    /// cache: `commit_defs_for_matching`'s freshness pass costs O(candidates)
984    /// on every call regardless of outcome, so a repeat query (e.g. a host
985    /// resolving a protected/static method's reference scope on every
986    /// code-lens refresh) would otherwise re-pay it every time.
987    pub fn indexed_subtype_classes(
988        &self,
989        class_fqn: &str,
990        files: &[Arc<str>],
991        include_trait_users: bool,
992    ) -> Vec<SubtypeClassSite> {
993        let cache_key = SubtypeQueryCacheKey {
994            class_fqn: class_fqn.trim_start_matches('\\').to_ascii_lowercase(),
995            include_trait_users,
996            generation: self.query_cache_generation(),
997            files_hash: hash_files(files),
998        };
999        if let Some(cached) = self.subtype_query_cache.read().get(&cache_key) {
1000            self.subtype_query_cache_hits
1001                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1002            return (**cached).clone();
1003        }
1004        let result = self.indexed_subtype_classes_uncached(class_fqn, files, include_trait_users);
1005        if self.query_cache_generation() != cache_key.generation {
1006            // The generation moved while computing (an edit, or a defs
1007            // commit growing the subtype index — possibly this query's own).
1008            // The key can never be looked up again, so don't cache it; the
1009            // next identical query recomputes once against settled state
1010            // and caches then.
1011            return result;
1012        }
1013        let mut cache = self.subtype_query_cache.write();
1014        if !cache.advance_to(cache_key.generation, || {
1015            self.subtype_query_cache_sites
1016                .store(0, std::sync::atomic::Ordering::Relaxed);
1017        }) {
1018            return result;
1019        }
1020        let new_len = result.len();
1021        let prior = self
1022            .subtype_query_cache_sites
1023            .fetch_add(new_len, std::sync::atomic::Ordering::Relaxed);
1024        if prior + new_len > SUBTYPE_QUERY_CACHE_SITE_CAP {
1025            cache.map.clear();
1026            self.subtype_query_cache_sites
1027                .store(new_len, std::sync::atomic::Ordering::Relaxed);
1028        }
1029        cache.map.insert(cache_key, Arc::new(result.clone()));
1030        result
1031    }
1032
1033    /// Uncached implementation of [`Self::indexed_subtype_classes`]. Callers
1034    /// should use the memoizing wrapper.
1035    fn indexed_subtype_classes_uncached(
1036        &self,
1037        class_fqn: &str,
1038        files: &[Arc<str>],
1039        include_trait_users: bool,
1040    ) -> Vec<SubtypeClassSite> {
1041        self.settle_workspace_index();
1042        let mut scanned: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1043        let mut pending: Vec<String> = vec![class_fqn.trim_start_matches('\\').to_string()];
1044        let mut sites: Vec<crate::db::SubtypeSite> = Vec::new();
1045        while !pending.is_empty() {
1046            let needles: Vec<String> = pending
1047                .drain(..)
1048                .filter(|f| scanned.insert(f.clone()))
1049                .map(|f| crate::db::subtype_index::short_name_of(&f).to_string())
1050                .collect();
1051            if !needles.is_empty() {
1052                self.commit_defs_for_matching(files, &needles);
1053            }
1054            sites = {
1055                let guard = self.db.salsa.read();
1056                guard.subtype_sites_of_lenient(class_fqn, include_trait_users)
1057            };
1058            pending = sites
1059                .iter()
1060                .map(|s| s.fqcn.trim_start_matches('\\').to_string())
1061                .filter(|f| !scanned.contains(f))
1062                .collect();
1063        }
1064        let mut out: Vec<SubtypeClassSite> = sites
1065            .into_iter()
1066            .filter_map(|s| {
1067                let loc = s.location.as_ref()?;
1068                let short = crate::db::subtype_index::short_name_of(&s.fqcn).to_string();
1069                let range = self.refine_location_to_name(loc, &short);
1070                Some(SubtypeClassSite {
1071                    fqcn: s.fqcn,
1072                    kind: s.kind,
1073                    is_abstract: s.is_abstract,
1074                    file: s.file,
1075                    range,
1076                })
1077            })
1078            .collect();
1079        // Anonymous classes never reach the definition collector; their
1080        // `new class implements X {}` sites are recorded as `impl:` postings
1081        // during body analysis (exact FQCN key plus a short-name key for the
1082        // same written-form leniency named classes get above).
1083        let root_lc = class_fqn.trim_start_matches('\\').to_ascii_lowercase();
1084        let short_lc = crate::db::subtype_index::short_name_of(&root_lc).to_string();
1085        let scope: rustc_hash::FxHashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
1086        let anon: Vec<(Arc<str>, u32, u16, u16)> = {
1087            let guard = self.db.salsa.read();
1088            let mut key = String::with_capacity("implshort:".len() + root_lc.len());
1089            key.push_str("impl:");
1090            key.push_str(&root_lc);
1091            let mut v = guard.reference_locations(&key);
1092            key.clear();
1093            key.push_str("implshort:");
1094            key.push_str(&short_lc);
1095            v.extend(guard.reference_locations(&key));
1096            v.sort();
1097            v.dedup();
1098            v
1099        };
1100        for (file, line, cs, ce) in anon {
1101            if !scope.contains(file.as_ref()) {
1102                continue;
1103            }
1104            let range = span_range(line, cs as u32, ce as u32);
1105            if out.iter().any(|s| s.file == file && s.range == range) {
1106                continue;
1107            }
1108            out.push(SubtypeClassSite {
1109                fqcn: Arc::from("class@anonymous"),
1110                kind: crate::db::ClassLikeKind::Class,
1111                is_abstract: false,
1112                file,
1113                range,
1114            });
1115        }
1116        out
1117    }
1118
1119    /// Concrete implementations of `class_fqn::method` across its transitive
1120    /// subtypes: the same-named non-abstract method available to each subtype
1121    /// (its own declaration, or one inherited/composed from a parent, trait,
1122    /// or mixin), as `(subtype fqcn, file, name range)`. Subtypes resolving to
1123    /// the same declaring location collapse to a single entry.
1124    pub fn indexed_method_implementations(
1125        &self,
1126        class_fqn: &str,
1127        method: &str,
1128        files: &[Arc<str>],
1129    ) -> Vec<(Arc<str>, Arc<str>, crate::Range)> {
1130        use std::panic::AssertUnwindSafe;
1131        let subs = self.indexed_subtype_classes(class_fqn, files, false);
1132        if subs.is_empty() {
1133            return Vec::new();
1134        }
1135        loop {
1136            let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
1137                let db = self.snapshot_db();
1138                let mut out: Vec<(Arc<str>, Arc<str>, crate::Range)> = Vec::new();
1139                for sub in &subs {
1140                    let here = crate::db::Fqcn::from_str(&db, sub.fqcn.as_ref());
1141                    let Some((_, m)) = crate::db::find_method_in_chain(&db, here, method) else {
1142                        continue;
1143                    };
1144                    if m.is_abstract {
1145                        continue;
1146                    }
1147                    let Some(loc) = m.location.as_ref() else {
1148                        continue;
1149                    };
1150                    let range = self.refine_location_to_name(loc, method);
1151                    out.push((sub.fqcn.clone(), loc.file.clone(), range));
1152                }
1153                out
1154            }));
1155            if let Ok(mut out) = attempt {
1156                out.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.start.line.cmp(&b.2.start.line)));
1157                out.dedup_by(|a, b| a.1 == b.1 && a.2 == b.2);
1158                return out;
1159            }
1160        }
1161    }
1162
1163    /// Commit definitions (class edges + freshness) for every file in `files`
1164    /// that is stale (committed against older text) or that has never been
1165    /// committed and mentions one of `shorts` as a whole identifier.
1166    ///
1167    /// The textual gate answers from the shared per-file mention cache — the
1168    /// same one `indexed_references_to`'s gate populates — so a file scanned
1169    /// by either consumer answers the other with a set lookup instead of an
1170    /// O(text) rescan per BFS round. A file the cache can't answer for is
1171    /// scanned once against the whole name universe and recorded.
1172    fn commit_defs_for_matching(&self, files: &[Arc<str>], shorts: &[String]) {
1173        use std::panic::AssertUnwindSafe;
1174
1175        use rayon::prelude::*;
1176
1177        let committed_any: rustc_hash::FxHashSet<Arc<str>> = {
1178            let guard = self.defs_committed_keys();
1179            guard.into_iter().collect()
1180        };
1181        // Admit the frontier names before preparing, so every needle gets a
1182        // real query (a declared class's short name is already in the
1183        // universe from indexing — admission then changes nothing).
1184        let (queries, mention_scanner) = {
1185            let guard = self.db.salsa.read();
1186            guard.add_literal_mention_names(shorts.iter().map(|s| s.as_str()));
1187            let queries: Vec<_> = shorts
1188                .iter()
1189                .filter_map(|s| guard.prepare_class_mention_query(s))
1190                .collect();
1191            (queries, guard.class_mention_scanner())
1192        };
1193        // Admission guarantees a query per needle and a non-empty universe;
1194        // anything else is defensive — the gate then admits every candidate
1195        // (recommit rather than skip, the conservative direction).
1196        let use_mentions = queries.len() == shorts.len() && mention_scanner.is_some();
1197        type Work = (Arc<str>, Arc<str>, Vec<crate::db::SubtypeEntry>);
1198        type MentionScanRec = (Arc<str>, Arc<str>, Box<[mir_types::Name]>);
1199        let (work, scanned): (Vec<Work>, Vec<MentionScanRec>) = loop {
1200            let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
1201                let db_main = self.snapshot_db();
1202                files
1203                    .par_iter()
1204                    .map_with(db_main, |db, path| {
1205                        let Some(sf) = db.lookup_source_file(path.as_ref()) else {
1206                            return (None, None);
1207                        };
1208                        let text = sf.text(&*db as &dyn MirDatabase).clone();
1209                        if self.is_defs_committed(path.as_ref(), &text) {
1210                            return (None, None);
1211                        }
1212                        // Never-committed files must mention a frontier name;
1213                        // stale (previously committed) files recommit
1214                        // unconditionally — their classes may have re-parented.
1215                        let mut scan_rec: Option<MentionScanRec> = None;
1216                        if use_mentions && !committed_any.contains(path.as_ref()) {
1217                            let mut answer = Some(false);
1218                            for q in &queries {
1219                                match db.class_mention_answer(path.as_ref(), q, &text) {
1220                                    Some(true) => {
1221                                        answer = Some(true);
1222                                        break;
1223                                    }
1224                                    Some(false) => {}
1225                                    None => answer = None,
1226                                }
1227                            }
1228                            let hit = match answer {
1229                                Some(hit) => hit,
1230                                None => {
1231                                    // Uncoverable entry: one scan answers
1232                                    // every query and is recorded below.
1233                                    let scanner = mention_scanner.as_ref().unwrap();
1234                                    let names = scanner.scan(&text);
1235                                    let hit = queries
1236                                        .iter()
1237                                        .any(|q| names.binary_search(&q.name).is_ok());
1238                                    scan_rec = Some((path.clone(), text.clone(), names));
1239                                    hit
1240                                }
1241                            };
1242                            if !hit {
1243                                return (None, scan_rec);
1244                            }
1245                        }
1246                        let defs =
1247                            crate::db::collect_file_definitions(&*db as &dyn MirDatabase, sf);
1248                        let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
1249                        (Some((path.clone(), text, entries)), scan_rec)
1250                    })
1251                    .collect::<Vec<_>>()
1252            }));
1253            if let Ok(v) = attempt {
1254                let mut work = Vec::new();
1255                let mut scanned = Vec::new();
1256                for (w, rec) in v {
1257                    if let Some(w) = w {
1258                        work.push(w);
1259                    }
1260                    if let Some(rec) = rec {
1261                        scanned.push(rec);
1262                    }
1263                }
1264                break (work, scanned);
1265            }
1266        };
1267        // Record the fallback scans regardless of hit/miss: each is a
1268        // complete, current mention set for its file, so the next round's
1269        // (and the references gate's) checks become set lookups.
1270        if let Some(scanner) = &mention_scanner {
1271            if !scanned.is_empty() {
1272                let guard = self.db.salsa.read();
1273                for (file, text, names) in scanned {
1274                    guard.set_file_class_mentions(&file, &text, scanner.epoch(), names);
1275                }
1276            }
1277        }
1278        if work.is_empty() {
1279            return;
1280        }
1281        let guard = self.db.salsa.read();
1282        for (file, text, entries) in &work {
1283            guard.set_file_class_edges(file, entries.clone());
1284            self.mark_defs_committed(file, text);
1285        }
1286    }
1287
1288    /// Declaration name span for a global constant. Constant slices carry no
1289    /// stored location, so this finds the declaring file via the workspace
1290    /// constants index and locates the `const NAME` / `define('NAME'` token
1291    /// textually.
1292    fn global_constant_decl_range(&self, fqn: &str) -> Option<(Arc<str>, crate::Range)> {
1293        use std::panic::AssertUnwindSafe;
1294        let short = crate::db::subtype_index::short_name_of(fqn).to_string();
1295        salsa::Cancelled::catch(AssertUnwindSafe(|| {
1296            let db = self.snapshot_db();
1297            let index = crate::db::workspace_index(&db);
1298            let loc = index.constant_loc(mir_types::Name::from(fqn.trim_start_matches('\\')))?;
1299            let file = loc.file().path(&db).clone();
1300            let sf = db.lookup_source_file(file.as_ref())?;
1301            let text = sf.text(&db as &dyn MirDatabase);
1302            for (idx, line) in text.lines().enumerate() {
1303                let trimmed = line.trim_start();
1304                let is_decl_line = trimmed.starts_with("const ")
1305                    || trimmed.contains("define(")
1306                    || trimmed.contains("define (");
1307                if !is_decl_line {
1308                    continue;
1309                }
1310                if let Some(col) = identifier_char_col(line, &short, 0, false) {
1311                    let n = short.chars().count() as u32;
1312                    return Some((file, span_range(idx as u32 + 1, col, col + n)));
1313                }
1314            }
1315            None
1316        }))
1317        .ok()
1318        .flatten()
1319    }
1320
1321    /// Class-level issues (inheritance violations, abstract-method gaps, override
1322    /// incompatibilities) for the given set of files.
1323    ///
1324    /// These checks are cross-file by nature and are not emitted by
1325    /// [`crate::FileAnalyzer::analyze`]. Call this after ingesting or
1326    /// re-analyzing a file and its dependents to get the full diagnostic picture.
1327    ///
1328    /// Circular-inheritance checks always run against the full workspace graph
1329    /// regardless of the `files` filter — a cycle is a workspace-wide problem.
1330    pub fn class_issues(&self, files: &[Arc<str>]) -> Vec<crate::Issue> {
1331        self.settle_workspace_index();
1332        let db = self.snapshot_db();
1333        let file_set: HashSet<Arc<str>> = files.iter().cloned().collect();
1334        // Read source texts through the snapshot already in hand — calling
1335        // `source_of` here would re-enter the session RwLock while this
1336        // snapshot is live, and a concurrent salsa write (which blocks new
1337        // readers behind the fair write lock while waiting for existing
1338        // snapshots to drop) turns that into a deadlock.
1339        let file_data: Vec<(Arc<str>, Arc<str>)> = files
1340            .iter()
1341            .filter_map(|f| {
1342                let sf = db.lookup_source_file(f)?;
1343                Some((
1344                    f.clone(),
1345                    sf.text(&db as &dyn crate::db::MirDatabase).clone(),
1346                ))
1347            })
1348            .collect();
1349        crate::class::ClassAnalyzer::with_files(&db, file_set, &file_data).analyze_all()
1350    }
1351
1352    /// Collector-phase issues (e.g. `BackedEnumCaseTypeMismatch`,
1353    /// `InvalidReadonlyPropertyDeclaration`, `InvalidDocblock`, and raw parse
1354    /// errors) for the given files.
1355    ///
1356    /// These are found while building a file's declaration slice
1357    /// ([`crate::db::collect_file_definitions`]), before body analysis or
1358    /// cross-file class checks ever run — neither [`crate::FileAnalyzer::analyze`]
1359    /// nor [`Self::class_issues`] reads them, so a caller merging just those
1360    /// two sources silently drops every collector-time diagnostic. Call this
1361    /// alongside them to get the full picture.
1362    ///
1363    /// A plain snapshot read through [`crate::db::collect_file_definitions`],
1364    /// same as [`Self::document_symbols`] — correct regardless of which path
1365    /// put the file's text into the db (`ingest_file`, `set_file_text`, lazy
1366    /// vendor load) or how many times. The parse-cache fast paths behind that
1367    /// query used to zero out `issues` on any hit, including re-collecting
1368    /// the *same* file after its salsa memo was invalidated; they now
1369    /// preserve (and, for a genuinely different file sharing content,
1370    /// re-point) the originally-computed issues instead.
1371    pub fn collector_issues(&self, files: &[Arc<str>]) -> Vec<crate::Issue> {
1372        let db = self.snapshot_db();
1373        files
1374            .iter()
1375            .filter_map(|f| db.lookup_source_file(f))
1376            .flat_map(|sf| {
1377                crate::db::collect_file_definitions(&db, sf)
1378                    .issues
1379                    .as_ref()
1380                    .clone()
1381            })
1382            .collect()
1383    }
1384
1385    /// All declarations defined in `file` as a **hierarchical tree**.
1386    ///
1387    /// Classes/interfaces/traits/enums are returned with their methods,
1388    /// properties, and constants nested in `children`. Top-level functions
1389    /// and constants are returned with empty `children`.
1390    pub fn document_symbols(&self, file: &str) -> Vec<crate::symbol::DocumentSymbol> {
1391        use crate::symbol::{DeclarationKind, DocumentSymbol};
1392
1393        let db = self.snapshot_db();
1394        let Some(sf) = db.lookup_source_file(file) else {
1395            return Vec::new();
1396        };
1397        let defs = crate::db::collect_file_definitions(&db, sf);
1398        let mut out: Vec<DocumentSymbol> = Vec::new();
1399
1400        let class_children = |methods: &mir_codebase::definitions::MemberMap<
1401            Arc<mir_codebase::definitions::MethodDef>,
1402        >,
1403                              props: Option<
1404            &mir_codebase::definitions::MemberMap<mir_codebase::definitions::PropertyDef>,
1405        >,
1406                              consts: &mir_codebase::definitions::MemberMap<
1407            mir_codebase::definitions::ConstantDef,
1408        >,
1409                              is_enum: bool|
1410         -> Vec<DocumentSymbol> {
1411            let mut out: Vec<DocumentSymbol> = Vec::new();
1412            for (_, m) in methods.iter() {
1413                out.push(DocumentSymbol {
1414                    name: m.name.clone(),
1415                    kind: DeclarationKind::Method,
1416                    location: m.location.clone(),
1417                    children: Vec::new(),
1418                });
1419            }
1420            if let Some(props) = props {
1421                for (_, p) in props.iter() {
1422                    out.push(DocumentSymbol {
1423                        name: p.name.clone(),
1424                        kind: DeclarationKind::Property,
1425                        location: p.location.clone(),
1426                        children: Vec::new(),
1427                    });
1428                }
1429            }
1430            let const_kind = if is_enum {
1431                DeclarationKind::EnumCase
1432            } else {
1433                DeclarationKind::Constant
1434            };
1435            for (_, c) in consts.iter() {
1436                out.push(DocumentSymbol {
1437                    name: c.name.clone(),
1438                    kind: const_kind,
1439                    location: c.location.clone(),
1440                    children: Vec::new(),
1441                });
1442            }
1443            out
1444        };
1445
1446        for c in defs.slice.classes.iter() {
1447            out.push(DocumentSymbol {
1448                name: c.fqcn.clone(),
1449                kind: DeclarationKind::Class,
1450                location: c.location.clone(),
1451                children: class_children(
1452                    &c.own_methods,
1453                    Some(&c.own_properties),
1454                    &c.own_constants,
1455                    false,
1456                ),
1457            });
1458        }
1459        for i in defs.slice.interfaces.iter() {
1460            out.push(DocumentSymbol {
1461                name: i.fqcn.clone(),
1462                kind: DeclarationKind::Interface,
1463                location: i.location.clone(),
1464                children: class_children(&i.own_methods, None, &i.own_constants, false),
1465            });
1466        }
1467        for t in defs.slice.traits.iter() {
1468            out.push(DocumentSymbol {
1469                name: t.fqcn.clone(),
1470                kind: DeclarationKind::Trait,
1471                location: t.location.clone(),
1472                children: class_children(
1473                    &t.own_methods,
1474                    Some(&t.own_properties),
1475                    &t.own_constants,
1476                    false,
1477                ),
1478            });
1479        }
1480        for e in defs.slice.enums.iter() {
1481            let mut children = class_children(&e.own_methods, None, &e.own_constants, true);
1482            for (_, case) in e.cases.iter() {
1483                children.push(DocumentSymbol {
1484                    name: case.name.clone(),
1485                    kind: DeclarationKind::EnumCase,
1486                    location: case.location.clone(),
1487                    children: Vec::new(),
1488                });
1489            }
1490            out.push(DocumentSymbol {
1491                name: e.fqcn.clone(),
1492                kind: DeclarationKind::Enum,
1493                location: e.location.clone(),
1494                children,
1495            });
1496        }
1497        for f in defs.slice.functions.iter() {
1498            out.push(DocumentSymbol {
1499                name: f.fqn.clone(),
1500                kind: DeclarationKind::Function,
1501                location: f.location.clone(),
1502                children: Vec::new(),
1503            });
1504        }
1505        for (name, _) in defs.slice.constants.iter() {
1506            out.push(DocumentSymbol {
1507                name: name.clone(),
1508                kind: DeclarationKind::Constant,
1509                location: None,
1510                children: Vec::new(),
1511            });
1512        }
1513        out
1514    }
1515
1516    /// Choose the candidate-admission gate for `symbol`.
1517    ///
1518    /// For any known non-constructor/non-`__invoke` method, the member name
1519    /// alone is the sound gate. Every posting-producing reference spells that
1520    /// token: `$obj->m()`, `Owner::m()`, inherited `Sub::m()`,
1521    /// `self::`/`static::`/`parent::m()`, callable strings/arrays, and trait
1522    /// aliases (`orig as alias` records `orig`, alias calls record `alias`
1523    /// plus the origin key). Dropping the owner short name matters on common
1524    /// owner names (`User`, `Model`, `Widget`): otherwise a cold reference
1525    /// query analyzes files that only type-hint the owner and cannot contain a
1526    /// reference to the queried method. Dynamic member names (`$obj->$m()`)
1527    /// produce no posting, so nothing is lost there.
1528    ///
1529    /// For `__construct` with a known owner, the identifier needle is the
1530    /// owner's short name (`new Cls(` sites never spell the member name and
1531    /// the bare word `__construct` would admit every file *declaring* a
1532    /// constructor), complemented by the raw call tokens `->__construct` /
1533    /// `::__construct`: an explicit re-init `$obj->__construct()` is a real
1534    /// recorded reference whose file may never name the class.
1535    ///
1536    /// `__invoke` keeps the general owner/name gate because `$obj()` call sites
1537    /// do not spell `__invoke`.
1538    ///
1539    /// Everything else uses the general OR needles
1540    /// ([`reference_gate_needles`]).
1541    fn reference_gate(&self, symbol: &crate::Name) -> ReferenceGate {
1542        if let crate::Name::Method { class, name } = symbol {
1543            if name.as_ref() == "__construct" && !class.is_empty() {
1544                return ReferenceGate {
1545                    idents: reference_gate_needles(symbol),
1546                    raw: vec!["->__construct".to_string(), "::__construct".to_string()],
1547                };
1548            }
1549            if name.as_ref() != "__construct" && name.as_ref() != "__invoke" && !class.is_empty() {
1550                let db = self.snapshot_db();
1551                let here = crate::db::Fqcn::from_str(&db, class.as_ref());
1552                let is_static = crate::db::find_method_in_chain(&db, here, name)
1553                    .map(|(_, m)| m.is_static)
1554                    .unwrap_or(false);
1555                if is_static || crate::db::class_exists(&db, class.as_ref()) {
1556                    return ReferenceGate {
1557                        idents: vec![name.to_string()],
1558                        raw: Vec::new(),
1559                    };
1560                }
1561            }
1562        }
1563        ReferenceGate {
1564            idents: reference_gate_needles(symbol),
1565            raw: Vec::new(),
1566        }
1567    }
1568}
1569
1570/// A transitive subtype hit with its declaration name span, as returned by
1571/// [`AnalysisSession::indexed_subtype_classes`].
1572#[derive(Debug, Clone)]
1573pub struct SubtypeClassSite {
1574    /// Display-form FQCN (no leading `\`).
1575    pub fqcn: Arc<str>,
1576    pub kind: crate::db::ClassLikeKind,
1577    pub is_abstract: bool,
1578    pub file: Arc<str>,
1579    /// The declared name's own token (1-based line, 0-based char columns).
1580    pub range: crate::Range,
1581}
1582
1583/// Build a [`crate::Range`] on one line from mir's native coordinates
1584/// (1-based line, 0-based columns).
1585fn span_range(line: u32, col_start: u32, col_end: u32) -> crate::Range {
1586    crate::Range {
1587        start: crate::Position {
1588            line,
1589            column: col_start,
1590        },
1591        end: crate::Position {
1592            line,
1593            column: col_end,
1594        },
1595    }
1596}
1597
1598/// Char column of the first word-boundary occurrence of `needle` in `line`
1599/// at or after char column `min_col`. Columns are code points, matching the
1600/// collector's `Location` convention.
1601fn identifier_char_col(
1602    line: &str,
1603    needle: &str,
1604    min_col: usize,
1605    case_insensitive: bool,
1606) -> Option<u32> {
1607    if needle.is_empty() {
1608        return None;
1609    }
1610    let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
1611    let chars: Vec<char> = line.chars().collect();
1612    let needle_chars: Vec<char> = needle.chars().collect();
1613    let n = needle_chars.len();
1614    if chars.len() < n {
1615        return None;
1616    }
1617    for start in min_col..=chars.len().saturating_sub(n) {
1618        let matches = chars[start..start + n]
1619            .iter()
1620            .zip(needle_chars.iter())
1621            .all(|(a, b)| {
1622                if case_insensitive {
1623                    a.eq_ignore_ascii_case(b)
1624                } else {
1625                    a == b
1626                }
1627            });
1628        if !matches {
1629            continue;
1630        }
1631        let before_ok = start == 0 || !is_ident(chars[start - 1]);
1632        let after = start + n;
1633        let after_ok = after >= chars.len() || !is_ident(chars[after]);
1634        if before_ok && after_ok {
1635            return Some(start as u32);
1636        }
1637    }
1638    None
1639}
1640
1641/// Whether `hay` mentions `needle` as a whole identifier (ASCII word
1642/// boundaries; conservative near multibyte text). ASCII-case-insensitive:
1643/// PHP class, function, and method names are case-insensitive, so `new
1644/// COLOR()` must count as mentioning `Color`; for the case-sensitive kinds
1645/// (constants, properties) folding only widens the candidate superset.
1646///
1647/// Test-only semantic oracle: the production gates (references freshness,
1648/// subtype-BFS defs commit) answer this predicate through the persistent
1649/// `ClassMentionIndex`; the parity test below pins the scanner to these
1650/// exact boundary and case semantics.
1651#[cfg(test)]
1652fn mentions_identifier(hay: &str, needle: &str) -> bool {
1653    let hay = hay.as_bytes();
1654    let needle = needle.as_bytes();
1655    let n = needle.len();
1656    if n == 0 || hay.len() < n {
1657        return false;
1658    }
1659    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1660    let first = needle[0].to_ascii_lowercase();
1661    for i in 0..=(hay.len() - n) {
1662        if hay[i].to_ascii_lowercase() != first || !hay[i..i + n].eq_ignore_ascii_case(needle) {
1663            continue;
1664        }
1665        if (i == 0 || !is_ident(hay[i - 1])) && (i + n == hay.len() || !is_ident(hay[i + n])) {
1666            return true;
1667        }
1668    }
1669    false
1670}
1671
1672fn short(fqn: &str) -> &str {
1673    fqn.rsplit('\\').next().unwrap_or(fqn)
1674}
1675
1676/// A candidate-file admission predicate for `indexed_references_to`'s
1677/// freshness pass, chosen by [`AnalysisSession::reference_gate`]. A file is
1678/// admitted when its text mentions any of `idents` as a whole identifier
1679/// (word-bounded, ASCII-case-insensitive) OR contains any of `raw` as a
1680/// plain substring (ASCII-case-insensitive, no word bounds — used for
1681/// call-shaped tokens like `->__construct`). A file matching neither can
1682/// hold no posting for the symbol.
1683struct ReferenceGate {
1684    idents: Vec<String>,
1685    raw: Vec<String>,
1686}
1687
1688/// Identifier words whose whole-word presence in a file's text is necessary
1689/// for the file to hold any posting [`AnalysisSession::indexed_references_to`]
1690/// can return for `symbol`. Member symbols include the owner class's short
1691/// name alongside the member name: `__construct` postings are recorded at
1692/// `new Cls(` sites, which never spell the member name.
1693fn reference_gate_needles(symbol: &crate::Name) -> Vec<String> {
1694    let mut needles = match symbol {
1695        crate::Name::Class(f) | crate::Name::Function(f) | crate::Name::GlobalConstant(f) => {
1696            vec![short(f).to_string()]
1697        }
1698        // `__construct` is invoked only as `new Cls(...)`, `parent::__construct()`,
1699        // or `self::__construct()`/`static::__construct()` from inside a
1700        // subclass — every real call site textually names the class itself
1701        // (directly, or via the enclosing subclass's own `extends`/`use`),
1702        // never the bare word `__construct`. Gating on the class's short name
1703        // alone is exact (no lost call sites) and, unlike the general member
1704        // case, dropping the method-name needle here doesn't reintroduce a
1705        // false negative. This matters: `__construct` is one of the most
1706        // common tokens in any real codebase, so OR-ing it in as a needle
1707        // admits nearly every file as a "must re-analyze" candidate on a
1708        // cold query, defeating the gate's entire purpose for constructors.
1709        crate::Name::Method { class, name } if name.as_ref() == "__construct" => {
1710            if class.is_empty() {
1711                // No class to scope to (owner unknown) — fall back to gating
1712                // on the bare name, same as the general member case below.
1713                vec![name.to_string()]
1714            } else {
1715                vec![short(class).to_string()]
1716            }
1717        }
1718        crate::Name::Method { class, name }
1719        | crate::Name::Property { class, name }
1720        | crate::Name::ClassConstant { class, name } => {
1721            let mut v = vec![name.to_string()];
1722            if !class.is_empty() {
1723                v.push(short(class).to_string());
1724            }
1725            v
1726        }
1727    };
1728    // An empty needle can never match; dropping it keeps the "empty needle
1729    // set disables the gate" contract at the call site conservative.
1730    needles.retain(|n| !n.is_empty());
1731    needles
1732}
1733
1734#[cfg(test)]
1735mod tests {
1736    use super::*;
1737
1738    #[test]
1739    fn mentions_identifier_is_case_insensitive_and_word_bounded() {
1740        assert!(mentions_identifier("$this->save();", "save"));
1741        assert!(mentions_identifier("new COLOR()", "Color"));
1742        assert!(mentions_identifier("use App\\Color as Paint;", "color"));
1743        assert!(!mentions_identifier("$this->saveAll();", "save"));
1744        assert!(!mentions_identifier("return $unsaved;", "save"));
1745        assert!(!mentions_identifier("no occurrence", "save"));
1746        assert!(!mentions_identifier("anything", ""));
1747        // Multibyte neighbors are conservatively treated as boundaries, and
1748        // substring scans must not split codepoints.
1749        assert!(!mentions_identifier("function xÉclairFoo() {}", "Éclair"));
1750        assert!(mentions_identifier("implements Éclair {}", "Éclair"));
1751    }
1752
1753    #[test]
1754    fn mention_scanner_membership_equals_per_needle_scans() {
1755        // The mention index is the sole implementation of the gates' textual
1756        // predicate, so scanner membership must equal the reference
1757        // per-needle predicate for every (hay, needle) pair — same boundary
1758        // and case semantics.
1759        use crate::db::MentionScanner;
1760        use std::sync::Arc;
1761        let universe = ["Color", "save", "ColorPicker", "Éclair", "C1", "_Wrap"];
1762        let names: Vec<(mir_types::Name, bool)> = universe
1763            .iter()
1764            .map(|s| (mir_types::Name::new(s).ascii_lowercase(), false))
1765            .collect();
1766        let scanner = Arc::new(MentionScanner::build(1, names).unwrap());
1767        let hays = [
1768            "$this->save();",
1769            "new COLOR()",
1770            "use App\\Color as Paint;",
1771            "$this->saveAll();",
1772            "return $unsaved;",
1773            "new ColorPicker(); Color::save();",
1774            "function xÉclairFoo() {}",
1775            "implements Éclair {}",
1776            "colorsave savecolor color_save",
1777            "class C1 extends _Wrap {}",
1778            "",
1779        ];
1780        for hay in hays {
1781            let scanned = scanner.scan(hay);
1782            for needle in universe {
1783                let expected = mentions_identifier(hay, needle);
1784                let name = mir_types::Name::new(needle).ascii_lowercase();
1785                assert_eq!(
1786                    scanned.binary_search(&name).is_ok(),
1787                    expected,
1788                    "needle {needle:?} on {hay:?}"
1789                );
1790            }
1791        }
1792    }
1793
1794    #[test]
1795    fn gate_needles_cover_member_and_owner_class() {
1796        // A regular member (non-constructor) gates on both the member name
1797        // and the owner's short name — a call site may name only one.
1798        let n = reference_gate_needles(&crate::Name::method("App\\Job", "run"));
1799        assert!(n.contains(&"run".to_string()) && n.contains(&"Job".to_string()));
1800        let n = reference_gate_needles(&crate::Name::class("App\\Ui\\Color"));
1801        assert_eq!(n, vec!["Color".to_string()]);
1802        // Unknown-owner member symbols still gate on the member name alone.
1803        let n = reference_gate_needles(&crate::Name::method("", "run"));
1804        assert_eq!(n, vec!["run".to_string()]);
1805    }
1806
1807    #[test]
1808    fn gate_needles_for_constructor_scope_to_owner_class_only() {
1809        // `__construct` is only ever spelled at `new Cls(`/`parent::__construct()`
1810        // sites, which always name the class — the bare method-name needle is
1811        // dropped so a cold constructor query doesn't admit nearly every file
1812        // in the workspace (every class defines *some* `__construct`).
1813        let n = reference_gate_needles(&crate::Name::method("App\\Job", "__construct"));
1814        assert_eq!(n, vec!["Job".to_string()]);
1815        // Unknown owner: nothing to scope to, fall back to the bare name.
1816        let n = reference_gate_needles(&crate::Name::method("", "__construct"));
1817        assert_eq!(n, vec!["__construct".to_string()]);
1818    }
1819
1820    fn session_with(files: &[(&str, &str)]) -> crate::AnalysisSession {
1821        let session = crate::AnalysisSession::new(crate::PhpVersion::LATEST);
1822        for (path, text) in files {
1823            session.set_file_text(Arc::from(*path), Arc::from(*text));
1824        }
1825        session
1826    }
1827
1828    #[test]
1829    fn gate_static_method_is_member_name_only() {
1830        // Regardless of subtypes: the member token alone is the sound gate
1831        // (an instance receiver `$obj::m()` never names the owner), and it
1832        // is also the selective part — the owner short name would only
1833        // widen the admitted set.
1834        let session = session_with(&[
1835            (
1836                "owner.php",
1837                "<?php\nclass Owner { public static function m(): void {} }\n",
1838            ),
1839            ("sub.php", "<?php\nclass Sub extends Owner {}\n"),
1840        ]);
1841        let gate = session.reference_gate(&crate::Name::method("Owner", "m"));
1842        assert_eq!(gate.idents, vec!["m".to_string()]);
1843        assert!(gate.raw.is_empty());
1844    }
1845
1846    #[test]
1847    fn gate_instance_method_is_member_name_only() {
1848        let session = session_with(&[(
1849            "owner.php",
1850            "<?php\nclass Owner { public function m(): void {} }\n",
1851        )]);
1852        let gate = session.reference_gate(&crate::Name::method("Owner", "m"));
1853        assert_eq!(gate.idents, vec!["m".to_string()]);
1854        assert!(gate.raw.is_empty());
1855    }
1856
1857    #[test]
1858    fn gate_invoke_keeps_owner_needle() {
1859        let session = session_with(&[(
1860            "owner.php",
1861            "<?php\nclass Owner { public function __invoke(): void {} }\n",
1862        )]);
1863        let gate = session.reference_gate(&crate::Name::method("Owner", "__invoke"));
1864        assert_eq!(
1865            gate.idents,
1866            reference_gate_needles(&crate::Name::method("Owner", "__invoke"))
1867        );
1868        assert!(gate.raw.is_empty());
1869    }
1870
1871    #[test]
1872    fn gate_constructor_adds_raw_call_tokens() {
1873        // Owner short name for `new Cls(` sites, plus the raw call tokens
1874        // for explicit re-init (`$obj->__construct()`) whose file may never
1875        // name the class. The bare identifier `__construct` must NOT be a
1876        // needle — it would admit every file declaring a constructor.
1877        let session = session_with(&[(
1878            "owner.php",
1879            "<?php\nclass Owner { public function __construct() {} }\n",
1880        )]);
1881        let gate = session.reference_gate(&crate::Name::method("Owner", "__construct"));
1882        assert_eq!(
1883            gate.idents,
1884            reference_gate_needles(&crate::Name::method("Owner", "__construct"))
1885        );
1886        assert_eq!(
1887            gate.raw,
1888            vec!["->__construct".to_string(), "::__construct".to_string()]
1889        );
1890    }
1891
1892    #[test]
1893    fn gate_unresolvable_owner_falls_back_to_general_needles() {
1894        let session = session_with(&[(
1895            "owner.php",
1896            "<?php\nclass Owner { public static function m(): void {} }\n",
1897        )]);
1898        let gate = session.reference_gate(&crate::Name::method("Nonexistent\\Missing", "m"));
1899        assert_eq!(
1900            gate.idents,
1901            reference_gate_needles(&crate::Name::method("Nonexistent\\Missing", "m"))
1902        );
1903        assert!(gate.raw.is_empty());
1904    }
1905}