Skip to main content

mir_analyzer/session/
queries.rs

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