Skip to main content

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 &current_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}