Skip to main content

mir_analyzer/session/
queries.rs

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