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 text-prefiltered
273    /// candidate scope).
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).
281    ///
282    /// Results are filtered to `files` (the host controls scope — e.g.
283    /// workspace files only, excluding stubs/vendor). With
284    /// `include_declaration`, the symbol's declaration name span is appended
285    /// when it lies inside the scope.
286    ///
287    /// `should_cancel` follows [`Self::references_to_in_files_cancellable`]'s
288    /// contract: polled at phase boundaries and between cancellation retries;
289    /// `true` aborts with `None`.
290    pub fn indexed_references_to(
291        &self,
292        symbol: &crate::Name,
293        files: &[Arc<str>],
294        include_declaration: bool,
295        should_cancel: &(dyn Fn() -> bool + Sync),
296    ) -> Option<Vec<(Arc<str>, crate::Range)>> {
297        use std::panic::AssertUnwindSafe;
298
299        use rayon::prelude::*;
300
301        let key = symbol.codebase_key();
302
303        // Freshness pass: candidates whose postings are not exact for their
304        // current text. Files not registered as `SourceFile` inputs are
305        // skipped (the caller's text pre-filter already scoped the set).
306        let stale: Vec<Arc<str>> = loop {
307            if should_cancel() {
308                return None;
309            }
310            let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
311                let current_gen = self.index_generation();
312                let db = self.snapshot_db();
313                files
314                    .iter()
315                    .filter(|f| {
316                        db.lookup_source_file(f.as_ref()).is_some_and(|sf| {
317                            let text = sf.text(&db as &dyn MirDatabase);
318                            !self.is_ref_committed(f.as_ref(), text, current_gen)
319                        })
320                    })
321                    .cloned()
322                    .collect::<Vec<_>>()
323            }));
324            match attempt {
325                Ok(v) => break v,
326                Err(_) if should_cancel() => return None,
327                Err(_) => {}
328            }
329        };
330
331        if !stale.is_empty() {
332            // Phase 1 (serial, no live snapshot held): warm up stale
333            // candidates. See `references_to_in_files_cancellable` for why
334            // this must be serial and snapshot-free.
335            for path in &stale {
336                if should_cancel() {
337                    return None;
338                }
339                self.prepare_file_for_analysis(path);
340            }
341
342            // Phase 2 (parallel, pure) under a cancellation retry loop, then
343            // a serial commit into both inverted indexes.
344            let (commit_gen, analyzed) = loop {
345                if should_cancel() {
346                    return None;
347                }
348                // Generation before the snapshot: a file add racing the
349                // analysis leaves these commits stale (self-healing on the
350                // next query), never wrongly fresh.
351                let gen = self.index_generation();
352                let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
353                    let db_main = self.snapshot_db();
354                    stale
355                        .par_iter()
356                        .map_with(db_main, |db, path| {
357                            let sf = db.lookup_source_file(path.as_ref())?;
358                            let text = sf.text(&*db as &dyn MirDatabase).clone();
359                            let out = crate::db::analyze_file(&*db as &dyn MirDatabase, sf).clone();
360                            let defs =
361                                crate::db::collect_file_definitions(&*db as &dyn MirDatabase, sf);
362                            let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
363                            // Stage the disk-cache write only when the commit
364                            // below will rewrite postings (see the sweep in
365                            // `reanalyze_file_set` for the cost rationale).
366                            let put = if self.ref_commit_is_current(path.as_ref(), &text, &out) {
367                                None
368                            } else {
369                                self.stage_ref_cache_put(
370                                    &*db as &dyn MirDatabase,
371                                    sf,
372                                    path.as_ref(),
373                                    &text,
374                                    &out,
375                                )
376                            };
377                            Some((path.clone(), text, out, entries, put))
378                        })
379                        .flatten()
380                        .collect::<Vec<_>>()
381                }));
382                match attempt {
383                    Ok(v) => break (gen, v),
384                    Err(_) if should_cancel() => return None,
385                    Err(_) => {}
386                }
387            };
388            let mut analyzed = analyzed;
389            let guard = self.db.salsa.read();
390            for (file, text, out, entries, put) in analyzed.iter_mut() {
391                // Pointer-identical memo ⇒ identical postings: skip the
392                // index rewrite and only re-stamp the freshness mark.
393                if !self.ref_commit_is_current(file.as_ref(), text, out) {
394                    guard.set_file_reference_locations(file.as_ref(), out.ref_locs.to_vec());
395                }
396                if let Some(put) = put.take() {
397                    self.apply_ref_cache_put(file.as_ref(), out, put);
398                }
399                self.mark_ref_committed(
400                    file,
401                    text,
402                    Some(out),
403                    commit_gen,
404                    !out.has_unresolved_names(),
405                );
406                if !self.is_defs_committed(file.as_ref(), text) {
407                    guard.set_file_class_edges(file, entries.clone());
408                    self.mark_defs_committed(file, text);
409                }
410            }
411        }
412
413        // Posting lookup, filtered to the candidate scope.
414        //
415        // Member symbols resolve against the queried class plus its hierarchy
416        // (mir records member refs under the *declaring* class, so a query on
417        // an interface method must include implementor keys and vice versa).
418        // Name-only fallback postings — receivers whose type couldn't be
419        // resolved — are consulted only when the typed keys produce nothing,
420        // mirroring the pre-index two-tier behavior: exact results when
421        // resolution succeeds, by-name matches when nothing resolves.
422        // `__construct` stays exact: `new Sub()` invokes `Sub::__construct`
423        // even when only a parent declares one, so hierarchy fan-out would
424        // wrongly return subtype instantiation sites for a parent query.
425        let hierarchy: Vec<String> = match symbol {
426            crate::Name::Method { class, name } => {
427                if name.as_ref() == "__construct" || class.is_empty() {
428                    if class.is_empty() {
429                        Vec::new()
430                    } else {
431                        vec![class.trim_start_matches('\\').to_string()]
432                    }
433                } else {
434                    self.member_hierarchy_classes(class.as_ref())
435                }
436            }
437            crate::Name::Property { class, .. } | crate::Name::ClassConstant { class, .. } => {
438                if class.is_empty() {
439                    Vec::new()
440                } else {
441                    self.member_hierarchy_classes(class.as_ref())
442                }
443            }
444            _ => Vec::new(),
445        };
446        let primary_keys: Vec<String> = match symbol {
447            crate::Name::Method { name, .. } => hierarchy
448                .iter()
449                .map(|c| format!("meth:{c}::{name}"))
450                .collect(),
451            crate::Name::Property { name, .. } => hierarchy
452                .iter()
453                .map(|c| format!("prop:{c}::{name}"))
454                .collect(),
455            crate::Name::ClassConstant { name, .. } => hierarchy
456                .iter()
457                .map(|c| format!("cnst:{c}::{name}"))
458                .collect(),
459            _ => vec![key.clone()],
460        };
461        let fallback_key: Option<String> = match symbol {
462            crate::Name::Method { name, .. } => Some(format!("methname:{name}")),
463            crate::Name::Property { name, .. } => Some(format!("propname:{name}")),
464            _ => None,
465        };
466        let scope: rustc_hash::FxHashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
467        let read_keys = |keys: &[String]| -> Vec<(Arc<str>, crate::Range)> {
468            let guard = self.db.salsa.read();
469            let mut merged: Vec<(Arc<str>, u32, u16, u16)> = Vec::new();
470            for k in keys {
471                merged.extend(guard.reference_locations(k));
472            }
473            merged
474                .into_iter()
475                .filter(|(file, ..)| scope.contains(file.as_ref()))
476                .map(|(file, line, col_start, col_end)| {
477                    (file, span_range(line, col_start as u32, col_end as u32))
478                })
479                .collect()
480        };
481        let mut out = read_keys(&primary_keys);
482        if out.is_empty() {
483            if let Some(fk) = fallback_key {
484                out = read_keys(std::slice::from_ref(&fk));
485            }
486        }
487        out.sort_by(|a, b| {
488            a.0.cmp(&b.0)
489                .then(a.1.start.line.cmp(&b.1.start.line))
490                .then(a.1.start.column.cmp(&b.1.start.column))
491        });
492        out.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
493
494        if include_declaration {
495            // Declaration lookup runs salsa queries (and may lazy-load); a
496            // concurrent write cancels it — declarations are then simply
497            // omitted rather than failing the whole request.
498            let decls: Vec<(Arc<str>, crate::Range)> = match symbol {
499                crate::Name::Method { class, .. }
500                | crate::Name::Property { class, .. }
501                | crate::Name::ClassConstant { class, .. } => {
502                    if class.is_empty() {
503                        // Unknown owner: declarations by name, recorded as
504                        // `methdecl:`/`propdecl:`/`cnstdecl:` postings during
505                        // class/trait/interface/enum analysis.
506                        match symbol {
507                            crate::Name::Method { name, .. } => {
508                                read_keys(&[format!("methdecl:{name}")])
509                            }
510                            crate::Name::Property { name, .. } => {
511                                read_keys(&[format!("propdecl:{name}")])
512                            }
513                            crate::Name::ClassConstant { name, .. } => {
514                                read_keys(&[format!("cnstdecl:{name}")])
515                            }
516                            _ => Vec::new(),
517                        }
518                    } else {
519                        salsa::Cancelled::catch(AssertUnwindSafe(|| {
520                            self.member_decl_sites(&hierarchy, symbol)
521                        }))
522                        .unwrap_or_default()
523                    }
524                }
525                _ => salsa::Cancelled::catch(AssertUnwindSafe(|| {
526                    self.declaration_name_range(symbol).into_iter().collect()
527                }))
528                .unwrap_or_default(),
529            };
530            for (file, range) in decls {
531                if scope.contains(file.as_ref())
532                    && !out.iter().any(|(f, r)| *f == file && *r == range)
533                {
534                    out.push((file, range));
535                }
536            }
537        }
538        Some(out)
539    }
540
541    /// The queried class plus every class its members' references could be
542    /// keyed under: resolved ancestors (a call on a subtype instance records
543    /// the declaring ancestor) and transitive subtypes including trait users
544    /// (a call on a subtype that overrides records the subtype). Display-form
545    /// FQCNs, deduplicated case-insensitively.
546    fn member_hierarchy_classes(&self, class_fqn: &str) -> Vec<String> {
547        use std::panic::AssertUnwindSafe;
548        let target = class_fqn.trim_start_matches('\\').to_string();
549        let mut out: Vec<String> = vec![target.clone()];
550        let ancestors = salsa::Cancelled::catch(AssertUnwindSafe(|| {
551            let db = self.snapshot_db();
552            let here = crate::db::Fqcn::from_str(&db, &target);
553            crate::db::class_ancestors_by_fqcn(&db, here)
554                .iter()
555                .skip(1)
556                .map(|a| a.trim_start_matches('\\').to_string())
557                .collect::<Vec<_>>()
558        }))
559        .unwrap_or_default();
560        out.extend(ancestors);
561        let subs = {
562            let guard = self.db.salsa.read();
563            guard.subtype_sites_of(&target, true)
564        };
565        out.extend(
566            subs.into_iter()
567                .map(|s| s.fqcn.trim_start_matches('\\').to_string()),
568        );
569        let mut seen: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
570        out.retain(|c| seen.insert(c.to_ascii_lowercase()));
571        out
572    }
573
574    /// Own-member declaration sites for `symbol` across `classes`: each class
575    /// that itself declares the member (not inherited) contributes its name
576    /// token. Kind-specific lookups — a class often declares a property and a
577    /// method with the same short name, and `member_location` can't tell them
578    /// apart.
579    fn member_decl_sites(
580        &self,
581        classes: &[String],
582        symbol: &crate::Name,
583    ) -> Vec<(Arc<str>, crate::Range)> {
584        let mut out: Vec<(Arc<str>, crate::Range)> = Vec::new();
585        let db = self.snapshot_db();
586        for class in classes {
587            let here = crate::db::Fqcn::from_str(&db, class);
588            let (loc, needle) = match symbol {
589                crate::Name::Method { name, .. } => {
590                    let Some(m) = crate::db::find_method_in_class(&db, here, name) else {
591                        continue;
592                    };
593                    (m.location.clone(), name.to_string())
594                }
595                crate::Name::Property { name, .. } => {
596                    let Some(p) = crate::db::find_property_in_class(&db, here, name) else {
597                        continue;
598                    };
599                    (p.location.clone(), name.to_string())
600                }
601                crate::Name::ClassConstant { name, .. } => {
602                    let Some(c) = crate::db::find_class_constant_in_class(&db, here, name) else {
603                        continue;
604                    };
605                    (c.location.clone(), name.to_string())
606                }
607                _ => continue,
608            };
609            let Some(loc) = loc else { continue };
610            let range = self.refine_location_to_name(&loc, &needle);
611            out.push((loc.file.clone(), range));
612        }
613        out
614    }
615
616    /// The symbol's declaration site, narrowed from the collector's
617    /// whole-declaration span to the declared name's own token (matching the
618    /// span shape of recorded references).
619    pub fn declaration_name_range(&self, symbol: &crate::Name) -> Option<(Arc<str>, crate::Range)> {
620        if let crate::Name::GlobalConstant(fqn) = symbol {
621            return self.global_constant_decl_range(fqn);
622        }
623        let loc = self.definition_of(symbol).ok()?;
624        let short = match symbol {
625            crate::Name::Class(f) | crate::Name::Function(f) | crate::Name::GlobalConstant(f) => {
626                crate::db::subtype_index::short_name_of(f)
627            }
628            crate::Name::Method { name, .. }
629            | crate::Name::Property { name, .. }
630            | crate::Name::ClassConstant { name, .. } => name.as_ref(),
631        };
632        // Property declarations carry a `$` sigil in source, but reference
633        // ranges cover the bare name; the word-boundary search below lands on
634        // the name right after the sigil.
635        let file = loc.file.clone();
636        let range = self.refine_location_to_name(&loc, short);
637        Some((file, range))
638    }
639
640    /// Narrow a whole-declaration [`mir_types::Location`] to the first
641    /// word-boundary occurrence of `needle` inside its line span. Falls back
642    /// to the location's own coordinates when the text is unavailable or the
643    /// name doesn't appear (e.g. stub-only declarations).
644    fn refine_location_to_name(&self, loc: &mir_types::Location, needle: &str) -> crate::Range {
645        let fallback = span_range(loc.line, loc.col_start as u32, loc.col_end as u32);
646        let text = {
647            let db = self.snapshot_db();
648            db.lookup_source_file(loc.file.as_ref())
649                .map(|sf| sf.text(&db as &dyn MirDatabase).clone())
650        };
651        let Some(text) = text else {
652            return fallback;
653        };
654        let needle_chars = needle.chars().count() as u32;
655        let first_line = loc.line.saturating_sub(1) as usize;
656        // Exact-case first: PHP property/constant names are case-sensitive
657        // and an early case-insensitive hit can land on an unrelated token
658        // (a type hint sharing the name). Case-insensitive second, for
659        // method/class needles that arrive lowercase-normalized.
660        for case_insensitive in [false, true] {
661            for (idx, line_text) in text.lines().enumerate().skip(first_line) {
662                let line_no = idx as u32 + 1;
663                if line_no > loc.line_end {
664                    break;
665                }
666                let min_col = if line_no == loc.line {
667                    loc.col_start as usize
668                } else {
669                    0
670                };
671                if let Some(col) = identifier_char_col(line_text, needle, min_col, case_insensitive)
672                {
673                    return span_range(line_no, col, col + needle_chars);
674                }
675            }
676        }
677        fallback
678    }
679
680    /// Transitive subtypes of `class_fqn` (classes/interfaces/enums whose
681    /// resolved ancestor chain reaches it), answered from the maintained
682    /// subtype edge index.
683    ///
684    /// `files` is the host's candidate scope for the on-demand completeness
685    /// pass: per BFS round, not-yet-committed files whose text mentions a
686    /// frontier name get their definitions committed, so results are complete
687    /// even before a background sweep has covered the workspace. Committed
688    /// files answer from the index with no parsing at all.
689    ///
690    /// `include_trait_users` also counts `use Trait;` composition as a
691    /// subtype edge (visibility-scoping semantics); leave it off for
692    /// goto-implementation semantics (extends/implements only).
693    pub fn indexed_subtype_classes(
694        &self,
695        class_fqn: &str,
696        files: &[Arc<str>],
697        include_trait_users: bool,
698    ) -> Vec<SubtypeClassSite> {
699        let mut scanned: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
700        let mut pending: Vec<String> = vec![class_fqn.trim_start_matches('\\').to_string()];
701        let mut sites: Vec<crate::db::SubtypeSite> = Vec::new();
702        while !pending.is_empty() {
703            let needles: Vec<String> = pending
704                .drain(..)
705                .filter(|f| scanned.insert(f.clone()))
706                .map(|f| crate::db::subtype_index::short_name_of(&f).to_string())
707                .collect();
708            if !needles.is_empty() {
709                self.commit_defs_for_matching(files, &needles);
710            }
711            sites = {
712                let guard = self.db.salsa.read();
713                guard.subtype_sites_of_lenient(class_fqn, include_trait_users)
714            };
715            pending = sites
716                .iter()
717                .map(|s| s.fqcn.trim_start_matches('\\').to_string())
718                .filter(|f| !scanned.contains(f))
719                .collect();
720        }
721        let mut out: Vec<SubtypeClassSite> = sites
722            .into_iter()
723            .filter_map(|s| {
724                let loc = s.location.as_ref()?;
725                let short = crate::db::subtype_index::short_name_of(&s.fqcn).to_string();
726                let range = self.refine_location_to_name(loc, &short);
727                Some(SubtypeClassSite {
728                    fqcn: s.fqcn,
729                    kind: s.kind,
730                    is_abstract: s.is_abstract,
731                    file: s.file,
732                    range,
733                })
734            })
735            .collect();
736        // Anonymous classes never reach the definition collector; their
737        // `new class implements X {}` sites are recorded as `impl:` postings
738        // during body analysis (exact FQCN key plus a short-name key for the
739        // same written-form leniency named classes get above).
740        let root_lc = class_fqn.trim_start_matches('\\').to_ascii_lowercase();
741        let short_lc = crate::db::subtype_index::short_name_of(&root_lc).to_string();
742        let scope: rustc_hash::FxHashSet<&str> = files.iter().map(|f| f.as_ref()).collect();
743        let anon: Vec<(Arc<str>, u32, u16, u16)> = {
744            let guard = self.db.salsa.read();
745            let mut v = guard.reference_locations(&format!("impl:{root_lc}"));
746            v.extend(guard.reference_locations(&format!("implshort:{short_lc}")));
747            v.sort();
748            v.dedup();
749            v
750        };
751        for (file, line, cs, ce) in anon {
752            if !scope.contains(file.as_ref()) {
753                continue;
754            }
755            let range = span_range(line, cs as u32, ce as u32);
756            if out.iter().any(|s| s.file == file && s.range == range) {
757                continue;
758            }
759            out.push(SubtypeClassSite {
760                fqcn: Arc::from("class@anonymous"),
761                kind: crate::db::ClassLikeKind::Class,
762                is_abstract: false,
763                file,
764                range,
765            });
766        }
767        out
768    }
769
770    /// Concrete implementations of `class_fqn::method` across its transitive
771    /// subtypes: the same-named non-abstract method declared by each subtype,
772    /// as `(subtype fqcn, file, name range)`.
773    pub fn indexed_method_implementations(
774        &self,
775        class_fqn: &str,
776        method: &str,
777        files: &[Arc<str>],
778    ) -> Vec<(Arc<str>, Arc<str>, crate::Range)> {
779        use std::panic::AssertUnwindSafe;
780        let subs = self.indexed_subtype_classes(class_fqn, files, false);
781        if subs.is_empty() {
782            return Vec::new();
783        }
784        loop {
785            let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
786                let db = self.snapshot_db();
787                let mut out: Vec<(Arc<str>, Arc<str>, crate::Range)> = Vec::new();
788                for sub in &subs {
789                    let here = crate::db::Fqcn::from_str(&db, sub.fqcn.as_ref());
790                    let Some(m) = crate::db::find_method_in_class(&db, here, method) else {
791                        continue;
792                    };
793                    if m.is_abstract {
794                        continue;
795                    }
796                    let Some(loc) = m.location.as_ref() else {
797                        continue;
798                    };
799                    let range = self.refine_location_to_name(loc, method);
800                    out.push((sub.fqcn.clone(), loc.file.clone(), range));
801                }
802                out
803            }));
804            if let Ok(mut out) = attempt {
805                out.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.start.line.cmp(&b.2.start.line)));
806                out.dedup_by(|a, b| a.1 == b.1 && a.2 == b.2);
807                return out;
808            }
809        }
810    }
811
812    /// Commit definitions (class edges + freshness) for every file in `files`
813    /// that is stale (committed against older text) or that has never been
814    /// committed and mentions one of `shorts` as a whole identifier.
815    fn commit_defs_for_matching(&self, files: &[Arc<str>], shorts: &[String]) {
816        use std::panic::AssertUnwindSafe;
817
818        use rayon::prelude::*;
819
820        let committed_any: rustc_hash::FxHashSet<Arc<str>> = {
821            let guard = self.defs_committed_keys();
822            guard.into_iter().collect()
823        };
824        let work = loop {
825            let attempt = salsa::Cancelled::catch(AssertUnwindSafe(|| {
826                let db_main = self.snapshot_db();
827                files
828                    .par_iter()
829                    .map_with(db_main, |db, path| {
830                        let sf = db.lookup_source_file(path.as_ref())?;
831                        let text = sf.text(&*db as &dyn MirDatabase).clone();
832                        if self.is_defs_committed(path.as_ref(), &text) {
833                            return None;
834                        }
835                        // Never-committed files must mention a frontier name;
836                        // stale (previously committed) files recommit
837                        // unconditionally — their classes may have re-parented.
838                        if !committed_any.contains(path.as_ref())
839                            && !shorts.iter().any(|s| mentions_identifier(&text, s))
840                        {
841                            return None;
842                        }
843                        let defs =
844                            crate::db::collect_file_definitions(&*db as &dyn MirDatabase, sf);
845                        let entries = crate::db::subtype_index::entries_from_slice(&defs.slice);
846                        Some((path.clone(), text, entries))
847                    })
848                    .flatten()
849                    .collect::<Vec<_>>()
850            }));
851            if let Ok(v) = attempt {
852                break v;
853            }
854        };
855        if work.is_empty() {
856            return;
857        }
858        let guard = self.db.salsa.read();
859        for (file, text, entries) in &work {
860            guard.set_file_class_edges(file, entries.clone());
861            self.mark_defs_committed(file, text);
862        }
863    }
864
865    /// Declaration name span for a global constant. Constant slices carry no
866    /// stored location, so this finds the declaring file via the workspace
867    /// constants index and locates the `const NAME` / `define('NAME'` token
868    /// textually.
869    fn global_constant_decl_range(&self, fqn: &str) -> Option<(Arc<str>, crate::Range)> {
870        use std::panic::AssertUnwindSafe;
871        let short = crate::db::subtype_index::short_name_of(fqn).to_string();
872        salsa::Cancelled::catch(AssertUnwindSafe(|| {
873            let db = self.snapshot_db();
874            let index = crate::db::workspace_index(&db);
875            let loc = index
876                .constants
877                .get(&mir_types::Name::from(fqn.trim_start_matches('\\')))?;
878            let file = loc.file().path(&db).clone();
879            let sf = db.lookup_source_file(file.as_ref())?;
880            let text = sf.text(&db as &dyn MirDatabase);
881            for (idx, line) in text.lines().enumerate() {
882                let trimmed = line.trim_start();
883                let is_decl_line = trimmed.starts_with("const ")
884                    || trimmed.contains("define(")
885                    || trimmed.contains("define (");
886                if !is_decl_line {
887                    continue;
888                }
889                if let Some(col) = identifier_char_col(line, &short, 0, false) {
890                    let n = short.chars().count() as u32;
891                    return Some((file, span_range(idx as u32 + 1, col, col + n)));
892                }
893            }
894            None
895        }))
896        .ok()
897        .flatten()
898    }
899
900    /// Class-level issues (inheritance violations, abstract-method gaps, override
901    /// incompatibilities) for the given set of files.
902    ///
903    /// These checks are cross-file by nature and are not emitted by
904    /// [`crate::FileAnalyzer::analyze`]. Call this after ingesting or
905    /// re-analyzing a file and its dependents to get the full diagnostic picture.
906    ///
907    /// Circular-inheritance checks always run against the full workspace graph
908    /// regardless of the `files` filter — a cycle is a workspace-wide problem.
909    pub fn class_issues(&self, files: &[Arc<str>]) -> Vec<crate::Issue> {
910        let db = self.snapshot_db();
911        let file_set: HashSet<Arc<str>> = files.iter().cloned().collect();
912        // Read source texts through the snapshot already in hand — calling
913        // `source_of` here would re-enter the session RwLock while this
914        // snapshot is live, and a concurrent salsa write (which blocks new
915        // readers behind the fair write lock while waiting for existing
916        // snapshots to drop) turns that into a deadlock.
917        let file_data: Vec<(Arc<str>, Arc<str>)> = files
918            .iter()
919            .filter_map(|f| {
920                let sf = db.lookup_source_file(f)?;
921                Some((
922                    f.clone(),
923                    sf.text(&db as &dyn crate::db::MirDatabase).clone(),
924                ))
925            })
926            .collect();
927        crate::class::ClassAnalyzer::with_files(&db, file_set, &file_data).analyze_all()
928    }
929
930    /// All declarations defined in `file` as a **hierarchical tree**.
931    ///
932    /// Classes/interfaces/traits/enums are returned with their methods,
933    /// properties, and constants nested in `children`. Top-level functions
934    /// and constants are returned with empty `children`.
935    pub fn document_symbols(&self, file: &str) -> Vec<crate::symbol::DocumentSymbol> {
936        use crate::symbol::{DeclarationKind, DocumentSymbol};
937
938        let db = self.snapshot_db();
939        let Some(sf) = db.lookup_source_file(file) else {
940            return Vec::new();
941        };
942        let defs = crate::db::collect_file_definitions(&db, sf);
943        let mut out: Vec<DocumentSymbol> = Vec::new();
944
945        let class_children = |methods: &mir_codebase::definitions::MemberMap<
946            Arc<mir_codebase::definitions::MethodDef>,
947        >,
948                              props: Option<
949            &mir_codebase::definitions::MemberMap<mir_codebase::definitions::PropertyDef>,
950        >,
951                              consts: &mir_codebase::definitions::MemberMap<
952            mir_codebase::definitions::ConstantDef,
953        >,
954                              is_enum: bool|
955         -> Vec<DocumentSymbol> {
956            let mut out: Vec<DocumentSymbol> = Vec::new();
957            for (_, m) in methods.iter() {
958                out.push(DocumentSymbol {
959                    name: m.name.clone(),
960                    kind: DeclarationKind::Method,
961                    location: m.location.clone(),
962                    children: Vec::new(),
963                });
964            }
965            if let Some(props) = props {
966                for (_, p) in props.iter() {
967                    out.push(DocumentSymbol {
968                        name: p.name.clone(),
969                        kind: DeclarationKind::Property,
970                        location: p.location.clone(),
971                        children: Vec::new(),
972                    });
973                }
974            }
975            let const_kind = if is_enum {
976                DeclarationKind::EnumCase
977            } else {
978                DeclarationKind::Constant
979            };
980            for (_, c) in consts.iter() {
981                out.push(DocumentSymbol {
982                    name: c.name.clone(),
983                    kind: const_kind,
984                    location: c.location.clone(),
985                    children: Vec::new(),
986                });
987            }
988            out
989        };
990
991        for c in defs.slice.classes.iter() {
992            out.push(DocumentSymbol {
993                name: c.fqcn.clone(),
994                kind: DeclarationKind::Class,
995                location: c.location.clone(),
996                children: class_children(
997                    &c.own_methods,
998                    Some(&c.own_properties),
999                    &c.own_constants,
1000                    false,
1001                ),
1002            });
1003        }
1004        for i in defs.slice.interfaces.iter() {
1005            out.push(DocumentSymbol {
1006                name: i.fqcn.clone(),
1007                kind: DeclarationKind::Interface,
1008                location: i.location.clone(),
1009                children: class_children(&i.own_methods, None, &i.own_constants, false),
1010            });
1011        }
1012        for t in defs.slice.traits.iter() {
1013            out.push(DocumentSymbol {
1014                name: t.fqcn.clone(),
1015                kind: DeclarationKind::Trait,
1016                location: t.location.clone(),
1017                children: class_children(
1018                    &t.own_methods,
1019                    Some(&t.own_properties),
1020                    &t.own_constants,
1021                    false,
1022                ),
1023            });
1024        }
1025        for e in defs.slice.enums.iter() {
1026            let mut children = class_children(&e.own_methods, None, &e.own_constants, true);
1027            for (_, case) in e.cases.iter() {
1028                children.push(DocumentSymbol {
1029                    name: case.name.clone(),
1030                    kind: DeclarationKind::EnumCase,
1031                    location: case.location.clone(),
1032                    children: Vec::new(),
1033                });
1034            }
1035            out.push(DocumentSymbol {
1036                name: e.fqcn.clone(),
1037                kind: DeclarationKind::Enum,
1038                location: e.location.clone(),
1039                children,
1040            });
1041        }
1042        for f in defs.slice.functions.iter() {
1043            out.push(DocumentSymbol {
1044                name: f.fqn.clone(),
1045                kind: DeclarationKind::Function,
1046                location: f.location.clone(),
1047                children: Vec::new(),
1048            });
1049        }
1050        for (name, _) in defs.slice.constants.iter() {
1051            out.push(DocumentSymbol {
1052                name: name.clone(),
1053                kind: DeclarationKind::Constant,
1054                location: None,
1055                children: Vec::new(),
1056            });
1057        }
1058        out
1059    }
1060}
1061
1062/// A transitive subtype hit with its declaration name span, as returned by
1063/// [`AnalysisSession::indexed_subtype_classes`].
1064#[derive(Debug, Clone)]
1065pub struct SubtypeClassSite {
1066    /// Display-form FQCN (no leading `\`).
1067    pub fqcn: Arc<str>,
1068    pub kind: crate::db::ClassLikeKind,
1069    pub is_abstract: bool,
1070    pub file: Arc<str>,
1071    /// The declared name's own token (1-based line, 0-based char columns).
1072    pub range: crate::Range,
1073}
1074
1075/// Build a [`crate::Range`] on one line from mir's native coordinates
1076/// (1-based line, 0-based columns).
1077fn span_range(line: u32, col_start: u32, col_end: u32) -> crate::Range {
1078    crate::Range {
1079        start: crate::Position {
1080            line,
1081            column: col_start,
1082        },
1083        end: crate::Position {
1084            line,
1085            column: col_end,
1086        },
1087    }
1088}
1089
1090/// Char column of the first word-boundary occurrence of `needle` in `line`
1091/// at or after char column `min_col`. Columns are code points, matching the
1092/// collector's `Location` convention.
1093fn identifier_char_col(
1094    line: &str,
1095    needle: &str,
1096    min_col: usize,
1097    case_insensitive: bool,
1098) -> Option<u32> {
1099    if needle.is_empty() {
1100        return None;
1101    }
1102    let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
1103    let chars: Vec<char> = line.chars().collect();
1104    let needle_chars: Vec<char> = needle.chars().collect();
1105    let n = needle_chars.len();
1106    if chars.len() < n {
1107        return None;
1108    }
1109    for start in min_col..=chars.len().saturating_sub(n) {
1110        let matches = chars[start..start + n]
1111            .iter()
1112            .zip(needle_chars.iter())
1113            .all(|(a, b)| {
1114                if case_insensitive {
1115                    a.eq_ignore_ascii_case(b)
1116                } else {
1117                    a == b
1118                }
1119            });
1120        if !matches {
1121            continue;
1122        }
1123        let before_ok = start == 0 || !is_ident(chars[start - 1]);
1124        let after = start + n;
1125        let after_ok = after >= chars.len() || !is_ident(chars[after]);
1126        if before_ok && after_ok {
1127            return Some(start as u32);
1128        }
1129    }
1130    None
1131}
1132
1133/// Whether `hay` mentions `needle` as a whole identifier (ASCII word
1134/// boundaries; conservative near multibyte text). Mirrors the host-side
1135/// candidate prefilter so the completeness pass never analyzes files that
1136/// cannot name the symbol.
1137fn mentions_identifier(hay: &str, needle: &str) -> bool {
1138    if needle.is_empty() {
1139        return false;
1140    }
1141    let hay_b = hay.as_bytes();
1142    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1143    let mut from = 0;
1144    while let Some(rel) = hay[from..].find(needle) {
1145        let idx = from + rel;
1146        let before_ok = idx == 0 || !is_ident(hay_b[idx - 1]);
1147        let end = idx + needle.len();
1148        let after_ok = end >= hay_b.len() || !is_ident(hay_b[end]);
1149        if before_ok && after_ok {
1150            return true;
1151        }
1152        // Step to the next char boundary, not just the next byte: PHP allows
1153        // non-ASCII bytes in identifiers, so `idx + 1` can land mid-codepoint
1154        // and panic the next `hay[from..]` slice.
1155        from = idx + 1;
1156        while from < hay_b.len() && (hay_b[from] & 0xC0) == 0x80 {
1157            from += 1;
1158        }
1159    }
1160    false
1161}