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