Skip to main content

mir_analyzer/session/
queries.rs

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