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