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        let needles = reference_gate_needles(symbol);
318        let needle_matcher = IdentifierNeedles::new(&needles);
319        let committed_any: rustc_hash::FxHashSet<Arc<str>> =
320            self.ref_committed_keys().into_iter().collect();
321        let stale: Vec<Arc<str>> = loop {
322            if should_cancel() {
323                return None;
324            }
325            let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
326                let current_gen = self.index_generation();
327                let db_main = self.snapshot_db();
328                files
329                    .par_iter()
330                    .map_with(db_main, |db, f| {
331                        let sf = db.lookup_source_file(f.as_ref())?;
332                        let text = sf.text(&*db as &dyn MirDatabase);
333                        if self.is_ref_committed(f.as_ref(), text, current_gen) {
334                            return None;
335                        }
336                        if !committed_any.contains(f.as_ref())
337                            && !needles.is_empty()
338                            && !needle_matcher.matches(text)
339                        {
340                            return None;
341                        }
342                        Some(f.clone())
343                    })
344                    .flatten()
345                    .collect::<Vec<_>>()
346            }));
347            match attempt {
348                Ok(v) => break v,
349                Err(_) if should_cancel() => return None,
350                Err(_) => {}
351            }
352        };
353
354        if !stale.is_empty() {
355            // Phase 1 (serial, no live snapshot held): warm up stale
356            // candidates. `prepare_file_for_analysis` mutates salsa inputs
357            // (via `load_class`), so a concurrent writer — the background
358            // warm sweep, or another request — can raise `salsa::Cancelled`
359            // partway through a file. Catch and retry the SAME file here
360            // rather than letting the panic escape: uncaught, it would force
361            // the caller's outer retry loop (`indexed_references`) to
362            // re-enter from scratch, redoing the freshness pass and
363            // re-walking every already-warmed file in `stale` (cheap no-ops
364            // via the `prepared_files` cache, but not free) before it even
365            // gets back to the file that was interrupted. This doesn't
366            // change how many times a write is ultimately attempted (the
367            // outer loop already retries indefinitely on `Cancelled`); it
368            // only narrows what a single cancellation discards from "the
369            // whole query so far" to "the one file that was mid-flight".
370            //
371            // Tried and reverted: running this loop itself in parallel
372            // (rayon, both per-file and whole-batch retry variants). Each
373            // file's warm-up is individually safe under concurrent access
374            // (every shared registry it touches — `prepared_files`,
375            // `unresolvable_fqcns`, `pending_eager_function_files`, the
376            // salsa db via `with_db_mut` — is lock-protected), but under the
377            // `concurrent_reference_cancel` stress test (sustained
378            // multi-thread writers + a background indexer, both hammering
379            // the same db while several readers each run this phase
380            // concurrently) both parallel variants deadlocked: CPU usage
381            // dropped to ~0 while wall time kept climbing, the signature of
382            // several OS threads parked on a lock rather than making
383            // progress — most likely the fixed-size rayon pool getting
384            // saturated with workers blocked on `with_db_mut`'s `RwLock`
385            // write lock (an OS-level block, invisible to rayon's
386            // cooperative scheduler) while the thread that would release it
387            // is itself queued waiting for a free pool worker. Serial
388            // execution never contends for the pool this way, so it stays
389            // the safe choice here even though it forgoes the extra
390            // wall-clock parallelism a large stale set could otherwise use.
391            for path in &stale {
392                loop {
393                    if should_cancel() {
394                        return None;
395                    }
396                    match salsa::Cancelled::catch(AssertUnwindSafe(|| {
397                        self.prepare_file_for_analysis(path)
398                    })) {
399                        Ok(()) => break,
400                        Err(_) if should_cancel() => return None,
401                        Err(_) => {}
402                    }
403                }
404            }
405
406            // Phase 2 (parallel, pure) under a cancellation retry loop, then
407            // a serial commit into both inverted indexes.
408            let (commit_gen, analyzed) = loop {
409                if should_cancel() {
410                    return None;
411                }
412                // Generation before the snapshot: a file add racing the
413                // analysis leaves these commits stale (self-healing on the
414                // next query), never wrongly fresh.
415                let gen = self.index_generation();
416                let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
417                    // Freeze on the pass-scoped snapshot (borrow-only symbol
418                    // lookups + pass-shared subtype cache): all lazy-loading
419                    // finished in Phase 1, and a concurrent index write
420                    // cancels this attempt, so the frozen view is never
421                    // stale. Same discipline as the batch body pass.
422                    let mut db_main = self.snapshot_db();
423                    db_main.freeze_workspace_index();
424                    stale
425                        .par_iter()
426                        .map_with(db_main, |db, path| {
427                            let sf = db.lookup_source_file(path.as_ref())?;
428                            let text = sf.text(&*db as &dyn MirDatabase).clone();
429                            let out = crate::db::analyze_file(&*db as &dyn MirDatabase, sf).clone();
430                            let defs =
431                                crate::db::collect_file_definitions(&*db as &dyn MirDatabase, sf);
432                            let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
433                            // Stage the disk-cache write only when the commit
434                            // below will rewrite postings (see the sweep in
435                            // `reanalyze_file_set` for the cost rationale).
436                            let put = if self.ref_commit_is_current(path.as_ref(), &text, &out) {
437                                None
438                            } else {
439                                self.stage_ref_cache_put(
440                                    &*db as &dyn MirDatabase,
441                                    sf,
442                                    path.as_ref(),
443                                    &text,
444                                    &out,
445                                )
446                            };
447                            Some((path.clone(), text, out, entries, put))
448                        })
449                        .flatten()
450                        .collect::<Vec<_>>()
451                }));
452                match attempt {
453                    Ok(v) => break (gen, v),
454                    Err(_) if should_cancel() => return None,
455                    Err(_) => {}
456                }
457            };
458            let mut analyzed = analyzed;
459            let guard = self.db.salsa.read();
460            for (file, text, out, entries, put) in analyzed.iter_mut() {
461                // Pointer-identical memo ⇒ identical postings: skip the
462                // index rewrite and only re-stamp the freshness mark.
463                if !self.ref_commit_is_current(file.as_ref(), text, out) {
464                    guard.set_file_reference_locations(file.as_ref(), out.ref_locs.to_vec());
465                }
466                if let Some(put) = put.take() {
467                    self.apply_ref_cache_put(file.as_ref(), out, put);
468                }
469                self.mark_ref_committed(
470                    file,
471                    text,
472                    Some(out),
473                    commit_gen,
474                    !out.has_unresolved_names(),
475                );
476                if !self.is_defs_committed(file.as_ref(), text) {
477                    guard.set_file_class_edges(file, entries.clone());
478                    self.mark_defs_committed(file, text);
479                }
480            }
481        }
482
483        // Posting lookup, filtered to the candidate scope.
484        //
485        // Member symbols resolve against the queried class plus its hierarchy
486        // (mir records member refs under the *declaring* class, so a query on
487        // an interface method must include implementor keys and vice versa).
488        // Name-only fallback postings — receivers whose type couldn't be
489        // resolved — are consulted only when the typed keys produce nothing,
490        // mirroring the pre-index two-tier behavior: exact results when
491        // resolution succeeds, by-name matches when nothing resolves.
492        // `__construct` stays exact: `new Sub()` invokes `Sub::__construct`
493        // even when only a parent declares one, so hierarchy fan-out would
494        // wrongly return subtype instantiation sites for a parent query.
495        let hierarchy: Vec<String> = match symbol {
496            crate::Name::Method { class, name } => {
497                if name.as_ref() == "__construct" || class.is_empty() {
498                    if class.is_empty() {
499                        Vec::new()
500                    } else {
501                        vec![class.trim_start_matches('\\').to_string()]
502                    }
503                } else {
504                    self.member_hierarchy_classes(class.as_ref())
505                }
506            }
507            crate::Name::Property { class, .. } | crate::Name::ClassConstant { class, .. } => {
508                if class.is_empty() {
509                    Vec::new()
510                } else {
511                    self.member_hierarchy_classes(class.as_ref())
512                }
513            }
514            _ => Vec::new(),
515        };
516        let primary_keys: Vec<String> = match symbol {
517            crate::Name::Method { name, .. } => hierarchy
518                .iter()
519                .map(|c| format!("meth:{c}::{name}"))
520                .collect(),
521            crate::Name::Property { name, .. } => hierarchy
522                .iter()
523                .map(|c| format!("prop:{c}::{name}"))
524                .collect(),
525            crate::Name::ClassConstant { name, .. } => hierarchy
526                .iter()
527                .map(|c| format!("cnst:{c}::{name}"))
528                .collect(),
529            _ => vec![key.clone()],
530        };
531        let fallback_key: Option<String> = match symbol {
532            crate::Name::Method { name, .. } => Some(format!("methname:{name}")),
533            crate::Name::Property { name, .. } => Some(format!("propname:{name}")),
534            _ => None,
535        };
536        let scope: rustc_hash::FxHashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
537        let read_keys = |keys: &[String]| -> Vec<(Arc<str>, crate::Range)> {
538            let guard = self.db.salsa.read();
539            let mut merged: Vec<(Arc<str>, u32, u16, u16)> = Vec::new();
540            for k in keys {
541                merged.extend(guard.reference_locations(k));
542            }
543            merged
544                .into_iter()
545                .filter(|(file, ..)| scope.contains(file.as_ref()))
546                .map(|(file, line, col_start, col_end)| {
547                    (file, span_range(line, col_start as u32, col_end as u32))
548                })
549                .collect()
550        };
551        let mut out = read_keys(&primary_keys);
552        if out.is_empty() {
553            if let Some(fk) = fallback_key {
554                out = read_keys(std::slice::from_ref(&fk));
555            }
556        }
557        out.sort_by(|a, b| {
558            a.0.cmp(&b.0)
559                .then(a.1.start.line.cmp(&b.1.start.line))
560                .then(a.1.start.column.cmp(&b.1.start.column))
561        });
562        out.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
563
564        if include_declaration {
565            // Declaration lookup runs salsa queries (and may lazy-load); a
566            // concurrent write cancels it — declarations are then simply
567            // omitted rather than failing the whole request.
568            let decls: Vec<(Arc<str>, crate::Range)> = match symbol {
569                crate::Name::Method { class, .. }
570                | crate::Name::Property { class, .. }
571                | crate::Name::ClassConstant { class, .. } => {
572                    if class.is_empty() {
573                        // Unknown owner: declarations by name, recorded as
574                        // `methdecl:`/`propdecl:`/`cnstdecl:` postings during
575                        // class/trait/interface/enum analysis.
576                        match symbol {
577                            crate::Name::Method { name, .. } => {
578                                read_keys(&[format!("methdecl:{name}")])
579                            }
580                            crate::Name::Property { name, .. } => {
581                                read_keys(&[format!("propdecl:{name}")])
582                            }
583                            crate::Name::ClassConstant { name, .. } => {
584                                read_keys(&[format!("cnstdecl:{name}")])
585                            }
586                            _ => Vec::new(),
587                        }
588                    } else {
589                        salsa::Cancelled::catch(AssertUnwindSafe(|| {
590                            self.member_decl_sites(&hierarchy, symbol)
591                        }))
592                        .unwrap_or_default()
593                    }
594                }
595                _ => salsa::Cancelled::catch(AssertUnwindSafe(|| {
596                    self.declaration_name_range(symbol).into_iter().collect()
597                }))
598                .unwrap_or_default(),
599            };
600            for (file, range) in decls {
601                if scope.contains(file.as_ref())
602                    && !out.iter().any(|(f, r)| *f == file && *r == range)
603                {
604                    out.push((file, range));
605                }
606            }
607        }
608        Some(out)
609    }
610
611    /// The queried class plus every class its members' references could be
612    /// keyed under: resolved ancestors (a call on a subtype instance records
613    /// the declaring ancestor) and transitive subtypes including trait users
614    /// (a call on a subtype that overrides records the subtype). Display-form
615    /// FQCNs, deduplicated case-insensitively.
616    fn member_hierarchy_classes(&self, class_fqn: &str) -> Vec<String> {
617        use std::panic::AssertUnwindSafe;
618        let target = class_fqn.trim_start_matches('\\').to_string();
619        let mut out: Vec<String> = vec![target.clone()];
620        let ancestors = salsa::Cancelled::catch(AssertUnwindSafe(|| {
621            let db = self.snapshot_db();
622            let here = crate::db::Fqcn::from_str(&db, &target);
623            crate::db::class_ancestors_by_fqcn(&db, here)
624                .iter()
625                .skip(1)
626                .map(|a| a.trim_start_matches('\\').to_string())
627                .collect::<Vec<_>>()
628        }))
629        .unwrap_or_default();
630        out.extend(ancestors);
631        let subs = {
632            let guard = self.db.salsa.read();
633            guard.subtype_sites_of(&target, true)
634        };
635        out.extend(
636            subs.into_iter()
637                .map(|s| s.fqcn.trim_start_matches('\\').to_string()),
638        );
639        let mut seen: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
640        out.retain(|c| seen.insert(c.to_ascii_lowercase()));
641        out
642    }
643
644    /// Own-member declaration sites for `symbol` across `classes`: each class
645    /// that itself declares the member (not inherited) contributes its name
646    /// token. Kind-specific lookups — a class often declares a property and a
647    /// method with the same short name, and `member_location` can't tell them
648    /// apart.
649    fn member_decl_sites(
650        &self,
651        classes: &[String],
652        symbol: &crate::Name,
653    ) -> Vec<(Arc<str>, crate::Range)> {
654        let mut out: Vec<(Arc<str>, crate::Range)> = Vec::new();
655        let db = self.snapshot_db();
656        for class in classes {
657            let here = crate::db::Fqcn::from_str(&db, class);
658            let (loc, needle) = match symbol {
659                crate::Name::Method { name, .. } => {
660                    let Some(m) = crate::db::find_method_in_class(&db, here, name) else {
661                        continue;
662                    };
663                    (m.location.clone(), name.to_string())
664                }
665                crate::Name::Property { name, .. } => {
666                    let Some(p) = crate::db::find_property_in_class(&db, here, name) else {
667                        continue;
668                    };
669                    (p.location.clone(), name.to_string())
670                }
671                crate::Name::ClassConstant { name, .. } => {
672                    let Some(c) = crate::db::find_class_constant_in_class(&db, here, name) else {
673                        continue;
674                    };
675                    (c.location.clone(), name.to_string())
676                }
677                _ => continue,
678            };
679            let Some(loc) = loc else { continue };
680            let range = self.refine_location_to_name(&loc, &needle);
681            out.push((loc.file.clone(), range));
682        }
683        out
684    }
685
686    /// The symbol's declaration site, narrowed from the collector's
687    /// whole-declaration span to the declared name's own token (matching the
688    /// span shape of recorded references).
689    pub fn declaration_name_range(&self, symbol: &crate::Name) -> Option<(Arc<str>, crate::Range)> {
690        if let crate::Name::GlobalConstant(fqn) = symbol {
691            return self.global_constant_decl_range(fqn);
692        }
693        let loc = self.definition_of(symbol).ok()?;
694        let short = match symbol {
695            crate::Name::Class(f) | crate::Name::Function(f) | crate::Name::GlobalConstant(f) => {
696                crate::db::subtype_index::short_name_of(f)
697            }
698            crate::Name::Method { name, .. }
699            | crate::Name::Property { name, .. }
700            | crate::Name::ClassConstant { name, .. } => name.as_ref(),
701        };
702        // Property declarations carry a `$` sigil in source, but reference
703        // ranges cover the bare name; the word-boundary search below lands on
704        // the name right after the sigil.
705        let file = loc.file.clone();
706        let range = self.refine_location_to_name(&loc, short);
707        Some((file, range))
708    }
709
710    /// Narrow a whole-declaration [`mir_types::Location`] to the first
711    /// word-boundary occurrence of `needle` inside its line span. Falls back
712    /// to the location's own coordinates when the text is unavailable or the
713    /// name doesn't appear (e.g. stub-only declarations).
714    fn refine_location_to_name(&self, loc: &mir_types::Location, needle: &str) -> crate::Range {
715        let fallback = span_range(loc.line, loc.col_start as u32, loc.col_end as u32);
716        let text = {
717            let db = self.snapshot_db();
718            db.lookup_source_file(loc.file.as_ref())
719                .map(|sf| sf.text(&db as &dyn MirDatabase).clone())
720        };
721        let Some(text) = text else {
722            return fallback;
723        };
724        let needle_chars = needle.chars().count() as u32;
725        let first_line = loc.line.saturating_sub(1) as usize;
726        // Exact-case first: PHP property/constant names are case-sensitive
727        // and an early case-insensitive hit can land on an unrelated token
728        // (a type hint sharing the name). Case-insensitive second, for
729        // method/class needles that arrive lowercase-normalized.
730        for case_insensitive in [false, true] {
731            for (idx, line_text) in text.lines().enumerate().skip(first_line) {
732                let line_no = idx as u32 + 1;
733                if line_no > loc.line_end {
734                    break;
735                }
736                let min_col = if line_no == loc.line {
737                    loc.col_start as usize
738                } else {
739                    0
740                };
741                if let Some(col) = identifier_char_col(line_text, needle, min_col, case_insensitive)
742                {
743                    return span_range(line_no, col, col + needle_chars);
744                }
745            }
746        }
747        fallback
748    }
749
750    /// Transitive subtypes of `class_fqn` (classes/interfaces/enums whose
751    /// resolved ancestor chain reaches it), answered from the maintained
752    /// subtype edge index.
753    ///
754    /// `files` is the host's candidate scope for the on-demand completeness
755    /// pass: per BFS round, not-yet-committed files whose text mentions a
756    /// frontier name get their definitions committed, so results are complete
757    /// even before a background sweep has covered the workspace. Committed
758    /// files answer from the index with no parsing at all.
759    ///
760    /// `include_trait_users` also counts `use Trait;` composition as a
761    /// subtype edge (visibility-scoping semantics); leave it off for
762    /// goto-implementation semantics (extends/implements only).
763    pub fn indexed_subtype_classes(
764        &self,
765        class_fqn: &str,
766        files: &[Arc<str>],
767        include_trait_users: bool,
768    ) -> Vec<SubtypeClassSite> {
769        let mut scanned: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
770        let mut pending: Vec<String> = vec![class_fqn.trim_start_matches('\\').to_string()];
771        let mut sites: Vec<crate::db::SubtypeSite> = Vec::new();
772        while !pending.is_empty() {
773            let needles: Vec<String> = pending
774                .drain(..)
775                .filter(|f| scanned.insert(f.clone()))
776                .map(|f| crate::db::subtype_index::short_name_of(&f).to_string())
777                .collect();
778            if !needles.is_empty() {
779                self.commit_defs_for_matching(files, &needles);
780            }
781            sites = {
782                let guard = self.db.salsa.read();
783                guard.subtype_sites_of_lenient(class_fqn, include_trait_users)
784            };
785            pending = sites
786                .iter()
787                .map(|s| s.fqcn.trim_start_matches('\\').to_string())
788                .filter(|f| !scanned.contains(f))
789                .collect();
790        }
791        let mut out: Vec<SubtypeClassSite> = sites
792            .into_iter()
793            .filter_map(|s| {
794                let loc = s.location.as_ref()?;
795                let short = crate::db::subtype_index::short_name_of(&s.fqcn).to_string();
796                let range = self.refine_location_to_name(loc, &short);
797                Some(SubtypeClassSite {
798                    fqcn: s.fqcn,
799                    kind: s.kind,
800                    is_abstract: s.is_abstract,
801                    file: s.file,
802                    range,
803                })
804            })
805            .collect();
806        // Anonymous classes never reach the definition collector; their
807        // `new class implements X {}` sites are recorded as `impl:` postings
808        // during body analysis (exact FQCN key plus a short-name key for the
809        // same written-form leniency named classes get above).
810        let root_lc = class_fqn.trim_start_matches('\\').to_ascii_lowercase();
811        let short_lc = crate::db::subtype_index::short_name_of(&root_lc).to_string();
812        let scope: rustc_hash::FxHashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
813        let anon: Vec<(Arc<str>, u32, u16, u16)> = {
814            let guard = self.db.salsa.read();
815            let mut v = guard.reference_locations(&format!("impl:{root_lc}"));
816            v.extend(guard.reference_locations(&format!("implshort:{short_lc}")));
817            v.sort();
818            v.dedup();
819            v
820        };
821        for (file, line, cs, ce) in anon {
822            if !scope.contains(file.as_ref()) {
823                continue;
824            }
825            let range = span_range(line, cs as u32, ce as u32);
826            if out.iter().any(|s| s.file == file && s.range == range) {
827                continue;
828            }
829            out.push(SubtypeClassSite {
830                fqcn: Arc::from("class@anonymous"),
831                kind: crate::db::ClassLikeKind::Class,
832                is_abstract: false,
833                file,
834                range,
835            });
836        }
837        out
838    }
839
840    /// Concrete implementations of `class_fqn::method` across its transitive
841    /// subtypes: the same-named non-abstract method available to each subtype
842    /// (its own declaration, or one inherited/composed from a parent, trait,
843    /// or mixin), as `(subtype fqcn, file, name range)`. Subtypes resolving to
844    /// the same declaring location collapse to a single entry.
845    pub fn indexed_method_implementations(
846        &self,
847        class_fqn: &str,
848        method: &str,
849        files: &[Arc<str>],
850    ) -> Vec<(Arc<str>, Arc<str>, crate::Range)> {
851        use std::panic::AssertUnwindSafe;
852        let subs = self.indexed_subtype_classes(class_fqn, files, false);
853        if subs.is_empty() {
854            return Vec::new();
855        }
856        loop {
857            let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
858                let db = self.snapshot_db();
859                let mut out: Vec<(Arc<str>, Arc<str>, crate::Range)> = Vec::new();
860                for sub in &subs {
861                    let here = crate::db::Fqcn::from_str(&db, sub.fqcn.as_ref());
862                    let Some((_, m)) = crate::db::find_method_in_chain(&db, here, method) else {
863                        continue;
864                    };
865                    if m.is_abstract {
866                        continue;
867                    }
868                    let Some(loc) = m.location.as_ref() else {
869                        continue;
870                    };
871                    let range = self.refine_location_to_name(loc, method);
872                    out.push((sub.fqcn.clone(), loc.file.clone(), range));
873                }
874                out
875            }));
876            if let Ok(mut out) = attempt {
877                out.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.start.line.cmp(&b.2.start.line)));
878                out.dedup_by(|a, b| a.1 == b.1 && a.2 == b.2);
879                return out;
880            }
881        }
882    }
883
884    /// Commit definitions (class edges + freshness) for every file in `files`
885    /// that is stale (committed against older text) or that has never been
886    /// committed and mentions one of `shorts` as a whole identifier.
887    fn commit_defs_for_matching(&self, files: &[Arc<str>], shorts: &[String]) {
888        use std::panic::AssertUnwindSafe;
889
890        use rayon::prelude::*;
891
892        let committed_any: rustc_hash::FxHashSet<Arc<str>> = {
893            let guard = self.defs_committed_keys();
894            guard.into_iter().collect()
895        };
896        let needles = IdentifierNeedles::new(shorts);
897        let work = loop {
898            let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
899                let db_main = self.snapshot_db();
900                files
901                    .par_iter()
902                    .map_with(db_main, |db, path| {
903                        let sf = db.lookup_source_file(path.as_ref())?;
904                        let text = sf.text(&*db as &dyn MirDatabase).clone();
905                        if self.is_defs_committed(path.as_ref(), &text) {
906                            return None;
907                        }
908                        // Never-committed files must mention a frontier name;
909                        // stale (previously committed) files recommit
910                        // unconditionally — their classes may have re-parented.
911                        if !committed_any.contains(path.as_ref()) && !needles.matches(&text) {
912                            return None;
913                        }
914                        let defs =
915                            crate::db::collect_file_definitions(&*db as &dyn MirDatabase, sf);
916                        let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
917                        Some((path.clone(), text, entries))
918                    })
919                    .flatten()
920                    .collect::<Vec<_>>()
921            }));
922            if let Ok(v) = attempt {
923                break v;
924            }
925        };
926        if work.is_empty() {
927            return;
928        }
929        let guard = self.db.salsa.read();
930        for (file, text, entries) in &work {
931            guard.set_file_class_edges(file, entries.clone());
932            self.mark_defs_committed(file, text);
933        }
934    }
935
936    /// Declaration name span for a global constant. Constant slices carry no
937    /// stored location, so this finds the declaring file via the workspace
938    /// constants index and locates the `const NAME` / `define('NAME'` token
939    /// textually.
940    fn global_constant_decl_range(&self, fqn: &str) -> Option<(Arc<str>, crate::Range)> {
941        use std::panic::AssertUnwindSafe;
942        let short = crate::db::subtype_index::short_name_of(fqn).to_string();
943        salsa::Cancelled::catch(AssertUnwindSafe(|| {
944            let db = self.snapshot_db();
945            let index = crate::db::workspace_index(&db);
946            let loc = index
947                .constants
948                .get(&mir_types::Name::from(fqn.trim_start_matches('\\')))?;
949            let file = loc.file().path(&db).clone();
950            let sf = db.lookup_source_file(file.as_ref())?;
951            let text = sf.text(&db as &dyn MirDatabase);
952            for (idx, line) in text.lines().enumerate() {
953                let trimmed = line.trim_start();
954                let is_decl_line = trimmed.starts_with("const ")
955                    || trimmed.contains("define(")
956                    || trimmed.contains("define (");
957                if !is_decl_line {
958                    continue;
959                }
960                if let Some(col) = identifier_char_col(line, &short, 0, false) {
961                    let n = short.chars().count() as u32;
962                    return Some((file, span_range(idx as u32 + 1, col, col + n)));
963                }
964            }
965            None
966        }))
967        .ok()
968        .flatten()
969    }
970
971    /// Class-level issues (inheritance violations, abstract-method gaps, override
972    /// incompatibilities) for the given set of files.
973    ///
974    /// These checks are cross-file by nature and are not emitted by
975    /// [`crate::FileAnalyzer::analyze`]. Call this after ingesting or
976    /// re-analyzing a file and its dependents to get the full diagnostic picture.
977    ///
978    /// Circular-inheritance checks always run against the full workspace graph
979    /// regardless of the `files` filter — a cycle is a workspace-wide problem.
980    pub fn class_issues(&self, files: &[Arc<str>]) -> Vec<crate::Issue> {
981        let db = self.snapshot_db();
982        let file_set: HashSet<Arc<str>> = files.iter().cloned().collect();
983        // Read source texts through the snapshot already in hand — calling
984        // `source_of` here would re-enter the session RwLock while this
985        // snapshot is live, and a concurrent salsa write (which blocks new
986        // readers behind the fair write lock while waiting for existing
987        // snapshots to drop) turns that into a deadlock.
988        let file_data: Vec<(Arc<str>, Arc<str>)> = files
989            .iter()
990            .filter_map(|f| {
991                let sf = db.lookup_source_file(f)?;
992                Some((
993                    f.clone(),
994                    sf.text(&db as &dyn crate::db::MirDatabase).clone(),
995                ))
996            })
997            .collect();
998        crate::class::ClassAnalyzer::with_files(&db, file_set, &file_data).analyze_all()
999    }
1000
1001    /// All declarations defined in `file` as a **hierarchical tree**.
1002    ///
1003    /// Classes/interfaces/traits/enums are returned with their methods,
1004    /// properties, and constants nested in `children`. Top-level functions
1005    /// and constants are returned with empty `children`.
1006    pub fn document_symbols(&self, file: &str) -> Vec<crate::symbol::DocumentSymbol> {
1007        use crate::symbol::{DeclarationKind, DocumentSymbol};
1008
1009        let db = self.snapshot_db();
1010        let Some(sf) = db.lookup_source_file(file) else {
1011            return Vec::new();
1012        };
1013        let defs = crate::db::collect_file_definitions(&db, sf);
1014        let mut out: Vec<DocumentSymbol> = Vec::new();
1015
1016        let class_children = |methods: &mir_codebase::definitions::MemberMap<
1017            Arc<mir_codebase::definitions::MethodDef>,
1018        >,
1019                              props: Option<
1020            &mir_codebase::definitions::MemberMap<mir_codebase::definitions::PropertyDef>,
1021        >,
1022                              consts: &mir_codebase::definitions::MemberMap<
1023            mir_codebase::definitions::ConstantDef,
1024        >,
1025                              is_enum: bool|
1026         -> Vec<DocumentSymbol> {
1027            let mut out: Vec<DocumentSymbol> = Vec::new();
1028            for (_, m) in methods.iter() {
1029                out.push(DocumentSymbol {
1030                    name: m.name.clone(),
1031                    kind: DeclarationKind::Method,
1032                    location: m.location.clone(),
1033                    children: Vec::new(),
1034                });
1035            }
1036            if let Some(props) = props {
1037                for (_, p) in props.iter() {
1038                    out.push(DocumentSymbol {
1039                        name: p.name.clone(),
1040                        kind: DeclarationKind::Property,
1041                        location: p.location.clone(),
1042                        children: Vec::new(),
1043                    });
1044                }
1045            }
1046            let const_kind = if is_enum {
1047                DeclarationKind::EnumCase
1048            } else {
1049                DeclarationKind::Constant
1050            };
1051            for (_, c) in consts.iter() {
1052                out.push(DocumentSymbol {
1053                    name: c.name.clone(),
1054                    kind: const_kind,
1055                    location: c.location.clone(),
1056                    children: Vec::new(),
1057                });
1058            }
1059            out
1060        };
1061
1062        for c in defs.slice.classes.iter() {
1063            out.push(DocumentSymbol {
1064                name: c.fqcn.clone(),
1065                kind: DeclarationKind::Class,
1066                location: c.location.clone(),
1067                children: class_children(
1068                    &c.own_methods,
1069                    Some(&c.own_properties),
1070                    &c.own_constants,
1071                    false,
1072                ),
1073            });
1074        }
1075        for i in defs.slice.interfaces.iter() {
1076            out.push(DocumentSymbol {
1077                name: i.fqcn.clone(),
1078                kind: DeclarationKind::Interface,
1079                location: i.location.clone(),
1080                children: class_children(&i.own_methods, None, &i.own_constants, false),
1081            });
1082        }
1083        for t in defs.slice.traits.iter() {
1084            out.push(DocumentSymbol {
1085                name: t.fqcn.clone(),
1086                kind: DeclarationKind::Trait,
1087                location: t.location.clone(),
1088                children: class_children(
1089                    &t.own_methods,
1090                    Some(&t.own_properties),
1091                    &t.own_constants,
1092                    false,
1093                ),
1094            });
1095        }
1096        for e in defs.slice.enums.iter() {
1097            let mut children = class_children(&e.own_methods, None, &e.own_constants, true);
1098            for (_, case) in e.cases.iter() {
1099                children.push(DocumentSymbol {
1100                    name: case.name.clone(),
1101                    kind: DeclarationKind::EnumCase,
1102                    location: case.location.clone(),
1103                    children: Vec::new(),
1104                });
1105            }
1106            out.push(DocumentSymbol {
1107                name: e.fqcn.clone(),
1108                kind: DeclarationKind::Enum,
1109                location: e.location.clone(),
1110                children,
1111            });
1112        }
1113        for f in defs.slice.functions.iter() {
1114            out.push(DocumentSymbol {
1115                name: f.fqn.clone(),
1116                kind: DeclarationKind::Function,
1117                location: f.location.clone(),
1118                children: Vec::new(),
1119            });
1120        }
1121        for (name, _) in defs.slice.constants.iter() {
1122            out.push(DocumentSymbol {
1123                name: name.clone(),
1124                kind: DeclarationKind::Constant,
1125                location: None,
1126                children: Vec::new(),
1127            });
1128        }
1129        out
1130    }
1131}
1132
1133/// A transitive subtype hit with its declaration name span, as returned by
1134/// [`AnalysisSession::indexed_subtype_classes`].
1135#[derive(Debug, Clone)]
1136pub struct SubtypeClassSite {
1137    /// Display-form FQCN (no leading `\`).
1138    pub fqcn: Arc<str>,
1139    pub kind: crate::db::ClassLikeKind,
1140    pub is_abstract: bool,
1141    pub file: Arc<str>,
1142    /// The declared name's own token (1-based line, 0-based char columns).
1143    pub range: crate::Range,
1144}
1145
1146/// Build a [`crate::Range`] on one line from mir's native coordinates
1147/// (1-based line, 0-based columns).
1148fn span_range(line: u32, col_start: u32, col_end: u32) -> crate::Range {
1149    crate::Range {
1150        start: crate::Position {
1151            line,
1152            column: col_start,
1153        },
1154        end: crate::Position {
1155            line,
1156            column: col_end,
1157        },
1158    }
1159}
1160
1161/// Char column of the first word-boundary occurrence of `needle` in `line`
1162/// at or after char column `min_col`. Columns are code points, matching the
1163/// collector's `Location` convention.
1164fn identifier_char_col(
1165    line: &str,
1166    needle: &str,
1167    min_col: usize,
1168    case_insensitive: bool,
1169) -> Option<u32> {
1170    if needle.is_empty() {
1171        return None;
1172    }
1173    let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
1174    let chars: Vec<char> = line.chars().collect();
1175    let needle_chars: Vec<char> = needle.chars().collect();
1176    let n = needle_chars.len();
1177    if chars.len() < n {
1178        return None;
1179    }
1180    for start in min_col..=chars.len().saturating_sub(n) {
1181        let matches = chars[start..start + n]
1182            .iter()
1183            .zip(needle_chars.iter())
1184            .all(|(a, b)| {
1185                if case_insensitive {
1186                    a.eq_ignore_ascii_case(b)
1187                } else {
1188                    a == b
1189                }
1190            });
1191        if !matches {
1192            continue;
1193        }
1194        let before_ok = start == 0 || !is_ident(chars[start - 1]);
1195        let after = start + n;
1196        let after_ok = after >= chars.len() || !is_ident(chars[after]);
1197        if before_ok && after_ok {
1198            return Some(start as u32);
1199        }
1200    }
1201    None
1202}
1203
1204/// Compiled multi-needle form of [`mentions_identifier`]: one SIMD-backed
1205/// pass over the text for the whole needle set instead of one byte scan per
1206/// needle. Identical semantics — whole-identifier, ASCII-case-insensitive.
1207/// Build once per sweep and share across the rayon workers; matters when a
1208/// subtype BFS round carries dozens of frontier names across an
1209/// O(workspace) candidate scan.
1210pub(crate) struct IdentifierNeedles {
1211    /// `None` when the needle set is empty or the automaton failed to build
1212    /// (pattern-set limits — unreachable for identifier words); the fallback
1213    /// then rescans per needle so behavior never changes, only speed.
1214    ac: Option<aho_corasick::AhoCorasick>,
1215    needles: Vec<String>,
1216}
1217
1218impl IdentifierNeedles {
1219    pub(crate) fn new(needles: &[String]) -> Self {
1220        let kept: Vec<String> = needles.iter().filter(|n| !n.is_empty()).cloned().collect();
1221        let ac = if kept.is_empty() {
1222            None
1223        } else {
1224            aho_corasick::AhoCorasick::builder()
1225                .ascii_case_insensitive(true)
1226                .build(&kept)
1227                .ok()
1228        };
1229        Self { ac, needles: kept }
1230    }
1231
1232    /// Whether `hay` mentions any needle as a whole identifier. Overlapping
1233    /// iteration enumerates every occurrence of every needle, so the word-
1234    /// boundary filter sees exactly the candidates the per-needle scans would.
1235    pub(crate) fn matches(&self, hay: &str) -> bool {
1236        let Some(ac) = &self.ac else {
1237            return self.needles.iter().any(|n| mentions_identifier(hay, n));
1238        };
1239        let bytes = hay.as_bytes();
1240        let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1241        ac.find_overlapping_iter(hay).any(|m| {
1242            (m.start() == 0 || !is_ident(bytes[m.start() - 1]))
1243                && (m.end() == bytes.len() || !is_ident(bytes[m.end()]))
1244        })
1245    }
1246}
1247
1248/// Whether `hay` mentions `needle` as a whole identifier (ASCII word
1249/// boundaries; conservative near multibyte text). ASCII-case-insensitive:
1250/// PHP class, function, and method names are case-insensitive, so `new
1251/// COLOR()` must count as mentioning `Color`; for the case-sensitive kinds
1252/// (constants, properties) folding only widens the candidate superset.
1253/// Gates the completeness passes so they never analyze files that cannot
1254/// name the symbol.
1255fn mentions_identifier(hay: &str, needle: &str) -> bool {
1256    let hay = hay.as_bytes();
1257    let needle = needle.as_bytes();
1258    let n = needle.len();
1259    if n == 0 || hay.len() < n {
1260        return false;
1261    }
1262    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1263    let first = needle[0].to_ascii_lowercase();
1264    for i in 0..=(hay.len() - n) {
1265        if hay[i].to_ascii_lowercase() != first || !hay[i..i + n].eq_ignore_ascii_case(needle) {
1266            continue;
1267        }
1268        if (i == 0 || !is_ident(hay[i - 1])) && (i + n == hay.len() || !is_ident(hay[i + n])) {
1269            return true;
1270        }
1271    }
1272    false
1273}
1274
1275/// Identifier words whose whole-word presence in a file's text is necessary
1276/// for the file to hold any posting [`AnalysisSession::indexed_references_to`]
1277/// can return for `symbol`. Member symbols include the owner class's short
1278/// name alongside the member name: `__construct` postings are recorded at
1279/// `new Cls(` sites, which never spell the member name.
1280fn reference_gate_needles(symbol: &crate::Name) -> Vec<String> {
1281    fn short(fqn: &str) -> &str {
1282        fqn.rsplit('\\').next().unwrap_or(fqn)
1283    }
1284    let mut needles = match symbol {
1285        crate::Name::Class(f) | crate::Name::Function(f) | crate::Name::GlobalConstant(f) => {
1286            vec![short(f).to_string()]
1287        }
1288        // `__construct` is invoked only as `new Cls(...)`, `parent::__construct()`,
1289        // or `self::__construct()`/`static::__construct()` from inside a
1290        // subclass — every real call site textually names the class itself
1291        // (directly, or via the enclosing subclass's own `extends`/`use`),
1292        // never the bare word `__construct`. Gating on the class's short name
1293        // alone is exact (no lost call sites) and, unlike the general member
1294        // case, dropping the method-name needle here doesn't reintroduce a
1295        // false negative. This matters: `__construct` is one of the most
1296        // common tokens in any real codebase, so OR-ing it in as a needle
1297        // admits nearly every file as a "must re-analyze" candidate on a
1298        // cold query, defeating the gate's entire purpose for constructors.
1299        crate::Name::Method { class, name } if name.as_ref() == "__construct" => {
1300            if class.is_empty() {
1301                // No class to scope to (owner unknown) — fall back to gating
1302                // on the bare name, same as the general member case below.
1303                vec![name.to_string()]
1304            } else {
1305                vec![short(class).to_string()]
1306            }
1307        }
1308        crate::Name::Method { class, name }
1309        | crate::Name::Property { class, name }
1310        | crate::Name::ClassConstant { class, name } => {
1311            let mut v = vec![name.to_string()];
1312            if !class.is_empty() {
1313                v.push(short(class).to_string());
1314            }
1315            v
1316        }
1317    };
1318    // An empty needle can never match; dropping it keeps the "empty needle
1319    // set disables the gate" contract at the call site conservative.
1320    needles.retain(|n| !n.is_empty());
1321    needles
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326    use super::*;
1327
1328    #[test]
1329    fn mentions_identifier_is_case_insensitive_and_word_bounded() {
1330        assert!(mentions_identifier("$this->save();", "save"));
1331        assert!(mentions_identifier("new COLOR()", "Color"));
1332        assert!(mentions_identifier("use App\\Color as Paint;", "color"));
1333        assert!(!mentions_identifier("$this->saveAll();", "save"));
1334        assert!(!mentions_identifier("return $unsaved;", "save"));
1335        assert!(!mentions_identifier("no occurrence", "save"));
1336        assert!(!mentions_identifier("anything", ""));
1337        // Multibyte neighbors are conservatively treated as boundaries, and
1338        // substring scans must not split codepoints.
1339        assert!(!mentions_identifier("function xÉclairFoo() {}", "Éclair"));
1340        assert!(mentions_identifier("implements Éclair {}", "Éclair"));
1341    }
1342
1343    #[test]
1344    fn identifier_needles_match_per_needle_scans_exactly() {
1345        let hays = [
1346            "$this->save();",
1347            "new COLOR()",
1348            "use App\\Color as Paint;",
1349            "$this->saveAll();",
1350            "return $unsaved;",
1351            "no occurrence",
1352            "function xÉclairFoo() {}",
1353            "implements Éclair {}",
1354            "save",
1355            "Color save",
1356            "colorsave savecolor",
1357            "",
1358        ];
1359        let needle_sets: [&[&str]; 4] = [
1360            &["save"],
1361            &["Color", "save"],
1362            &["Éclair", "color", "occurrence"],
1363            &[],
1364        ];
1365        for needles in needle_sets {
1366            let owned: Vec<String> = needles.iter().map(|s| s.to_string()).collect();
1367            let compiled = IdentifierNeedles::new(&owned);
1368            for hay in hays {
1369                assert_eq!(
1370                    compiled.matches(hay),
1371                    owned.iter().any(|n| mentions_identifier(hay, n)),
1372                    "needles {owned:?} on {hay:?}"
1373                );
1374            }
1375        }
1376    }
1377
1378    #[test]
1379    fn gate_needles_cover_member_and_owner_class() {
1380        // A regular member (non-constructor) gates on both the member name
1381        // and the owner's short name — a call site may name only one.
1382        let n = reference_gate_needles(&crate::Name::method("App\\Job", "run"));
1383        assert!(n.contains(&"run".to_string()) && n.contains(&"Job".to_string()));
1384        let n = reference_gate_needles(&crate::Name::class("App\\Ui\\Color"));
1385        assert_eq!(n, vec!["Color".to_string()]);
1386        // Unknown-owner member symbols still gate on the member name alone.
1387        let n = reference_gate_needles(&crate::Name::method("", "run"));
1388        assert_eq!(n, vec!["run".to_string()]);
1389    }
1390
1391    #[test]
1392    fn gate_needles_for_constructor_scope_to_owner_class_only() {
1393        // `__construct` is only ever spelled at `new Cls(`/`parent::__construct()`
1394        // sites, which always name the class — the bare method-name needle is
1395        // dropped so a cold constructor query doesn't admit nearly every file
1396        // in the workspace (every class defines *some* `__construct`).
1397        let n = reference_gate_needles(&crate::Name::method("App\\Job", "__construct"));
1398        assert_eq!(n, vec!["Job".to_string()]);
1399        // Unknown owner: nothing to scope to, fall back to the bare name.
1400        let n = reference_gate_needles(&crate::Name::method("", "__construct"));
1401        assert_eq!(n, vec!["__construct".to_string()]);
1402    }
1403}