Skip to main content

php_lsp/navigation/
references.rs

1use std::collections::{HashMap, HashSet};
2use std::ops::ControlFlow;
3use std::sync::Arc;
4
5use php_ast::visitor::{Visitor, walk_stmt};
6use php_ast::{
7    ClassMemberKind, EnumMemberKind, ExprKind, NamespaceBody, Span, Stmt, StmtKind, UseKind,
8};
9use rayon::prelude::*;
10use tower_lsp::lsp_types::{Location, Position, Range, Url};
11
12use super::walk::{
13    all_class_ref_names_in_stmts, class_refs_in_stmts, constant_refs_in_stmts,
14    function_refs_in_stmts, global_constant_refs_in_stmts, method_refs_in_stmts, new_refs_in_stmts,
15    property_refs_in_stmts, refs_in_stmts, refs_in_stmts_with_use,
16};
17use crate::document::ast::{ParsedDoc, str_offset_in_range};
18use crate::document::document_store::DocumentStore;
19use crate::text::{fqn_short_name, utf16_code_units};
20
21/// What kind of symbol the cursor is on.  Used to dispatch to the
22/// appropriate semantic walker so that, e.g., searching for `get` as a
23/// *method* doesn't return free-function calls named `get`.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum SymbolKind {
26    /// A free (top-level) function.
27    Function,
28    /// An instance or static method (`->name`, `?->name`, `::name`).
29    Method,
30    /// A class, interface, trait, or enum name used as a type.
31    Class,
32    /// A class / trait property (`->name`, `?->name`, promoted or declared).
33    Property,
34    /// A class, interface, enum, or trait constant (`Class::CONST`, `self::CONST`).
35    Constant,
36}
37
38/// Find all locations where `word` is referenced across the given documents.
39/// If `include_declaration` is true, also includes the declaration site.
40/// Pass `kind` to restrict results to a particular symbol category; `None`
41/// falls back to the original word-based walker (better some results than none).
42pub fn find_references(
43    word: &str,
44    all_docs: &[(Url, Arc<ParsedDoc>)],
45    include_declaration: bool,
46    kind: Option<SymbolKind>,
47) -> Vec<Location> {
48    find_references_inner(word, all_docs, include_declaration, false, kind, None)
49}
50
51/// Like [`find_references`] but narrows scanning to docs whose namespace +
52/// `use` imports would resolve `word` to `target_fqn`. Used by
53/// `textDocument/references` for the AST fallback so it doesn't match
54/// same-short-name symbols in unrelated namespaces.
55pub fn find_references_with_target(
56    word: &str,
57    all_docs: &[(Url, Arc<ParsedDoc>)],
58    include_declaration: bool,
59    kind: Option<SymbolKind>,
60    target_fqn: &str,
61) -> Vec<Location> {
62    // Default: include `use` statement spans so callers that pass
63    // `kind=None` (notably the rename handler) get their use-import edits.
64    // For typed kinds we want the kind-specific walker (so a Method search
65    // doesn't pick up free functions sharing the name); the general walker
66    // would falsely widen those results.
67    let include_use = kind.is_none();
68    find_references_inner(
69        word,
70        all_docs,
71        include_declaration,
72        include_use,
73        kind,
74        Some(target_fqn),
75    )
76}
77
78/// Like `find_references` but also includes `use` statement spans.
79/// Used by rename so that `use Foo;` statements are also updated.
80/// Always uses the general walker (rename must update all occurrence kinds).
81pub fn find_references_with_use(
82    word: &str,
83    all_docs: &[(Url, Arc<ParsedDoc>)],
84    include_declaration: bool,
85) -> Vec<Location> {
86    find_references_inner(word, all_docs, include_declaration, true, None, None)
87}
88
89/// Find only `new ClassName(...)` instantiation sites across all docs.
90///
91/// Used by the `__construct` references handler — `SymbolKind::Class` (the normal
92/// class-kind path) is too broad because mir's `ClassReference` key covers type
93/// hints, `instanceof`, `extends`, and `implements` in addition to `new` calls.
94/// This function walks the AST using `new_refs_in_stmts` which only emits spans
95/// for `ExprKind::New` nodes, giving the caller exactly the call sites.
96///
97/// `class_fqn` is the fully-qualified name (e.g. `"Alpha\\Widget"`) used to
98/// filter files where the short name resolves to a different class. Pass `None`
99/// for global-namespace classes.
100pub fn find_constructor_references(
101    short_name: &str,
102    all_docs: &[(Url, Arc<ParsedDoc>)],
103    class_fqn: Option<&str>,
104) -> Vec<Location> {
105    all_docs
106        .par_iter()
107        .flat_map_iter(|(uri, doc)| {
108            // Cheap memchr gate before import AST walk.
109            if !doc.view().source().contains(short_name)
110                && !class_fqn
111                    .is_some_and(|f| doc.view().source().contains(f.trim_start_matches('\\')))
112            {
113                return Vec::new();
114            }
115            // Namespace filter: skip if the file's imports can't resolve the
116            // short name to the target FQN and the FQN doesn't appear literally.
117            if let Some(fqn) = class_fqn
118                && !doc_can_reference_target(doc, short_name, fqn)
119                && !doc.view().source().contains(fqn.trim_start_matches('\\'))
120            {
121                return Vec::new();
122            }
123            let mut spans = Vec::new();
124            new_refs_in_stmts(&doc.program().stmts, short_name, class_fqn, &mut spans);
125            let sv = doc.view();
126            spans
127                .into_iter()
128                .map(|span| {
129                    let start = sv.position_of(span.start);
130                    let end = sv.position_of(span.end);
131                    Location {
132                        uri: uri.clone(),
133                        range: Range { start, end },
134                    }
135                })
136                .collect::<Vec<_>>()
137        })
138        .collect()
139}
140
141/// Convert a session reference tuple `(file_uri, line, col_start, col_end)` —
142/// as produced by `DocumentStore::session_references_to` — into an LSP
143/// `Location`. Returns `None` when the file URI fails to parse.
144pub(crate) fn session_tuple_to_location(
145    (file, line, col_start, col_end): (Arc<str>, u32, u32, u32),
146) -> Option<Location> {
147    let uri = Url::parse(&file).ok()?;
148    Some(Location {
149        uri,
150        range: Range {
151            start: Position {
152                line,
153                character: col_start,
154            },
155            end: Position {
156                line,
157                character: col_end,
158            },
159        },
160    })
161}
162
163/// Dedup key for a reference location: `(uri, start line, start char, end char)`.
164/// Finer than `type_definition`'s `(uri, line)` key — two references on the same
165/// line (e.g. chained calls) are distinct results and must both survive.
166pub(crate) fn ref_location_key(loc: &Location) -> (String, u32, u32, u32) {
167    (
168        loc.uri.to_string(),
169        loc.range.start.line,
170        loc.range.start.character,
171        loc.range.end.character,
172    )
173}
174
175/// De-duplicate reference locations by [`ref_location_key`], preserving
176/// first-seen order.
177pub(crate) fn dedup_ref_locations(locations: &mut Vec<Location>) {
178    let mut seen = HashSet::new();
179    locations.retain(|loc| seen.insert(ref_location_key(loc)));
180}
181
182// NOTE: a mir-codebase fast path for references (find_references_codebase)
183// previously lived here, fully stubbed: every symbol kind fell through to the
184// AST walker, and nothing called it. Removed. A real fast path for Class kind
185// would need mir's ClassReference index to be exhaustive (mir v0.41.0 covers
186// type hints, `instanceof`, `extends`, `implements`, `new` calls, and static-
187// call class tokens), but the AST walker is authoritative and already augmented
188// with session refs for Class and Function — there is no coverage gap to fill.
189
190fn find_references_inner(
191    word: &str,
192    all_docs: &[(Url, Arc<ParsedDoc>)],
193    include_declaration: bool,
194    include_use: bool,
195    kind: Option<SymbolKind>,
196    target_fqn: Option<&str>,
197) -> Vec<Location> {
198    // Each document is scanned independently: substring pre-filter, AST walk,
199    // then span → position translation. Rayon parallelizes across docs; the
200    // per-doc work is CPU-bound and 100% independent, so this scales linearly
201    // with cores on large workspaces (Laravel: ~1,600 files).
202    // Per-file namespace pre-filter only applies to Function and Class kinds,
203    // where the target FQN refers to the symbol itself. For methods the
204    // target is the *owning* FQCN, which can't be compared against the
205    // method name via namespace resolution.
206    let namespace_filter_active =
207        matches!(kind, Some(SymbolKind::Function) | Some(SymbolKind::Class));
208    all_docs
209        .par_iter()
210        .flat_map_iter(|(uri, doc)| {
211            // Cheap memchr gate before any AST work. doc_can_reference_target
212            // walks use-statement nodes and must not run on files that can't
213            // possibly match.
214            if !doc.view().source().contains(word) {
215                return Vec::new();
216            }
217            if namespace_filter_active
218                && let Some(target) = target_fqn
219                && !doc_can_reference_target(doc, word, target)
220            {
221                return Vec::new();
222            }
223            scan_doc(
224                word,
225                uri,
226                doc,
227                include_declaration,
228                include_use,
229                kind,
230                target_fqn,
231            )
232        })
233        .collect()
234}
235
236/// Return true when this doc's namespace + `use` imports could plausibly
237/// refer to `target_fqn` under the short name `word`.  Used as a pre-filter
238/// so the AST walker doesn't emit refs in files whose namespace would resolve
239/// `word` to a different FQN.
240fn doc_can_reference_target(doc: &ParsedDoc, word: &str, target_fqn: &str) -> bool {
241    let target = target_fqn.trim_start_matches('\\');
242    let imports = collect_file_imports(doc);
243    let resolved = crate::navigation::moniker::resolve_fqn(doc, word, &imports);
244    // PHP falls back to the global namespace for unqualified *function* calls
245    // when the namespaced version doesn't exist.  We don't know at this point
246    // which symbol category the target is, so accept either an exact match
247    // or a global-namespace fallback match.
248    resolved == target
249        || (resolved == word && !target.contains('\\'))
250        || (resolved == word && target == format!("\\{word}"))
251}
252
253struct ImportsVisitor {
254    only_kind: Option<UseKind>,
255    out: HashMap<String, String>,
256}
257
258impl<'arena, 'src> Visitor<'arena, 'src> for ImportsVisitor {
259    fn visit_stmt(&mut self, stmt: &Stmt<'arena, 'src>) -> ControlFlow<()> {
260        match &stmt.kind {
261            StmtKind::Use(u) if self.only_kind.is_none_or(|k| u.kind == k) => {
262                for item in u.uses.iter() {
263                    let fqn = item.name.to_string_repr().into_owned();
264                    let short = item
265                        .alias
266                        .map(|a| a.to_string())
267                        .unwrap_or_else(|| fqn_short_name(&fqn).to_string());
268                    self.out.insert(short, fqn);
269                }
270                ControlFlow::Continue(())
271            }
272            // walk_stmt recurses into NamespaceBody::Braced automatically.
273            StmtKind::Namespace(_) => walk_stmt(self, stmt),
274            _ => ControlFlow::Continue(()),
275        }
276    }
277}
278
279/// Build a local-name → FQN map from a doc's `use` statements.  Mirrors
280/// `Backend::file_imports` but self-contained so the reference walker can
281/// run without a persistent codebase. Includes all use kinds (class, function,
282/// const) — callers that only want class imports should use `collect_class_imports`.
283pub(crate) fn collect_file_imports(doc: &ParsedDoc) -> HashMap<String, String> {
284    collect_imports_filtered(doc, None)
285}
286
287/// Like `collect_file_imports` but restricted to `use ClassName` statements
288/// (`UseKind::Normal`). Use this wherever the import map is fed into class
289/// resolution — mixing in `use function` / `use const` entries causes the
290/// resolver to map a function/const short name to the wrong FQN when the same
291/// short name appears as a type hint or class reference.
292///
293/// TODO: upstream fix — have mir's FileAnalyzer auto-load via its ClassResolver
294/// so lsp no longer needs to pre-collect class dependencies manually.
295pub(crate) fn collect_class_imports(doc: &ParsedDoc) -> HashMap<String, String> {
296    collect_imports_filtered(doc, Some(UseKind::Normal))
297}
298
299fn collect_imports_filtered(
300    doc: &ParsedDoc,
301    only_kind: Option<UseKind>,
302) -> HashMap<String, String> {
303    let mut v = ImportsVisitor {
304        only_kind,
305        out: HashMap::new(),
306    };
307    for stmt in doc.program().stmts.iter() {
308        let _ = v.visit_stmt(stmt);
309    }
310    v.out
311}
312
313/// Collect every class-typed reference in `doc` (extends, implements, new,
314/// instanceof, type hints, static calls, catch types), resolved to an FQN via
315/// the current namespace and `use` imports. Used to lazy-load same-namespace
316/// dependencies that have no explicit `use` statement (and so are missed by
317/// `collect_file_imports`) before semantic analysis runs.
318///
319/// Returns de-duplicated FQNs with any leading `\` stripped.
320pub(crate) fn collect_referenced_class_fqns(doc: &ParsedDoc) -> Vec<String> {
321    let imports = collect_class_imports(doc);
322    let names = all_class_ref_names_in_stmts(&doc.program().stmts);
323    let locals = collect_local_type_decl_fqns(doc);
324    let mut out: Vec<String> = names
325        .into_iter()
326        .map(|name| {
327            // A leading `\` marks an already-fully-qualified reference like
328            // `new \App\Model\Entity()` — strip the slash and use as-is.
329            // `resolve_fqn` would otherwise prepend the current namespace.
330            if let Some(stripped) = name.strip_prefix('\\') {
331                return stripped.to_string();
332            }
333            let fqn = crate::navigation::moniker::resolve_fqn(doc, &name, &imports);
334            fqn.trim_start_matches('\\').to_string()
335        })
336        // Skip references that resolve to a type declared in this very file —
337        // mir already has them via `session.ingest_file`, and asking it to
338        // lazy-load them can recurse back through analysis.
339        .filter(|fqn| !locals.contains(fqn))
340        .collect();
341    out.sort_unstable();
342    out.dedup();
343    out
344}
345
346/// FQNs of every top-level type declared in `doc` (class, interface, trait,
347/// enum), applying the file's `namespace` declaration. Used to suppress
348/// self-references in the lazy-load list.
349fn collect_local_type_decl_fqns(doc: &ParsedDoc) -> HashSet<String> {
350    use php_ast::NamespaceBody;
351    let mut out = HashSet::new();
352    fn name_of(kind: &StmtKind<'_, '_>) -> Option<String> {
353        match kind {
354            StmtKind::Class(c) => c.name.as_ref().map(|n| n.to_string()),
355            StmtKind::Interface(i) => Some(i.name.to_string()),
356            StmtKind::Trait(t) => Some(t.name.to_string()),
357            StmtKind::Enum(e) => Some(e.name.to_string()),
358            _ => None,
359        }
360    }
361    let mut current_ns: Option<String> = None;
362    for stmt in doc.program().stmts.iter() {
363        match &stmt.kind {
364            StmtKind::Namespace(ns) => {
365                let ns_name = ns.name.as_ref().map(|n| n.to_string_repr().to_string());
366                match &ns.body {
367                    NamespaceBody::Braced(inner) => {
368                        let prefix = ns_name
369                            .as_deref()
370                            .map(|n| format!("{n}\\"))
371                            .unwrap_or_default();
372                        for s in inner.stmts.iter() {
373                            if let Some(n) = name_of(&s.kind) {
374                                out.insert(format!("{prefix}{n}"));
375                            }
376                        }
377                    }
378                    NamespaceBody::Simple => {
379                        current_ns = ns_name;
380                    }
381                }
382            }
383            k => {
384                if let Some(n) = name_of(k) {
385                    let fqn = match &current_ns {
386                        Some(ns) => format!("{ns}\\{n}"),
387                        None => n,
388                    };
389                    out.insert(fqn);
390                }
391            }
392        }
393    }
394    out
395}
396
397fn scan_doc(
398    word: &str,
399    uri: &Url,
400    doc: &Arc<ParsedDoc>,
401    include_declaration: bool,
402    include_use: bool,
403    kind: Option<SymbolKind>,
404    target_fqn: Option<&str>,
405) -> Vec<Location> {
406    let source = doc.source();
407    // Substring pre-filter: every walker below pushes a span only when an
408    // identifier's bytes equal `word`, so if `word` does not appear in the
409    // source it cannot produce any reference. `str::contains` is memchr-fast
410    // and skips the full AST traversal for the vast majority of files.
411    if !source.contains(word) {
412        return Vec::new();
413    }
414    let stmts = &doc.program().stmts;
415    let mut spans = Vec::new();
416
417    if include_use {
418        // Rename path: general walker covers call sites, `use` imports, and declarations.
419        refs_in_stmts_with_use(source, stmts, word, &mut spans);
420        if !include_declaration {
421            let mut decl_spans = Vec::new();
422            collect_declaration_spans(source, stmts, word, None, &mut decl_spans);
423            let decl_set: HashSet<(u32, u32)> =
424                decl_spans.iter().map(|s| (s.start, s.end)).collect();
425            spans.retain(|span| !decl_set.contains(&(span.start, span.end)));
426        }
427    } else {
428        match kind {
429            Some(SymbolKind::Function) => function_refs_in_stmts(stmts, word, &mut spans),
430            Some(SymbolKind::Method) => method_refs_in_stmts(stmts, word, &mut spans),
431            Some(SymbolKind::Class) => class_refs_in_stmts(stmts, word, &mut spans),
432            // Property walker emits both access sites *and* declaration spans
433            // (used by rename). Strip decls here when the caller doesn't want them.
434            Some(SymbolKind::Property) => {
435                let class_filter =
436                    target_fqn.map(|fqn| fqn_short_name(fqn.trim_start_matches('\\')));
437                property_refs_in_stmts(source, stmts, word, class_filter, &mut spans);
438                if !include_declaration {
439                    let mut decl_spans = Vec::new();
440                    collect_declaration_spans(
441                        source,
442                        stmts,
443                        word,
444                        Some(SymbolKind::Property),
445                        &mut decl_spans,
446                    );
447                    let decl_set: HashSet<(u32, u32)> =
448                        decl_spans.iter().map(|s| (s.start, s.end)).collect();
449                    spans.retain(|span| !decl_set.contains(&(span.start, span.end)));
450                }
451            }
452            // Constant walker emits both declaration spans and access spans.
453            Some(SymbolKind::Constant) => {
454                // Class constants: target_fqn = owning class short name (no backslash).
455                // Global/namespace constants: target_fqn = None (root) or
456                //   "Namespace\\ConstName" (namespaced, has backslash). Route to the
457                //   bare-identifier walker instead of the `::` class-const walker.
458                let is_global = target_fqn.is_none_or(|fqn| fqn.contains('\\'));
459                if is_global {
460                    global_constant_refs_in_stmts(source, stmts, word, target_fqn, &mut spans);
461                } else {
462                    // target_fqn = class short name for class constants.
463                    constant_refs_in_stmts(source, stmts, word, target_fqn, &mut spans);
464                }
465                if !include_declaration {
466                    let mut decl_spans = Vec::new();
467                    collect_declaration_spans(
468                        source,
469                        stmts,
470                        word,
471                        Some(SymbolKind::Constant),
472                        &mut decl_spans,
473                    );
474                    let decl_set: HashSet<(u32, u32)> =
475                        decl_spans.iter().map(|s| (s.start, s.end)).collect();
476                    spans.retain(|span| !decl_set.contains(&(span.start, span.end)));
477                }
478            }
479            // General walker already includes declarations; filter them out if unwanted.
480            None => {
481                refs_in_stmts(source, stmts, word, &mut spans);
482                if !include_declaration {
483                    let mut decl_spans = Vec::new();
484                    collect_declaration_spans(source, stmts, word, None, &mut decl_spans);
485                    let decl_set: HashSet<(u32, u32)> =
486                        decl_spans.iter().map(|s| (s.start, s.end)).collect();
487                    spans.retain(|span| !decl_set.contains(&(span.start, span.end)));
488                }
489            }
490        }
491        // Typed walkers (except Property, which already includes decls) don't emit
492        // declaration spans, so add them separately when wanted. Pass `kind` so only
493        // declarations of the matching category are appended — a Method search must
494        // not return a free-function declaration with the same name.
495        if include_declaration
496            && matches!(
497                kind,
498                Some(SymbolKind::Function) | Some(SymbolKind::Method) | Some(SymbolKind::Class)
499            )
500        {
501            collect_declaration_spans(source, stmts, word, kind, &mut spans);
502        }
503    }
504
505    let sv = doc.view();
506    let word_utf16_len: u32 = utf16_code_units(word);
507    spans
508        .into_iter()
509        .map(|span| {
510            let start = sv.position_of(span.start);
511            let end = Position {
512                line: start.line,
513                character: start.character + word_utf16_len,
514            };
515            Location {
516                uri: uri.clone(),
517                range: Range { start, end },
518            }
519        })
520        .collect()
521}
522
523/// Build a span covering exactly the declared name (not the keyword before it).
524/// Uses the stmt_span to search within the statement's context, avoiding false
525/// matches from earlier occurrences of the same name in the file.
526fn declaration_name_span(source: &str, name: &str, stmt_span: Span) -> Span {
527    let start = str_offset_in_range(source, stmt_span, name).unwrap_or(stmt_span.start);
528    Span {
529        start,
530        end: start + name.len() as u32,
531    }
532}
533
534/// Collect every span where `word` is *declared* within `stmts`.
535///
536/// When `kind` is `Some`, only declarations of the matching category are collected:
537/// - `Function` → free (`StmtKind::Function`) declarations only
538/// - `Method`   → method declarations inside classes / traits / enums only
539/// - `Class`    → class / interface / trait / enum type declarations only
540///
541/// `None` collects every declaration kind (used by `is_declaration_span`).
542fn collect_declaration_spans(
543    source: &str,
544    stmts: &[Stmt<'_, '_>],
545    word: &str,
546    kind: Option<SymbolKind>,
547    out: &mut Vec<Span>,
548) {
549    let want_free = matches!(kind, None | Some(SymbolKind::Function));
550    let want_method = matches!(kind, None | Some(SymbolKind::Method));
551    let want_type = matches!(kind, None | Some(SymbolKind::Class));
552    let want_property = matches!(kind, None | Some(SymbolKind::Property));
553    let want_constant = matches!(kind, None | Some(SymbolKind::Constant));
554
555    for stmt in stmts {
556        match &stmt.kind {
557            StmtKind::Function(f) if want_free && f.name == word => {
558                out.push(declaration_name_span(
559                    source,
560                    &f.name.to_string(),
561                    stmt.span,
562                ));
563            }
564            StmtKind::Class(c) => {
565                if want_type
566                    && let Some(name) = c.name
567                    && name == word
568                {
569                    out.push(declaration_name_span(source, &name.to_string(), stmt.span));
570                }
571                if want_method || want_property || want_constant {
572                    for member in c.body.members.iter() {
573                        match &member.kind {
574                            ClassMemberKind::Method(m) if want_method && m.name == word => {
575                                // Scope the name search to the member span,
576                                // not the whole class — otherwise a class
577                                // named the same as one of its members
578                                // (`class get { function get() {} }`) resolves
579                                // both decls to the class name's position.
580                                out.push(declaration_name_span(
581                                    source,
582                                    &m.name.to_string(),
583                                    member.span,
584                                ));
585                            }
586                            ClassMemberKind::Method(m)
587                                if want_property && m.name == "__construct" =>
588                            {
589                                // Promoted constructor params act as property declarations.
590                                for p in m.params.iter() {
591                                    if p.visibility.is_some() && p.name == word {
592                                        out.push(declaration_name_span(
593                                            source,
594                                            &p.name.to_string(),
595                                            p.span,
596                                        ));
597                                    }
598                                }
599                            }
600                            ClassMemberKind::Property(p) if want_property && p.name == word => {
601                                out.push(declaration_name_span(
602                                    source,
603                                    &p.name.to_string(),
604                                    member.span,
605                                ));
606                            }
607                            ClassMemberKind::ClassConst(c) if want_constant && c.name == word => {
608                                out.push(declaration_name_span(
609                                    source,
610                                    &c.name.to_string(),
611                                    member.span,
612                                ));
613                            }
614                            _ => {}
615                        }
616                    }
617                }
618            }
619            StmtKind::Interface(i) => {
620                if want_type && i.name == word {
621                    out.push(declaration_name_span(
622                        source,
623                        &i.name.to_string(),
624                        stmt.span,
625                    ));
626                }
627                if want_method || want_constant {
628                    for member in i.body.members.iter() {
629                        match &member.kind {
630                            ClassMemberKind::Method(m) if want_method && m.name == word => {
631                                out.push(declaration_name_span(
632                                    source,
633                                    &m.name.to_string(),
634                                    member.span,
635                                ));
636                            }
637                            ClassMemberKind::ClassConst(c) if want_constant && c.name == word => {
638                                out.push(declaration_name_span(
639                                    source,
640                                    &c.name.to_string(),
641                                    member.span,
642                                ));
643                            }
644                            _ => {}
645                        }
646                    }
647                }
648            }
649            StmtKind::Trait(t) => {
650                if want_type && t.name == word {
651                    out.push(declaration_name_span(
652                        source,
653                        &t.name.to_string(),
654                        stmt.span,
655                    ));
656                }
657                if want_method || want_property || want_constant {
658                    for member in t.body.members.iter() {
659                        match &member.kind {
660                            ClassMemberKind::Method(m) if want_method && m.name == word => {
661                                out.push(declaration_name_span(
662                                    source,
663                                    &m.name.to_string(),
664                                    member.span,
665                                ));
666                            }
667                            ClassMemberKind::Property(p) if want_property && p.name == word => {
668                                out.push(declaration_name_span(
669                                    source,
670                                    &p.name.to_string(),
671                                    member.span,
672                                ));
673                            }
674                            ClassMemberKind::ClassConst(c) if want_constant && c.name == word => {
675                                out.push(declaration_name_span(
676                                    source,
677                                    &c.name.to_string(),
678                                    member.span,
679                                ));
680                            }
681                            _ => {}
682                        }
683                    }
684                }
685            }
686            StmtKind::Enum(e) => {
687                if want_type && e.name == word {
688                    out.push(declaration_name_span(
689                        source,
690                        &e.name.to_string(),
691                        stmt.span,
692                    ));
693                }
694                for member in e.body.members.iter() {
695                    match &member.kind {
696                        EnumMemberKind::Method(m) if want_method && m.name == word => {
697                            out.push(declaration_name_span(
698                                source,
699                                &m.name.to_string(),
700                                member.span,
701                            ));
702                        }
703                        EnumMemberKind::Case(c) if want_type && c.name == word => {
704                            out.push(declaration_name_span(
705                                source,
706                                &c.name.to_string(),
707                                member.span,
708                            ));
709                        }
710                        EnumMemberKind::ClassConst(c) if want_constant && c.name == word => {
711                            out.push(declaration_name_span(
712                                source,
713                                &c.name.to_string(),
714                                member.span,
715                            ));
716                        }
717                        _ => {}
718                    }
719                }
720            }
721            StmtKind::Const(items) if want_constant => {
722                for item in items.iter() {
723                    if item.name == word {
724                        let name = item.name.to_string();
725                        out.push(declaration_name_span(source, &name, item.span));
726                    }
727                }
728            }
729            StmtKind::Expression(expr) if want_constant => {
730                // `define('NAME', value)` acts as a global constant declaration.
731                if let ExprKind::FunctionCall(f) = &expr.kind
732                    && let ExprKind::Identifier(id) = &f.name.kind
733                    && id.as_str() == "define"
734                    && let Some(first_arg) = f.args.first()
735                    && let ExprKind::String(s) = &first_arg.value.kind
736                    && *s == word
737                {
738                    let start = first_arg.value.span.start + 1;
739                    out.push(Span {
740                        start,
741                        end: start + s.len() as u32,
742                    });
743                }
744            }
745            StmtKind::Namespace(ns) => {
746                if let NamespaceBody::Braced(inner) = &ns.body {
747                    collect_declaration_spans(source, &inner.stmts, word, kind, out);
748                }
749            }
750            _ => {}
751        }
752    }
753}
754
755/// Build a `mir_analyzer::Name` from `(word, kind, target_fqn)`.
756/// Returns `None` when kind is None or the required FQN piece is missing.
757pub fn build_mir_symbol(
758    word: &str,
759    kind: Option<SymbolKind>,
760    target_fqn: Option<&str>,
761) -> Option<mir_analyzer::Name> {
762    match kind {
763        Some(SymbolKind::Function) => {
764            target_fqn.map(|fqn| mir_analyzer::Name::Function(Arc::from(fqn)))
765        }
766        Some(SymbolKind::Class) => target_fqn.map(|fqn| mir_analyzer::Name::Class(Arc::from(fqn))),
767        Some(SymbolKind::Method) => target_fqn.map(|owning| mir_analyzer::Name::Method {
768            class: Arc::from(owning),
769            // PHP method dispatch is case-insensitive; normalize here.
770            name: Arc::from(word.to_ascii_lowercase()),
771        }),
772        Some(SymbolKind::Property) => target_fqn.map(|owning| mir_analyzer::Name::Property {
773            class: Arc::from(owning),
774            name: Arc::from(word),
775        }),
776        Some(SymbolKind::Constant) | None => None,
777    }
778}
779
780/// Unified reference collector used by `textDocument/references`.
781///
782/// Decides which path(s) to take — mir's type-aware session index (fast,
783/// exact for methods), the AST walker (comprehensive), or both — and merges
784/// the results. All call sites previously duplicated this merge logic by hand.
785pub struct ReferenceQuery<'a> {
786    pub word: &'a str,
787    pub kind: Option<SymbolKind>,
788    pub target_fqn: Option<&'a str>,
789    /// Short name of the owning class for method queries (e.g. `"Widget"` from
790    /// `"App\\Widget"`). Used to post-filter mir results to files that textually
791    /// mention the class, preventing false positives from same-name methods on
792    /// unrelated classes.
793    pub owner_short: Option<&'a str>,
794}
795
796impl<'a> ReferenceQuery<'a> {
797    /// Collect reference locations. `candidate_docs` should already be filtered
798    /// to files that mention `self.word` (from `DocumentStore::candidate_docs_for`).
799    /// For Method queries, callers must call `DocumentStore::ensure_files_ingested`
800    /// before this method to populate the mir session.
801    ///
802    /// `declaration_location` is the cursor span; it is appended to Method-path
803    /// results when `include_declaration` is `true`.
804    pub fn collect(
805        &self,
806        docs: &DocumentStore,
807        candidate_docs: &[(Url, Arc<ParsedDoc>)],
808        include_declaration: bool,
809        declaration_location: Option<Location>,
810    ) -> Vec<Location> {
811        // --- Method path: prefer mir's type-aware session index -----------
812        if matches!(self.kind, Some(SymbolKind::Method))
813            && let Some(sym) = build_mir_symbol(self.word, self.kind, self.target_fqn)
814        {
815            let locs: Vec<Location> = docs
816                .session_references_to(&sym)
817                .into_iter()
818                .filter_map(|tuple| {
819                    let loc = session_tuple_to_location(tuple)?;
820                    if let Some(short) = self.owner_short {
821                        let mentions = docs
822                            .source_text(&loc.uri)
823                            .as_ref()
824                            .map(|src| src.contains(short))
825                            .unwrap_or(true);
826                        if !mentions {
827                            return None;
828                        }
829                    }
830                    Some(loc)
831                })
832                .collect();
833
834            if !locs.is_empty() {
835                let mut combined = locs;
836                if include_declaration {
837                    if let Some(decl) = declaration_location {
838                        combined.push(decl);
839                    }
840                    dedup_ref_locations(&mut combined);
841                }
842                return combined;
843            }
844        }
845        // mir session had no results — fall through to AST walker.
846
847        // --- AST walker path (all non-Method kinds, or Method fallback) ---
848        let mut locations = match self.target_fqn {
849            Some(t) => find_references_with_target(
850                self.word,
851                candidate_docs,
852                include_declaration,
853                self.kind,
854                t,
855            ),
856            None => find_references(self.word, candidate_docs, include_declaration, self.kind),
857        };
858
859        // For Function and Class kinds, augment with session refs that the
860        // AST walker may miss (cross-file dynamic dispatch, generated code).
861        if !matches!(
862            self.kind,
863            Some(SymbolKind::Method) | Some(SymbolKind::Property)
864        ) && let Some(sym) = build_mir_symbol(self.word, self.kind, self.target_fqn)
865        {
866            let extra = docs.session_references_to(&sym);
867            if !extra.is_empty() {
868                let mut seen: HashSet<(String, u32, u32, u32)> =
869                    locations.iter().map(ref_location_key).collect();
870                for loc in extra.into_iter().filter_map(session_tuple_to_location) {
871                    if seen.insert(ref_location_key(&loc)) {
872                        locations.push(loc);
873                    }
874                }
875            }
876        }
877
878        locations
879    }
880}