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