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 ¤t_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}