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