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