Skip to main content

php_lsp/
references.rs

1use std::collections::HashSet;
2use std::sync::Arc;
3
4use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Span, Stmt, StmtKind};
5use rayon::prelude::*;
6use tower_lsp::lsp_types::{Location, Position, Range, Url};
7
8use crate::ast::{ParsedDoc, str_offset};
9use crate::walk::{
10    class_refs_in_stmts, function_refs_in_stmts, method_refs_in_stmts, new_refs_in_stmts,
11    property_refs_in_stmts, refs_in_stmts, refs_in_stmts_with_use,
12};
13
14/// Callback signature for the mir-codebase reference-lookup fast path:
15/// `(key) -> Vec<(file_uri, start_byte, end_byte)>`.
16pub type RefLookup<'a> = dyn Fn(&str) -> Vec<(Arc<str>, u32, u16, u16)> + 'a;
17
18/// What kind of symbol the cursor is on.  Used to dispatch to the
19/// appropriate semantic walker so that, e.g., searching for `get` as a
20/// *method* doesn't return free-function calls named `get`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum SymbolKind {
23    /// A free (top-level) function.
24    Function,
25    /// An instance or static method (`->name`, `?->name`, `::name`).
26    Method,
27    /// A class, interface, trait, or enum name used as a type.
28    Class,
29    /// A class / trait property (`->name`, `?->name`, promoted or declared).
30    Property,
31}
32
33/// Find all locations where `word` is referenced across the given documents.
34/// If `include_declaration` is true, also includes the declaration site.
35/// Pass `kind` to restrict results to a particular symbol category; `None`
36/// falls back to the original word-based walker (better some results than none).
37pub fn find_references(
38    word: &str,
39    all_docs: &[(Url, Arc<ParsedDoc>)],
40    include_declaration: bool,
41    kind: Option<SymbolKind>,
42) -> Vec<Location> {
43    find_references_inner(word, all_docs, include_declaration, false, kind, None)
44}
45
46/// Like [`find_references`] but narrows scanning to docs whose namespace +
47/// `use` imports would resolve `word` to `target_fqn`. Used by
48/// `textDocument/references` for the AST fallback so it doesn't match
49/// same-short-name symbols in unrelated namespaces.
50pub fn find_references_with_target(
51    word: &str,
52    all_docs: &[(Url, Arc<ParsedDoc>)],
53    include_declaration: bool,
54    kind: Option<SymbolKind>,
55    target_fqn: &str,
56) -> Vec<Location> {
57    find_references_inner(
58        word,
59        all_docs,
60        include_declaration,
61        false,
62        kind,
63        Some(target_fqn),
64    )
65}
66
67/// Like `find_references` but also includes `use` statement spans.
68/// Used by rename so that `use Foo;` statements are also updated.
69/// Always uses the general walker (rename must update all occurrence kinds).
70pub fn find_references_with_use(
71    word: &str,
72    all_docs: &[(Url, Arc<ParsedDoc>)],
73    include_declaration: bool,
74) -> Vec<Location> {
75    find_references_inner(word, all_docs, include_declaration, true, None, None)
76}
77
78/// Find only `new ClassName(...)` instantiation sites across all docs.
79///
80/// Used by the `__construct` references handler — `SymbolKind::Class` (the normal
81/// class-kind path) is too broad because mir's `ClassReference` key covers type
82/// hints, `instanceof`, `extends`, and `implements` in addition to `new` calls.
83/// This function walks the AST using `new_refs_in_stmts` which only emits spans
84/// for `ExprKind::New` nodes, giving the caller exactly the call sites.
85///
86/// `class_fqn` is the fully-qualified name (e.g. `"Alpha\\Widget"`) used to
87/// filter files where the short name resolves to a different class. Pass `None`
88/// for global-namespace classes.
89pub fn find_constructor_references(
90    short_name: &str,
91    all_docs: &[(Url, Arc<ParsedDoc>)],
92    class_fqn: Option<&str>,
93) -> Vec<Location> {
94    let _class_utf16_len: u32 = short_name.chars().map(|c| c.len_utf16() as u32).sum();
95    all_docs
96        .par_iter()
97        .flat_map_iter(|(uri, doc)| {
98            // Skip files that can't reference the target unless they may use the FQN
99            // directly (without a `use` statement). FQN-qualified identifiers in the
100            // AST are disambiguated inside `new_refs_in_stmts` via `class_fqn`.
101            if let Some(fqn) = class_fqn
102                && !doc_can_reference_target(doc, short_name, fqn)
103                && !doc.view().source().contains(fqn.trim_start_matches('\\'))
104            {
105                return Vec::new();
106            }
107            let mut spans = Vec::new();
108            new_refs_in_stmts(&doc.program().stmts, short_name, class_fqn, &mut spans);
109            let sv = doc.view();
110            spans
111                .into_iter()
112                .map(|span| {
113                    let start = sv.position_of(span.start);
114                    let end = sv.position_of(span.end);
115                    Location {
116                        uri: uri.clone(),
117                        range: Range { start, end },
118                    }
119                })
120                .collect::<Vec<_>>()
121        })
122        .collect()
123}
124
125/// Fast path: look up pre-computed reference locations from the mir codebase index.
126///
127/// Handles `Function`, `Class`, and (partially) `Method` kinds.  For `Function` and
128/// `Class` the mir analyzer records every call-site / instantiation via
129/// `mark_*_referenced_at` and the index is authoritative.
130///
131/// For `Method`, the index is used as a pre-filter: only files that contain a tracked
132/// call site for the method are scanned with the AST walker.  This fast path is
133/// activated for two cases where the tracked set is reliably complete or narrows the
134/// search scope without missing real references:
135///   • `private` methods — PHP semantics guarantee that private methods are only
136///     callable from within the class body, so mir always resolves the receiver type.
137///   • methods on `final` classes — no subclassing means call sites on the concrete
138///     type are unambiguous; the codebase set covers all statically-typed callers.
139///
140/// Returns `None` for public/protected methods on non-final classes and for `None`
141/// kind (caller should use the general AST walker instead).  Also returns `None` when
142/// no matching symbol is found in the codebase.
143pub fn find_references_codebase(
144    word: &str,
145    all_docs: &[(Url, Arc<ParsedDoc>)],
146    include_declaration: bool,
147    kind: Option<SymbolKind>,
148    codebase: &mir_codebase::Codebase,
149    lookup_refs: &RefLookup<'_>,
150) -> Option<Vec<Location>> {
151    find_references_codebase_with_target(
152        word,
153        all_docs,
154        include_declaration,
155        kind,
156        None,
157        codebase,
158        lookup_refs,
159    )
160}
161
162/// Like [`find_references_codebase`] but accepts an exact FQN (for Function/Class)
163/// or owning FQCN (for Method) to avoid short-name collisions across namespaces
164/// and unrelated classes. When `target_fqn` is `None`, behaves identically to
165/// `find_references_codebase`.
166pub fn find_references_codebase_with_target(
167    word: &str,
168    all_docs: &[(Url, Arc<ParsedDoc>)],
169    include_declaration: bool,
170    kind: Option<SymbolKind>,
171    target_fqn: Option<&str>,
172    codebase: &mir_codebase::Codebase,
173    lookup_refs: &RefLookup<'_>,
174) -> Option<Vec<Location>> {
175    // Build a URI-string → (Url, ParsedDoc) map for O(1) lookup.
176    let doc_map: std::collections::HashMap<&str, (&Url, &Arc<ParsedDoc>)> = all_docs
177        .iter()
178        .map(|(url, doc)| (url.as_str(), (url, doc)))
179        .collect();
180
181    let spans_to_location =
182        |file: &str, line: u32, col_start: u16, col_end: u16| -> Option<Location> {
183            let (url, _doc) = doc_map.get(file)?;
184            Some(Location {
185                uri: (*url).clone(),
186                range: Range {
187                    start: Position::new(line.saturating_sub(1), col_start as u32),
188                    end: Position::new(line.saturating_sub(1), col_end as u32),
189                },
190            })
191        };
192
193    // Normalize: strip a single leading `\` from any fully-qualified target.
194    let target_fqn = target_fqn.map(|t| t.trim_start_matches('\\'));
195
196    match kind {
197        Some(SymbolKind::Function) => {
198            // When the caller resolved a specific FQN for the cursor, use it
199            // exactly — don't union across namespaces that share the short name.
200            let fqns: Vec<Arc<str>> = if let Some(t) = target_fqn.filter(|t| t.contains('\\')) {
201                // Exact FQN match only. If the codebase doesn't know this FQN,
202                // return None so the caller falls back to the AST walker
203                // (which will at least find in-file references).
204                match codebase.functions.get(t) {
205                    Some(entry) => vec![entry.key().clone()],
206                    None => return None,
207                }
208            } else {
209                codebase
210                    .functions
211                    .iter()
212                    .filter_map(|e| {
213                        let fqn = e.key();
214                        let short = fqn.rsplit('\\').next().unwrap_or(fqn.as_ref());
215                        if short == word {
216                            Some(fqn.clone())
217                        } else {
218                            None
219                        }
220                    })
221                    .collect()
222            };
223
224            if fqns.is_empty() {
225                return None;
226            }
227
228            let mut call_site_count = 0usize;
229            let mut locations: Vec<Location> = Vec::new();
230            for fqn in &fqns {
231                for (file, line, col_start, col_end) in lookup_refs(fqn) {
232                    if let Some(loc) = spans_to_location(&file, line, col_start, col_end) {
233                        locations.push(loc);
234                        call_site_count += 1;
235                    }
236                }
237                if include_declaration
238                    && let Some(func) = codebase.functions.get(fqn.as_ref())
239                    && let Some(decl) = &func.location
240                    && let Ok(url) = Url::parse(&decl.file)
241                {
242                    locations.push(Location {
243                        uri: url,
244                        range: Range {
245                            start: Position::new(
246                                decl.line.saturating_sub(1),
247                                decl.col_start as u32,
248                            ),
249                            end: Position::new(
250                                decl.line_end.saturating_sub(1),
251                                decl.col_end as u32,
252                            ),
253                        },
254                    });
255                }
256            }
257            // If mir tracked no call sites for this FQN, the index may be
258            // incomplete (still analyzing) or genuinely empty. Fall back to
259            // the AST walker so we don't silently drop real refs.
260            if call_site_count == 0 {
261                return None;
262            }
263            Some(locations)
264        }
265
266        // The mir index records ClassReference only for `new Foo()` expressions, not
267        // for type hints, `extends`, `implements`, or `instanceof`. Using the index
268        // would silently drop those sites when any `new` call exists. Always fall
269        // through to the AST walker (class_refs_in_stmts) which covers all sites.
270        Some(SymbolKind::Class) => None,
271
272        Some(SymbolKind::Method) => {
273            let word_lower = word.to_lowercase();
274
275            // Pre-compute the set of user-code URIs so stub classes that carry
276            // a (bundled-stub) `location` but aren't part of the workspace get
277            // filtered out.
278            let user_code_uris: HashSet<&str> =
279                all_docs.iter().map(|(url, _)| url.as_str()).collect();
280            let is_user_code = |loc: &Option<mir_codebase::storage::Location>| -> bool {
281                loc.as_ref()
282                    .is_some_and(|l| user_code_uris.contains(l.file.as_ref()))
283            };
284
285            let mut method_keys: Vec<String> = Vec::new();
286            let mut candidate_arcs: Vec<Arc<str>> = Vec::new();
287
288            if let Some(owner_fqcn) = target_fqn {
289                // Caller resolved the owning FQCN. Build the full set of owners
290                // (the target plus subclasses / implementers / trait users) and
291                // return locations straight from mir's reference index — which
292                // is keyed by exact FQCN, so calls on unrelated same-named
293                // classes are completely filtered out without re-walking the AST.
294                let mut owners: Vec<Arc<str>> = Vec::new();
295
296                if let Some(entry) = codebase.classes.get(owner_fqcn) {
297                    owners.push(entry.key().clone());
298                    for e in codebase.classes.iter() {
299                        if e.value()
300                            .all_parents
301                            .iter()
302                            .any(|p| p.as_ref() == owner_fqcn)
303                        {
304                            owners.push(e.key().clone());
305                        }
306                    }
307                } else if let Some(entry) = codebase.enums.get(owner_fqcn) {
308                    owners.push(entry.key().clone());
309                } else if let Some(entry) = codebase.interfaces.get(owner_fqcn) {
310                    owners.push(entry.key().clone());
311                    for e in codebase.classes.iter() {
312                        if e.value()
313                            .interfaces
314                            .iter()
315                            .any(|i| i.as_ref() == owner_fqcn)
316                        {
317                            owners.push(e.key().clone());
318                        }
319                    }
320                } else if let Some(entry) = codebase.traits.get(owner_fqcn) {
321                    owners.push(entry.key().clone());
322                    for e in codebase.classes.iter() {
323                        if e.value().traits.iter().any(|t| t.as_ref() == owner_fqcn) {
324                            owners.push(e.key().clone());
325                        }
326                    }
327                } else {
328                    return None;
329                }
330
331                // Reference locations are exact method-name spans from mir's
332                // index — use them directly (no AST re-scan which can't
333                // distinguish by receiver type).
334                let mut call_site_count = 0usize;
335                let mut locations: Vec<Location> = Vec::new();
336                for owner in &owners {
337                    let key = format!("{}::{}", owner, word_lower);
338                    for (file, line, col_start, col_end) in lookup_refs(&key) {
339                        if let Some(loc) = spans_to_location(&file, line, col_start, col_end) {
340                            locations.push(loc);
341                            call_site_count += 1;
342                        }
343                    }
344                }
345                // If mir tracked no call sites, fall back to the AST walker.
346                // This avoids silently dropping refs when mir can't resolve
347                // the receiver type at a call site (e.g. dynamic dispatch,
348                // typed-less $this, cross-file calls pending analysis).
349                if call_site_count == 0 {
350                    return None;
351                }
352
353                if include_declaration {
354                    // For each owner, parse its decl file and locate the
355                    // method's *name* span (not the body). This keeps the
356                    // declaration result pinpoint, matching what the rest of
357                    // the system does for non-codebase references.
358                    for owner in &owners {
359                        let decl_file =
360                            codebase
361                                .classes
362                                .get(owner.as_ref())
363                                .and_then(|e| {
364                                    e.value()
365                                        .own_methods
366                                        .get(word_lower.as_str())
367                                        .and_then(|m| m.location.as_ref().map(|l| l.file.clone()))
368                                })
369                                .or_else(|| {
370                                    codebase.enums.get(owner.as_ref()).and_then(|e| {
371                                        e.value().own_methods.get(word_lower.as_str()).and_then(
372                                            |m| m.location.as_ref().map(|l| l.file.clone()),
373                                        )
374                                    })
375                                })
376                                .or_else(|| {
377                                    codebase.interfaces.get(owner.as_ref()).and_then(|e| {
378                                        e.value().own_methods.get(word_lower.as_str()).and_then(
379                                            |m| m.location.as_ref().map(|l| l.file.clone()),
380                                        )
381                                    })
382                                })
383                                .or_else(|| {
384                                    codebase.traits.get(owner.as_ref()).and_then(|e| {
385                                        e.value().own_methods.get(word_lower.as_str()).and_then(
386                                            |m| m.location.as_ref().map(|l| l.file.clone()),
387                                        )
388                                    })
389                                });
390                        let Some(decl_file) = decl_file else { continue };
391                        let Some((url, doc)) = all_docs
392                            .iter()
393                            .find(|(u, _)| u.as_str() == decl_file.as_ref())
394                        else {
395                            continue;
396                        };
397                        // Scope the declaration lookup to the owning class, so
398                        // unrelated same-named methods in the same file don't
399                        // add spurious decl spans.
400                        let short = owner.rsplit('\\').next().unwrap_or(owner.as_ref());
401                        let mut spans: Vec<Span> = Vec::new();
402                        collect_method_decls_in_class(
403                            doc.source(),
404                            &doc.program().stmts,
405                            short,
406                            word,
407                            &mut spans,
408                        );
409                        let sv = doc.view();
410                        let word_utf16_len: u32 = word.chars().map(|c| c.len_utf16() as u32).sum();
411                        for span in spans {
412                            let start = sv.position_of(span.start);
413                            let end = Position {
414                                line: start.line,
415                                character: start.character + word_utf16_len,
416                            };
417                            locations.push(Location {
418                                uri: (*url).clone(),
419                                range: Range { start, end },
420                            });
421                        }
422                    }
423                }
424
425                return if locations.is_empty() {
426                    None
427                } else {
428                    Some(locations)
429                };
430            } else {
431                // No resolved owner — fall back to the previous gated heuristic
432                // (only final classes or private methods get the fast path).
433                for entry in codebase.classes.iter() {
434                    let cls = entry.value();
435                    if !is_user_code(&cls.location) {
436                        continue;
437                    }
438                    if let Some(method) = cls.own_methods.get(word_lower.as_str())
439                        && (cls.is_final || method.visibility == mir_codebase::Visibility::Private)
440                    {
441                        method_keys.push(format!("{}::{}", entry.key(), word_lower));
442                        if include_declaration && let Some(loc) = &method.location {
443                            candidate_arcs.push(loc.file.clone());
444                        }
445                    }
446                }
447                for entry in codebase.enums.iter() {
448                    let enm = entry.value();
449                    if !is_user_code(&enm.location) {
450                        continue;
451                    }
452                    if let Some(method) = enm.own_methods.get(word_lower.as_str())
453                        && method.visibility == mir_codebase::Visibility::Private
454                    {
455                        method_keys.push(format!("{}::{}", entry.key(), word_lower));
456                        if include_declaration && let Some(loc) = &method.location {
457                            candidate_arcs.push(loc.file.clone());
458                        }
459                    }
460                }
461
462                if method_keys.is_empty() {
463                    return None;
464                }
465            }
466
467            // Collect candidate files from the reference index.
468            for key in &method_keys {
469                for (file, _, _, _) in lookup_refs(key) {
470                    candidate_arcs.push(file);
471                }
472            }
473            let candidate_uris: HashSet<&str> = candidate_arcs.iter().map(|a| a.as_ref()).collect();
474
475            // Restrict the AST walk to the candidate files only.
476            let candidate_docs: Vec<(Url, Arc<ParsedDoc>)> = all_docs
477                .iter()
478                .filter(|(url, _)| candidate_uris.contains(url.as_str()))
479                .cloned()
480                .collect();
481
482            let locations = find_references_inner(
483                word,
484                &candidate_docs,
485                include_declaration,
486                false,
487                Some(SymbolKind::Method),
488                None,
489            );
490            Some(locations)
491        }
492
493        // General walker already handles None kind; codebase index adds no value.
494        None => None,
495
496        // Properties aren't tracked in the mir codebase index; fall through to
497        // the general AST walker by returning None.
498        Some(SymbolKind::Property) => None,
499    }
500}
501
502fn find_references_inner(
503    word: &str,
504    all_docs: &[(Url, Arc<ParsedDoc>)],
505    include_declaration: bool,
506    include_use: bool,
507    kind: Option<SymbolKind>,
508    target_fqn: Option<&str>,
509) -> Vec<Location> {
510    // Each document is scanned independently: substring pre-filter, AST walk,
511    // then span → position translation. Rayon parallelizes across docs; the
512    // per-doc work is CPU-bound and 100% independent, so this scales linearly
513    // with cores on large workspaces (Laravel: ~1,600 files).
514    // Per-file namespace pre-filter only applies to Function and Class kinds,
515    // where the target FQN refers to the symbol itself. For methods the
516    // target is the *owning* FQCN, which can't be compared against the
517    // method name via namespace resolution.
518    let namespace_filter_active =
519        matches!(kind, Some(SymbolKind::Function) | Some(SymbolKind::Class));
520    all_docs
521        .par_iter()
522        .flat_map_iter(|(uri, doc)| {
523            if namespace_filter_active
524                && let Some(target) = target_fqn
525                && !doc_can_reference_target(doc, word, target)
526            {
527                return Vec::new();
528            }
529            scan_doc(word, uri, doc, include_declaration, include_use, kind)
530        })
531        .collect()
532}
533
534/// Return true when this doc's namespace + `use` imports could plausibly
535/// refer to `target_fqn` under the short name `word`.  Used as a pre-filter
536/// so the AST walker doesn't emit refs in files whose namespace would resolve
537/// `word` to a different FQN.
538fn doc_can_reference_target(doc: &ParsedDoc, word: &str, target_fqn: &str) -> bool {
539    let target = target_fqn.trim_start_matches('\\');
540    let imports = collect_file_imports(doc);
541    let resolved = crate::moniker::resolve_fqn(doc, word, &imports);
542    // PHP falls back to the global namespace for unqualified *function* calls
543    // when the namespaced version doesn't exist.  We don't know at this point
544    // which symbol category the target is, so accept either an exact match
545    // or a global-namespace fallback match.
546    resolved == target
547        || (resolved == word && !target.contains('\\'))
548        || (resolved == word && target == format!("\\{word}"))
549}
550
551/// Build a local-name → FQN map from a doc's `use` statements.  Mirrors
552/// `Backend::file_imports` but self-contained so the reference walker can
553/// run without a persistent codebase.
554pub(crate) fn collect_file_imports(doc: &ParsedDoc) -> std::collections::HashMap<String, String> {
555    let mut out = std::collections::HashMap::new();
556    fn walk(stmts: &[Stmt<'_, '_>], out: &mut std::collections::HashMap<String, String>) {
557        for stmt in stmts {
558            match &stmt.kind {
559                StmtKind::Use(u) => {
560                    for item in u.uses.iter() {
561                        let fqn = item.name.to_string_repr().into_owned();
562                        let short = item
563                            .alias
564                            .map(|a| a.to_string())
565                            .unwrap_or_else(|| fqn.rsplit('\\').next().unwrap_or(&fqn).to_string());
566                        out.insert(short, fqn);
567                    }
568                }
569                StmtKind::Namespace(ns) => {
570                    if let NamespaceBody::Braced(inner) = &ns.body {
571                        walk(inner, out);
572                    }
573                }
574                _ => {}
575            }
576        }
577    }
578    walk(&doc.program().stmts, &mut out);
579    out
580}
581
582fn scan_doc(
583    word: &str,
584    uri: &Url,
585    doc: &Arc<ParsedDoc>,
586    include_declaration: bool,
587    include_use: bool,
588    kind: Option<SymbolKind>,
589) -> Vec<Location> {
590    let source = doc.source();
591    // Substring pre-filter: every walker below pushes a span only when an
592    // identifier's bytes equal `word`, so if `word` does not appear in the
593    // source it cannot produce any reference. `str::contains` is memchr-fast
594    // and skips the full AST traversal for the vast majority of files.
595    if !source.contains(word) {
596        return Vec::new();
597    }
598    let stmts = &doc.program().stmts;
599    let mut spans = Vec::new();
600
601    if include_use {
602        // Rename path: general walker covers call sites, `use` imports, and declarations.
603        refs_in_stmts_with_use(source, stmts, word, &mut spans);
604        if !include_declaration {
605            let mut decl_spans = Vec::new();
606            collect_declaration_spans(source, stmts, word, None, &mut decl_spans);
607            let decl_set: HashSet<(u32, u32)> =
608                decl_spans.iter().map(|s| (s.start, s.end)).collect();
609            spans.retain(|span| !decl_set.contains(&(span.start, span.end)));
610        }
611    } else {
612        match kind {
613            Some(SymbolKind::Function) => function_refs_in_stmts(stmts, word, &mut spans),
614            Some(SymbolKind::Method) => method_refs_in_stmts(stmts, word, &mut spans),
615            Some(SymbolKind::Class) => class_refs_in_stmts(stmts, word, &mut spans),
616            // Property walker emits both access sites *and* declaration spans
617            // (used by rename). Strip decls here when the caller doesn't want them.
618            Some(SymbolKind::Property) => {
619                property_refs_in_stmts(source, stmts, word, &mut spans);
620                if !include_declaration {
621                    let mut decl_spans = Vec::new();
622                    collect_declaration_spans(
623                        source,
624                        stmts,
625                        word,
626                        Some(SymbolKind::Property),
627                        &mut decl_spans,
628                    );
629                    let decl_set: HashSet<(u32, u32)> =
630                        decl_spans.iter().map(|s| (s.start, s.end)).collect();
631                    spans.retain(|span| !decl_set.contains(&(span.start, span.end)));
632                }
633            }
634            // General walker already includes declarations; filter them out if unwanted.
635            None => {
636                refs_in_stmts(source, stmts, word, &mut spans);
637                if !include_declaration {
638                    let mut decl_spans = Vec::new();
639                    collect_declaration_spans(source, stmts, word, None, &mut decl_spans);
640                    let decl_set: HashSet<(u32, u32)> =
641                        decl_spans.iter().map(|s| (s.start, s.end)).collect();
642                    spans.retain(|span| !decl_set.contains(&(span.start, span.end)));
643                }
644            }
645        }
646        // Typed walkers (except Property, which already includes decls) don't emit
647        // declaration spans, so add them separately when wanted. Pass `kind` so only
648        // declarations of the matching category are appended — a Method search must
649        // not return a free-function declaration with the same name.
650        if include_declaration
651            && matches!(
652                kind,
653                Some(SymbolKind::Function) | Some(SymbolKind::Method) | Some(SymbolKind::Class)
654            )
655        {
656            collect_declaration_spans(source, stmts, word, kind, &mut spans);
657        }
658    }
659
660    let sv = doc.view();
661    let word_utf16_len: u32 = word.chars().map(|c| c.len_utf16() as u32).sum();
662    spans
663        .into_iter()
664        .map(|span| {
665            let start = sv.position_of(span.start);
666            let end = Position {
667                line: start.line,
668                character: start.character + word_utf16_len,
669            };
670            Location {
671                uri: uri.clone(),
672                range: Range { start, end },
673            }
674        })
675        .collect()
676}
677
678/// Build a span covering exactly the declared name (not the keyword before it).
679fn declaration_name_span(source: &str, name: &str) -> Span {
680    let start = str_offset(source, name);
681    Span {
682        start,
683        end: start + name.len() as u32,
684    }
685}
686
687/// Collect method-name declaration spans for a method named `method_word`
688/// inside the class/interface/trait/enum whose short name is `class_short`.
689/// Used by the Method fast path to emit precise declaration spans that are
690/// scoped to the target owning type, so unrelated same-named methods in the
691/// same file don't pollute the results.
692fn collect_method_decls_in_class(
693    source: &str,
694    stmts: &[Stmt<'_, '_>],
695    class_short: &str,
696    method_word: &str,
697    out: &mut Vec<Span>,
698) {
699    for stmt in stmts {
700        match &stmt.kind {
701            StmtKind::Class(c) if c.name == Some(class_short) => {
702                for member in c.members.iter() {
703                    if let ClassMemberKind::Method(m) = &member.kind
704                        && m.name == method_word
705                    {
706                        out.push(declaration_name_span(source, m.name));
707                    }
708                }
709            }
710            StmtKind::Interface(i) if i.name == class_short => {
711                for member in i.members.iter() {
712                    if let ClassMemberKind::Method(m) = &member.kind
713                        && m.name == method_word
714                    {
715                        out.push(declaration_name_span(source, m.name));
716                    }
717                }
718            }
719            StmtKind::Trait(t) if t.name == class_short => {
720                for member in t.members.iter() {
721                    if let ClassMemberKind::Method(m) = &member.kind
722                        && m.name == method_word
723                    {
724                        out.push(declaration_name_span(source, m.name));
725                    }
726                }
727            }
728            StmtKind::Enum(e) if e.name == class_short => {
729                for member in e.members.iter() {
730                    if let EnumMemberKind::Method(m) = &member.kind
731                        && m.name == method_word
732                    {
733                        out.push(declaration_name_span(source, m.name));
734                    }
735                }
736            }
737            StmtKind::Namespace(ns) => {
738                if let NamespaceBody::Braced(inner) = &ns.body {
739                    collect_method_decls_in_class(source, inner, class_short, method_word, out);
740                }
741            }
742            _ => {}
743        }
744    }
745}
746
747/// Collect every span where `word` is *declared* within `stmts`.
748///
749/// When `kind` is `Some`, only declarations of the matching category are collected:
750/// - `Function` → free (`StmtKind::Function`) declarations only
751/// - `Method`   → method declarations inside classes / traits / enums only
752/// - `Class`    → class / interface / trait / enum type declarations only
753///
754/// `None` collects every declaration kind (used by `is_declaration_span`).
755fn collect_declaration_spans(
756    source: &str,
757    stmts: &[Stmt<'_, '_>],
758    word: &str,
759    kind: Option<SymbolKind>,
760    out: &mut Vec<Span>,
761) {
762    let want_free = matches!(kind, None | Some(SymbolKind::Function));
763    let want_method = matches!(kind, None | Some(SymbolKind::Method));
764    let want_type = matches!(kind, None | Some(SymbolKind::Class));
765    let want_property = matches!(kind, None | Some(SymbolKind::Property));
766
767    for stmt in stmts {
768        match &stmt.kind {
769            StmtKind::Function(f) => {
770                if want_free && f.name == word {
771                    out.push(declaration_name_span(source, f.name));
772                }
773            }
774            StmtKind::Class(c) => {
775                if want_type
776                    && let Some(name) = c.name
777                    && name == word
778                {
779                    out.push(declaration_name_span(source, name));
780                }
781                if want_method || want_property {
782                    for member in c.members.iter() {
783                        match &member.kind {
784                            ClassMemberKind::Method(m) if want_method && m.name == word => {
785                                out.push(declaration_name_span(source, m.name));
786                            }
787                            ClassMemberKind::Method(m)
788                                if want_property && m.name == "__construct" =>
789                            {
790                                // Promoted constructor params act as property declarations.
791                                for p in m.params.iter() {
792                                    if p.visibility.is_some() && p.name == word {
793                                        out.push(declaration_name_span(source, p.name));
794                                    }
795                                }
796                            }
797                            ClassMemberKind::Property(p) if want_property && p.name == word => {
798                                out.push(declaration_name_span(source, p.name));
799                            }
800                            _ => {}
801                        }
802                    }
803                }
804            }
805            StmtKind::Interface(i) => {
806                if want_type && i.name == word {
807                    out.push(declaration_name_span(source, i.name));
808                }
809                if want_method {
810                    for member in i.members.iter() {
811                        if let ClassMemberKind::Method(m) = &member.kind
812                            && m.name == word
813                        {
814                            out.push(declaration_name_span(source, m.name));
815                        }
816                    }
817                }
818            }
819            StmtKind::Trait(t) => {
820                if want_type && t.name == word {
821                    out.push(declaration_name_span(source, t.name));
822                }
823                if want_method || want_property {
824                    for member in t.members.iter() {
825                        match &member.kind {
826                            ClassMemberKind::Method(m) if want_method && m.name == word => {
827                                out.push(declaration_name_span(source, m.name));
828                            }
829                            ClassMemberKind::Property(p) if want_property && p.name == word => {
830                                out.push(declaration_name_span(source, p.name));
831                            }
832                            _ => {}
833                        }
834                    }
835                }
836            }
837            StmtKind::Enum(e) => {
838                if want_type && e.name == word {
839                    out.push(declaration_name_span(source, e.name));
840                }
841                for member in e.members.iter() {
842                    match &member.kind {
843                        EnumMemberKind::Method(m) if want_method && m.name == word => {
844                            out.push(declaration_name_span(source, m.name));
845                        }
846                        EnumMemberKind::Case(c) if want_type && c.name == word => {
847                            out.push(declaration_name_span(source, c.name));
848                        }
849                        _ => {}
850                    }
851                }
852            }
853            StmtKind::Namespace(ns) => {
854                if let NamespaceBody::Braced(inner) = &ns.body {
855                    collect_declaration_spans(source, inner, word, kind, out);
856                }
857            }
858            _ => {}
859        }
860    }
861}
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866
867    fn uri(path: &str) -> Url {
868        Url::parse(&format!("file://{path}")).unwrap()
869    }
870
871    fn doc(path: &str, source: &str) -> (Url, Arc<ParsedDoc>) {
872        (uri(path), Arc::new(ParsedDoc::parse(source.to_string())))
873    }
874
875    #[test]
876    fn finds_function_call_reference() {
877        let src = "<?php\nfunction greet() {}\ngreet();\ngreet();";
878        let docs = vec![doc("/a.php", src)];
879        let refs = find_references("greet", &docs, false, None);
880        assert_eq!(refs.len(), 2, "expected 2 call-site refs, got {:?}", refs);
881    }
882
883    #[test]
884    fn include_declaration_adds_def_site() {
885        let src = "<?php\nfunction greet() {}\ngreet();";
886        let docs = vec![doc("/a.php", src)];
887        let with_decl = find_references("greet", &docs, true, None);
888        let without_decl = find_references("greet", &docs, false, None);
889        // Without declaration: only the call site (line 2)
890        assert_eq!(
891            without_decl.len(),
892            1,
893            "expected 1 call-site ref without declaration"
894        );
895        assert_eq!(
896            without_decl[0].range.start.line, 2,
897            "call site should be on line 2"
898        );
899        // With declaration: 2 refs total (decl on line 1, call on line 2)
900        assert_eq!(
901            with_decl.len(),
902            2,
903            "expected 2 refs with declaration included"
904        );
905    }
906
907    #[test]
908    fn finds_new_expression_reference() {
909        let src = "<?php\nclass Foo {}\n$x = new Foo();";
910        let docs = vec![doc("/a.php", src)];
911        let refs = find_references("Foo", &docs, false, None);
912        assert_eq!(
913            refs.len(),
914            1,
915            "expected exactly 1 reference to Foo in new expr"
916        );
917        assert_eq!(
918            refs[0].range.start.line, 2,
919            "new Foo() reference should be on line 2"
920        );
921    }
922
923    #[test]
924    fn finds_reference_in_nested_function_call() {
925        let src = "<?php\nfunction greet() {}\necho(greet());";
926        let docs = vec![doc("/a.php", src)];
927        let refs = find_references("greet", &docs, false, None);
928        assert_eq!(
929            refs.len(),
930            1,
931            "expected exactly 1 nested function call reference"
932        );
933        assert_eq!(
934            refs[0].range.start.line, 2,
935            "nested greet() call should be on line 2"
936        );
937    }
938
939    #[test]
940    fn finds_references_across_multiple_docs() {
941        let a = doc("/a.php", "<?php\nfunction helper() {}");
942        let b = doc("/b.php", "<?php\nhelper();\nhelper();");
943        let refs = find_references("helper", &[a, b], false, None);
944        assert_eq!(refs.len(), 2, "expected 2 cross-file references");
945        assert!(refs.iter().all(|r| r.uri.path().ends_with("/b.php")));
946    }
947
948    #[test]
949    fn finds_method_call_reference() {
950        let src = "<?php\nclass Calc { public function add() {} }\n$c = new Calc();\n$c->add();";
951        let docs = vec![doc("/a.php", src)];
952        let refs = find_references("add", &docs, false, None);
953        assert_eq!(
954            refs.len(),
955            1,
956            "expected exactly 1 method call reference to 'add'"
957        );
958        assert_eq!(
959            refs[0].range.start.line, 3,
960            "add() call should be on line 3"
961        );
962    }
963
964    #[test]
965    fn finds_reference_inside_if_body() {
966        let src = "<?php\nfunction check() {}\nif (true) { check(); }";
967        let docs = vec![doc("/a.php", src)];
968        let refs = find_references("check", &docs, false, None);
969        assert_eq!(refs.len(), 1, "expected exactly 1 reference inside if body");
970        assert_eq!(
971            refs[0].range.start.line, 2,
972            "check() inside if should be on line 2"
973        );
974    }
975
976    #[test]
977    fn finds_use_statement_reference() {
978        // Renaming MyClass — the `use MyClass;` statement should be in the results
979        // when using find_references_with_use.
980        let src = "<?php\nuse MyClass;\n$x = new MyClass();";
981        let docs = vec![doc("/a.php", src)];
982        let refs = find_references_with_use("MyClass", &docs, false);
983        // Exactly 2 references: the `use MyClass;` on line 1 and `new MyClass()` on line 2.
984        assert_eq!(
985            refs.len(),
986            2,
987            "expected exactly 2 references, got: {:?}",
988            refs
989        );
990        let mut lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
991        lines.sort_unstable();
992        assert_eq!(
993            lines,
994            vec![1, 2],
995            "references should be on lines 1 (use) and 2 (new)"
996        );
997    }
998
999    #[test]
1000    fn find_references_returns_correct_lines() {
1001        // `helper` is called on lines 1 and 2 (0-based); check exact line numbers.
1002        let src = "<?php\nhelper();\nhelper();\nfunction helper() {}";
1003        let docs = vec![doc("/a.php", src)];
1004        let refs = find_references("helper", &docs, false, None);
1005        assert_eq!(refs.len(), 2, "expected exactly 2 call-site references");
1006        let mut lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1007        lines.sort_unstable();
1008        assert_eq!(lines, vec![1, 2], "references should be on lines 1 and 2");
1009    }
1010
1011    #[test]
1012    fn declaration_excluded_when_flag_false() {
1013        // When include_declaration=false the declaration line must not appear.
1014        let src = "<?php\nfunction doWork() {}\ndoWork();\ndoWork();";
1015        let docs = vec![doc("/a.php", src)];
1016        let refs = find_references("doWork", &docs, false, None);
1017        // Declaration is on line 1; call sites are on lines 2 and 3.
1018        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1019        assert!(
1020            !lines.contains(&1),
1021            "declaration line (1) must not appear when include_declaration=false, got: {:?}",
1022            lines
1023        );
1024        assert_eq!(refs.len(), 2, "expected 2 call-site references only");
1025    }
1026
1027    #[test]
1028    fn partial_match_not_included() {
1029        // Searching for references to `greet` should NOT include occurrences of `greeting`.
1030        let src = "<?php\nfunction greet() {}\nfunction greeting() {}\ngreet();\ngreeting();";
1031        let docs = vec![doc("/a.php", src)];
1032        let refs = find_references("greet", &docs, false, None);
1033        // Only `greet()` call site should be included, not `greeting()`.
1034        for r in &refs {
1035            // Each reference range should span exactly the length of "greet" (5 chars),
1036            // not longer (which would indicate "greeting" was matched).
1037            let span_len = r.range.end.character - r.range.start.character;
1038            assert_eq!(
1039                span_len, 5,
1040                "reference span length should equal len('greet')=5, got {} at {:?}",
1041                span_len, r
1042            );
1043        }
1044        // There should be exactly 1 call-site reference (the greet() call, not greeting()).
1045        assert_eq!(
1046            refs.len(),
1047            1,
1048            "expected exactly 1 reference to 'greet' (not 'greeting'), got: {:?}",
1049            refs
1050        );
1051    }
1052
1053    #[test]
1054    fn finds_reference_in_class_property_default() {
1055        // A class constant used as a property default value should be found by find_references.
1056        let src = "<?php\nclass Foo {\n    public string $status = Status::ACTIVE;\n}";
1057        let docs = vec![doc("/a.php", src)];
1058        let refs = find_references("Status", &docs, false, None);
1059        assert_eq!(
1060            refs.len(),
1061            1,
1062            "expected exactly 1 reference to Status in property default, got: {:?}",
1063            refs
1064        );
1065        assert_eq!(refs[0].range.start.line, 2, "reference should be on line 2");
1066    }
1067
1068    #[test]
1069    fn class_const_access_span_covers_only_member_name() {
1070        // Searching for the constant name `ACTIVE` in `Status::ACTIVE` must highlight
1071        // only `ACTIVE`, not the whole `Status::ACTIVE` expression.
1072        // Line 0: <?php
1073        // Line 1: $x = Status::ACTIVE;
1074        //                       ^ character 13
1075        let src = "<?php\n$x = Status::ACTIVE;";
1076        let docs = vec![doc("/a.php", src)];
1077        let refs = find_references("ACTIVE", &docs, false, None);
1078        assert_eq!(refs.len(), 1, "expected 1 reference, got: {:?}", refs);
1079        let r = &refs[0].range;
1080        assert_eq!(r.start.line, 1, "reference must be on line 1");
1081        // "$x = Status::" is 13 chars; "ACTIVE" starts at character 13.
1082        // Before the fix this was 5 (the start of "Status"), not 13.
1083        assert_eq!(
1084            r.start.character, 13,
1085            "range must start at 'ACTIVE' (char 13), not at 'Status' (char 5); got {:?}",
1086            r
1087        );
1088    }
1089
1090    #[test]
1091    fn class_const_access_no_duplicate_when_name_equals_class() {
1092        // Edge case: enum case named the same as the enum itself — `Status::Status`.
1093        // The general walker finds two distinct references:
1094        //   - the class-side `Status` at character 5  ($x = [S]tatus::Status)
1095        //   - the member-side `Status` at character 13 ($x = Status::[S]tatus)
1096        // Before the fix, both pushed a span starting at character 5, producing a duplicate.
1097        // Line 0: <?php
1098        // Line 1: $x = Status::Status;
1099        //              ^    char 5 (class)
1100        //                       ^ char 13 (member)
1101        let src = "<?php\n$x = Status::Status;";
1102        let docs = vec![doc("/a.php", src)];
1103        let refs = find_references("Status", &docs, false, None);
1104        assert_eq!(
1105            refs.len(),
1106            2,
1107            "expected exactly 2 refs (class side + member side), got: {:?}",
1108            refs
1109        );
1110        let mut chars: Vec<u32> = refs.iter().map(|r| r.range.start.character).collect();
1111        chars.sort_unstable();
1112        assert_eq!(
1113            chars,
1114            vec![5, 13],
1115            "class-side ref must be at char 5 and member-side at char 13, got: {:?}",
1116            refs
1117        );
1118    }
1119
1120    #[test]
1121    fn finds_reference_inside_enum_method_body() {
1122        // A function call inside an enum method body should be found by find_references.
1123        let src = "<?php\nfunction helper() {}\nenum Status {\n    public function label(): string { return helper(); }\n}";
1124        let docs = vec![doc("/a.php", src)];
1125        let refs = find_references("helper", &docs, false, None);
1126        assert_eq!(
1127            refs.len(),
1128            1,
1129            "expected exactly 1 reference to helper() inside enum method, got: {:?}",
1130            refs
1131        );
1132        assert_eq!(refs[0].range.start.line, 3, "reference should be on line 3");
1133    }
1134
1135    #[test]
1136    fn finds_reference_in_for_init_and_update() {
1137        // Function calls in `for` init and update expressions should be found.
1138        let src = "<?php\nfunction tick() {}\nfor (tick(); $i < 10; tick()) {}";
1139        let docs = vec![doc("/a.php", src)];
1140        let refs = find_references("tick", &docs, false, None);
1141        assert_eq!(
1142            refs.len(),
1143            2,
1144            "expected exactly 2 references to tick() (init + update), got: {:?}",
1145            refs
1146        );
1147        // Both are on line 2.
1148        assert!(refs.iter().all(|r| r.range.start.line == 2));
1149    }
1150
1151    // ── Semantic (kind-aware) tests ───────────────────────────────────────────
1152
1153    #[test]
1154    fn function_kind_skips_method_call_with_same_name() {
1155        // When looking for the free function `get`, method calls `$obj->get()` must be excluded.
1156        let src = "<?php\nfunction get() {}\nget();\n$obj->get();";
1157        let docs = vec![doc("/a.php", src)];
1158        let refs = find_references("get", &docs, false, Some(SymbolKind::Function));
1159        // Only the free call `get()` on line 2 should appear; not the method call on line 3.
1160        assert_eq!(
1161            refs.len(),
1162            1,
1163            "expected 1 free-function ref, got: {:?}",
1164            refs
1165        );
1166        assert_eq!(refs[0].range.start.line, 2);
1167    }
1168
1169    #[test]
1170    fn method_kind_skips_free_function_call_with_same_name() {
1171        // When looking for the method `add`, the free function call `add()` must be excluded.
1172        let src = "<?php\nfunction add() {}\nadd();\n$calc->add();";
1173        let docs = vec![doc("/a.php", src)];
1174        let refs = find_references("add", &docs, false, Some(SymbolKind::Method));
1175        // Only the method call on line 3 should appear.
1176        assert_eq!(refs.len(), 1, "expected 1 method ref, got: {:?}", refs);
1177        assert_eq!(refs[0].range.start.line, 3);
1178    }
1179
1180    #[test]
1181    fn class_kind_finds_new_expression() {
1182        // SymbolKind::Class should find `new Foo()` but not a free function call `Foo()`.
1183        let src = "<?php\nclass Foo {}\n$x = new Foo();\nFoo();";
1184        let docs = vec![doc("/a.php", src)];
1185        let refs = find_references("Foo", &docs, false, Some(SymbolKind::Class));
1186        // `new Foo()` on line 2 yes; `Foo()` on line 3 should NOT appear as a class ref.
1187        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1188        assert!(
1189            lines.contains(&2),
1190            "expected new Foo() on line 2, got: {:?}",
1191            refs
1192        );
1193        assert!(
1194            !lines.contains(&3),
1195            "free call Foo() should not appear as class ref, got: {:?}",
1196            refs
1197        );
1198    }
1199
1200    #[test]
1201    fn class_kind_finds_extends_and_implements() {
1202        let src = "<?php\nclass Base {}\ninterface Iface {}\nclass Child extends Base implements Iface {}";
1203        let docs = vec![doc("/a.php", src)];
1204
1205        let base_refs = find_references("Base", &docs, false, Some(SymbolKind::Class));
1206        let lines_base: Vec<u32> = base_refs.iter().map(|r| r.range.start.line).collect();
1207        assert!(
1208            lines_base.contains(&3),
1209            "expected extends Base on line 3, got: {:?}",
1210            base_refs
1211        );
1212
1213        let iface_refs = find_references("Iface", &docs, false, Some(SymbolKind::Class));
1214        let lines_iface: Vec<u32> = iface_refs.iter().map(|r| r.range.start.line).collect();
1215        assert!(
1216            lines_iface.contains(&3),
1217            "expected implements Iface on line 3, got: {:?}",
1218            iface_refs
1219        );
1220    }
1221
1222    #[test]
1223    fn class_kind_finds_type_hint() {
1224        // SymbolKind::Class should find `Foo` as a parameter type hint.
1225        let src = "<?php\nclass Foo {}\nfunction take(Foo $x): void {}";
1226        let docs = vec![doc("/a.php", src)];
1227        let refs = find_references("Foo", &docs, false, Some(SymbolKind::Class));
1228        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1229        assert!(
1230            lines.contains(&2),
1231            "expected type hint Foo on line 2, got: {:?}",
1232            refs
1233        );
1234    }
1235
1236    // ── Declaration span precision tests ────────────────────────────────────────
1237
1238    #[test]
1239    fn function_declaration_span_points_to_name_not_keyword() {
1240        // `include_declaration: true` — the declaration ref must start at `greet`,
1241        // not at the `function` keyword.
1242        let src = "<?php\nfunction greet() {}";
1243        let docs = vec![doc("/a.php", src)];
1244        let refs = find_references("greet", &docs, true, None);
1245        assert_eq!(refs.len(), 1, "expected exactly 1 ref (the declaration)");
1246        // "function " is 9 bytes; "greet" starts at byte 15 (after "<?php\n").
1247        // As a position, line 1, character 9.
1248        assert_eq!(
1249            refs[0].range.start.line, 1,
1250            "declaration should be on line 1"
1251        );
1252        assert_eq!(
1253            refs[0].range.start.character, 9,
1254            "declaration should start at the function name, not the 'function' keyword"
1255        );
1256        assert_eq!(
1257            refs[0].range.end.character,
1258            refs[0].range.start.character
1259                + "greet".chars().map(|c| c.len_utf16() as u32).sum::<u32>(),
1260            "range should span exactly the function name"
1261        );
1262    }
1263
1264    #[test]
1265    fn class_declaration_span_points_to_name_not_keyword() {
1266        let src = "<?php\nclass MyClass {}";
1267        let docs = vec![doc("/a.php", src)];
1268        let refs = find_references("MyClass", &docs, true, None);
1269        assert_eq!(refs.len(), 1);
1270        // "class " is 6 bytes; "MyClass" starts at character 6.
1271        assert_eq!(refs[0].range.start.line, 1);
1272        assert_eq!(
1273            refs[0].range.start.character, 6,
1274            "declaration should start at 'MyClass', not 'class'"
1275        );
1276    }
1277
1278    #[test]
1279    fn method_declaration_span_points_to_name_not_keyword() {
1280        let src = "<?php\nclass C {\n    public function doThing() {}\n}\n(new C())->doThing();";
1281        let docs = vec![doc("/a.php", src)];
1282        // include_declaration=true so we get the method declaration too.
1283        let refs = find_references("doThing", &docs, true, None);
1284        // Declaration on line 2, call on line 4.
1285        let decl_ref = refs
1286            .iter()
1287            .find(|r| r.range.start.line == 2)
1288            .expect("no declaration ref on line 2");
1289        // "    public function " is 20 chars; "doThing" starts at character 20.
1290        assert_eq!(
1291            decl_ref.range.start.character, 20,
1292            "method declaration should start at the method name, not 'public function'"
1293        );
1294    }
1295
1296    #[test]
1297    fn method_kind_with_include_declaration_does_not_return_free_function() {
1298        // Regression: kind precision must be preserved even when include_declaration=true.
1299        // A free function `get` and a method `get` coexist; searching with
1300        // SymbolKind::Method must NOT return either the free function call or its declaration.
1301        //
1302        // Line 0: <?php
1303        // Line 1: function get() {}          ← free function declaration
1304        // Line 2: get();                     ← free function call
1305        // Line 3: class C { public function get() {} }  ← method declaration
1306        // Line 4: $c->get();                 ← method call
1307        let src =
1308            "<?php\nfunction get() {}\nget();\nclass C { public function get() {} }\n$c->get();";
1309        let docs = vec![doc("/a.php", src)];
1310        let refs = find_references("get", &docs, true, Some(SymbolKind::Method));
1311        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1312        assert!(
1313            lines.contains(&3),
1314            "method declaration (line 3) must be present, got: {:?}",
1315            lines
1316        );
1317        assert!(
1318            lines.contains(&4),
1319            "method call (line 4) must be present, got: {:?}",
1320            lines
1321        );
1322        assert!(
1323            !lines.contains(&1),
1324            "free function declaration (line 1) must not appear when kind=Method, got: {:?}",
1325            lines
1326        );
1327        assert!(
1328            !lines.contains(&2),
1329            "free function call (line 2) must not appear when kind=Method, got: {:?}",
1330            lines
1331        );
1332    }
1333
1334    #[test]
1335    fn function_kind_with_include_declaration_does_not_return_method_call() {
1336        // Symmetric: SymbolKind::Function + include_declaration=true must not return method
1337        // calls or method declarations with the same name.
1338        //
1339        // Line 0: <?php
1340        // Line 1: function add() {}          ← free function declaration
1341        // Line 2: add();                     ← free function call
1342        // Line 3: class C { public function add() {} }  ← method declaration
1343        // Line 4: $c->add();                 ← method call
1344        let src =
1345            "<?php\nfunction add() {}\nadd();\nclass C { public function add() {} }\n$c->add();";
1346        let docs = vec![doc("/a.php", src)];
1347        let refs = find_references("add", &docs, true, Some(SymbolKind::Function));
1348        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1349        assert!(
1350            lines.contains(&1),
1351            "function declaration (line 1) must be present, got: {:?}",
1352            lines
1353        );
1354        assert!(
1355            lines.contains(&2),
1356            "function call (line 2) must be present, got: {:?}",
1357            lines
1358        );
1359        assert!(
1360            !lines.contains(&3),
1361            "method declaration (line 3) must not appear when kind=Function, got: {:?}",
1362            lines
1363        );
1364        assert!(
1365            !lines.contains(&4),
1366            "method call (line 4) must not appear when kind=Function, got: {:?}",
1367            lines
1368        );
1369    }
1370
1371    #[test]
1372    fn interface_method_declaration_included_when_flag_true() {
1373        // Regression: collect_declaration_spans must cover interface members, not only
1374        // classes/traits/enums. When include_declaration=true and kind=Method the
1375        // abstract method stub inside the interface must appear.
1376        //
1377        // Line 0: <?php
1378        // Line 1: interface I {
1379        // Line 2:     public function add(): void;   ← interface method declaration
1380        // Line 3: }
1381        // Line 4: $obj->add();                        ← call site
1382        let src = "<?php\ninterface I {\n    public function add(): void;\n}\n$obj->add();";
1383        let docs = vec![doc("/a.php", src)];
1384
1385        let refs = find_references("add", &docs, true, Some(SymbolKind::Method));
1386        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1387        assert!(
1388            lines.contains(&2),
1389            "interface method declaration (line 2) must appear with include_declaration=true, got: {:?}",
1390            lines
1391        );
1392        assert!(
1393            lines.contains(&4),
1394            "call site (line 4) must appear, got: {:?}",
1395            lines
1396        );
1397
1398        // With include_declaration=false only the call site should remain.
1399        let refs_no_decl = find_references("add", &docs, false, Some(SymbolKind::Method));
1400        let lines_no_decl: Vec<u32> = refs_no_decl.iter().map(|r| r.range.start.line).collect();
1401        assert!(
1402            !lines_no_decl.contains(&2),
1403            "interface method declaration must be excluded when include_declaration=false, got: {:?}",
1404            lines_no_decl
1405        );
1406    }
1407
1408    #[test]
1409    fn declaration_filter_finds_method_inside_same_named_class() {
1410        // Edge case: a class named `get` contains a method also named `get`.
1411        // collect_declaration_spans(kind=None) must find BOTH the class declaration
1412        // and the method declaration so is_declaration_span correctly filters both
1413        // when include_declaration=false.
1414        //
1415        // Line 0: <?php
1416        // Line 1: class get { public function get() {} }
1417        // Line 2: $obj->get();
1418        let src = "<?php\nclass get { public function get() {} }\n$obj->get();";
1419        let docs = vec![doc("/a.php", src)];
1420
1421        // With include_declaration=false, neither the class name nor the method
1422        // declaration should appear — only the call site on line 2.
1423        let refs = find_references("get", &docs, false, None);
1424        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1425        assert!(
1426            !lines.contains(&1),
1427            "declaration line (1) must not appear when include_declaration=false, got: {:?}",
1428            lines
1429        );
1430        assert!(
1431            lines.contains(&2),
1432            "call site (line 2) must be present, got: {:?}",
1433            lines
1434        );
1435
1436        // With include_declaration=true, the class declaration AND method declaration
1437        // are both on line 1; the call site is on line 2.
1438        let refs_with = find_references("get", &docs, true, None);
1439        assert_eq!(
1440            refs_with.len(),
1441            3,
1442            "expected 3 refs (class decl + method decl + call), got: {:?}",
1443            refs_with
1444        );
1445    }
1446
1447    #[test]
1448    fn interface_method_declaration_included_with_kind_none() {
1449        // Regression: the general walker must emit interface method name spans so that
1450        // kind=None + include_declaration=true returns the declaration, matching the
1451        // behaviour already present for class and trait methods.
1452        //
1453        // Line 0: <?php
1454        // Line 1: interface I {
1455        // Line 2:     public function add(): void;   ← declaration
1456        // Line 3: }
1457        // Line 4: $obj->add();                        ← call site
1458        let src = "<?php\ninterface I {\n    public function add(): void;\n}\n$obj->add();";
1459        let docs = vec![doc("/a.php", src)];
1460
1461        let refs = find_references("add", &docs, true, None);
1462        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1463        assert!(
1464            lines.contains(&2),
1465            "interface method declaration (line 2) must appear with kind=None + include_declaration=true, got: {:?}",
1466            lines
1467        );
1468    }
1469
1470    #[test]
1471    fn interface_method_declaration_excluded_with_kind_none_flag_false() {
1472        // Counterpart to interface_method_declaration_included_with_kind_none.
1473        // is_declaration_span calls collect_declaration_spans(kind=None), which after
1474        // the fix now emits interface method name spans. Verify that
1475        // include_declaration=false correctly suppresses the declaration.
1476        //
1477        // Line 0: <?php
1478        // Line 1: interface I {
1479        // Line 2:     public function add(): void;   ← declaration — must be absent
1480        // Line 3: }
1481        // Line 4: $obj->add();                        ← call site — must be present
1482        let src = "<?php\ninterface I {\n    public function add(): void;\n}\n$obj->add();";
1483        let docs = vec![doc("/a.php", src)];
1484
1485        let refs = find_references("add", &docs, false, None);
1486        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1487        assert!(
1488            !lines.contains(&2),
1489            "interface method declaration (line 2) must be excluded with kind=None + include_declaration=false, got: {:?}",
1490            lines
1491        );
1492        assert!(
1493            lines.contains(&4),
1494            "call site (line 4) must be present, got: {:?}",
1495            lines
1496        );
1497    }
1498
1499    #[test]
1500    fn function_kind_does_not_include_interface_method_declaration() {
1501        // kind=Function must not return interface method declarations. The existing
1502        // function_kind_with_include_declaration_does_not_return_method_call test
1503        // covers class methods; this covers the interface case specifically.
1504        //
1505        // Line 0: <?php
1506        // Line 1: function add() {}              ← free function declaration
1507        // Line 2: add();                         ← free function call
1508        // Line 3: interface I {
1509        // Line 4:     public function add(): void;  ← interface method — must be absent
1510        // Line 5: }
1511        let src =
1512            "<?php\nfunction add() {}\nadd();\ninterface I {\n    public function add(): void;\n}";
1513        let docs = vec![doc("/a.php", src)];
1514
1515        let refs = find_references("add", &docs, true, Some(SymbolKind::Function));
1516        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1517        assert!(
1518            lines.contains(&1),
1519            "free function declaration (line 1) must be present, got: {:?}",
1520            lines
1521        );
1522        assert!(
1523            lines.contains(&2),
1524            "free function call (line 2) must be present, got: {:?}",
1525            lines
1526        );
1527        assert!(
1528            !lines.contains(&4),
1529            "interface method declaration (line 4) must not appear with kind=Function, got: {:?}",
1530            lines
1531        );
1532    }
1533
1534    // ── switch / throw / unset / property-default coverage ──────────────────
1535
1536    #[test]
1537    fn finds_function_call_inside_switch_case() {
1538        // Line 1: function tick() {}
1539        // Line 2: switch ($x) { case 1: tick(); break; }
1540        let src = "<?php\nfunction tick() {}\nswitch ($x) {\n    case 1: tick(); break;\n}";
1541        let docs = vec![doc("/a.php", src)];
1542        let lines: Vec<u32> = find_references("tick", &docs, false, Some(SymbolKind::Function))
1543            .iter()
1544            .map(|r| r.range.start.line)
1545            .collect();
1546        assert!(
1547            lines.contains(&3),
1548            "tick() call inside switch case (line 3) must be present, got: {:?}",
1549            lines
1550        );
1551    }
1552
1553    #[test]
1554    fn finds_method_call_inside_switch_case() {
1555        // Line 1: switch ($x) { case 1: $obj->process(); break; }
1556        let src = "<?php\nswitch ($x) {\n    case 1: $obj->process(); break;\n}";
1557        let docs = vec![doc("/a.php", src)];
1558        let lines: Vec<u32> = find_references("process", &docs, false, Some(SymbolKind::Method))
1559            .iter()
1560            .map(|r| r.range.start.line)
1561            .collect();
1562        assert!(
1563            lines.contains(&2),
1564            "process() call inside switch case (line 2) must be present, got: {:?}",
1565            lines
1566        );
1567    }
1568
1569    #[test]
1570    fn finds_function_call_inside_switch_condition() {
1571        // Line 1: function classify() {}
1572        // Line 2: switch (classify()) { default: break; }
1573        let src = "<?php\nfunction classify() {}\nswitch (classify()) { default: break; }";
1574        let docs = vec![doc("/a.php", src)];
1575        let lines: Vec<u32> = find_references("classify", &docs, false, Some(SymbolKind::Function))
1576            .iter()
1577            .map(|r| r.range.start.line)
1578            .collect();
1579        assert!(
1580            lines.contains(&2),
1581            "classify() in switch subject (line 2) must be present, got: {:?}",
1582            lines
1583        );
1584    }
1585
1586    #[test]
1587    fn finds_function_call_inside_throw() {
1588        // Line 1: function makeException() {}
1589        // Line 2: throw makeException();
1590        let src = "<?php\nfunction makeException() {}\nthrow makeException();";
1591        let docs = vec![doc("/a.php", src)];
1592        let lines: Vec<u32> =
1593            find_references("makeException", &docs, false, Some(SymbolKind::Function))
1594                .iter()
1595                .map(|r| r.range.start.line)
1596                .collect();
1597        assert!(
1598            lines.contains(&2),
1599            "makeException() inside throw (line 2) must be present, got: {:?}",
1600            lines
1601        );
1602    }
1603
1604    #[test]
1605    fn finds_method_call_inside_throw() {
1606        // Line 1: throw $factory->create();
1607        let src = "<?php\nthrow $factory->create();";
1608        let docs = vec![doc("/a.php", src)];
1609        let lines: Vec<u32> = find_references("create", &docs, false, Some(SymbolKind::Method))
1610            .iter()
1611            .map(|r| r.range.start.line)
1612            .collect();
1613        assert!(
1614            lines.contains(&1),
1615            "create() inside throw (line 1) must be present, got: {:?}",
1616            lines
1617        );
1618    }
1619
1620    #[test]
1621    fn finds_method_call_inside_unset() {
1622        // Line 1: unset($obj->getProp());
1623        let src = "<?php\nunset($obj->getProp());";
1624        let docs = vec![doc("/a.php", src)];
1625        let lines: Vec<u32> = find_references("getProp", &docs, false, Some(SymbolKind::Method))
1626            .iter()
1627            .map(|r| r.range.start.line)
1628            .collect();
1629        assert!(
1630            lines.contains(&1),
1631            "getProp() inside unset (line 1) must be present, got: {:?}",
1632            lines
1633        );
1634    }
1635
1636    #[test]
1637    fn finds_static_method_call_in_class_property_default() {
1638        // Line 1: class Config {
1639        // Line 2:     public array $data = self::defaults();
1640        // Line 3:     public static function defaults(): array { return []; }
1641        // Line 4: }
1642        let src = "<?php\nclass Config {\n    public array $data = self::defaults();\n    public static function defaults(): array { return []; }\n}";
1643        let docs = vec![doc("/a.php", src)];
1644        let lines: Vec<u32> = find_references("defaults", &docs, false, Some(SymbolKind::Method))
1645            .iter()
1646            .map(|r| r.range.start.line)
1647            .collect();
1648        assert!(
1649            lines.contains(&2),
1650            "defaults() in class property default (line 2) must be present, got: {:?}",
1651            lines
1652        );
1653    }
1654
1655    #[test]
1656    fn finds_static_method_call_in_trait_property_default() {
1657        // Line 1: trait T {
1658        // Line 2:     public int $x = self::init();
1659        // Line 3:     public static function init(): int { return 0; }
1660        // Line 4: }
1661        let src = "<?php\ntrait T {\n    public int $x = self::init();\n    public static function init(): int { return 0; }\n}";
1662        let docs = vec![doc("/a.php", src)];
1663        let lines: Vec<u32> = find_references("init", &docs, false, Some(SymbolKind::Method))
1664            .iter()
1665            .map(|r| r.range.start.line)
1666            .collect();
1667        assert!(
1668            lines.contains(&2),
1669            "init() in trait property default (line 2) must be present, got: {:?}",
1670            lines
1671        );
1672    }
1673
1674    // ── find_references_codebase: Method fast-path ──────────────────────────
1675
1676    fn make_class(
1677        fqcn: &str,
1678        is_final: bool,
1679        method_name: &str,
1680        visibility: mir_codebase::Visibility,
1681    ) -> mir_codebase::ClassStorage {
1682        use indexmap::IndexMap;
1683        let method = mir_codebase::MethodStorage {
1684            name: std::sync::Arc::from(method_name),
1685            fqcn: std::sync::Arc::from(fqcn),
1686            params: vec![],
1687            return_type: None,
1688            inferred_return_type: None,
1689            visibility,
1690            is_static: false,
1691            is_abstract: false,
1692            is_final: false,
1693            is_constructor: false,
1694            template_params: vec![],
1695            assertions: vec![],
1696            throws: vec![],
1697            deprecated: None,
1698            is_internal: false,
1699            is_pure: false,
1700            location: None,
1701        };
1702        let mut methods: IndexMap<
1703            std::sync::Arc<str>,
1704            std::sync::Arc<mir_codebase::MethodStorage>,
1705        > = IndexMap::new();
1706        // own_methods keys are lowercase (PHP method names are case-insensitive).
1707        methods.insert(
1708            std::sync::Arc::from(method_name.to_lowercase().as_str()),
1709            std::sync::Arc::new(method),
1710        );
1711        mir_codebase::ClassStorage {
1712            fqcn: std::sync::Arc::from(fqcn),
1713            short_name: std::sync::Arc::from(fqcn.rsplit('\\').next().unwrap_or(fqcn)),
1714            parent: None,
1715            extends_type_args: vec![],
1716            interfaces: vec![],
1717            traits: vec![],
1718            mixins: vec![],
1719            implements_type_args: vec![],
1720            own_methods: methods,
1721            own_properties: IndexMap::new(),
1722            own_constants: IndexMap::new(),
1723            template_params: vec![],
1724            is_abstract: false,
1725            is_final,
1726            is_readonly: false,
1727            all_parents: vec![],
1728            deprecated: None,
1729            is_internal: false,
1730            type_aliases: std::collections::HashMap::new(),
1731            pending_import_types: vec![],
1732            // Synthetic user-code location so the fast path treats this as a
1733            // user class (stubs have `location: None` and are skipped).
1734            location: Some(mir_codebase::storage::Location {
1735                file: std::sync::Arc::from("file:///a.php"),
1736                line: 1,
1737                line_end: 1,
1738                col_start: 0,
1739                col_end: 0,
1740            }),
1741        }
1742    }
1743
1744    #[test]
1745    fn codebase_method_falls_back_for_public_method_on_nonfinal_class() {
1746        // Public method on a non-final class: no fast path → None → full AST scan.
1747        let cb = mir_codebase::Codebase::new();
1748        cb.classes.insert(
1749            std::sync::Arc::from("Foo"),
1750            make_class("Foo", false, "process", mir_codebase::Visibility::Public),
1751        );
1752        cb.mark_method_referenced_at(
1753            "Foo",
1754            "process",
1755            std::sync::Arc::from("file:///a.php"),
1756            3,
1757            0,
1758            7,
1759        );
1760
1761        let src = "<?php\nclass Foo { public function process() {} }\n$foo->process();";
1762        let docs = vec![doc("/a.php", src)];
1763        let result = find_references_codebase(
1764            "process",
1765            &docs,
1766            false,
1767            Some(SymbolKind::Method),
1768            &cb,
1769            &|k: &str| cb.get_reference_locations(k),
1770        );
1771        assert!(
1772            result.is_none(),
1773            "public method on non-final class must return None (fall back to AST), got: {:?}",
1774            result
1775        );
1776    }
1777
1778    #[test]
1779    fn codebase_method_fast_path_private_method_filters_files() {
1780        // Private method: only files tracked in the codebase index are scanned.
1781        // File b.php has a same-named call but is not in the codebase index —
1782        // it must be excluded, proving the fast path is active.
1783        let cb = mir_codebase::Codebase::new();
1784        cb.classes.insert(
1785            std::sync::Arc::from("Foo"),
1786            make_class("Foo", false, "execute", mir_codebase::Visibility::Private),
1787        );
1788        // Only a.php is tracked.
1789        cb.mark_method_referenced_at(
1790            "Foo",
1791            "execute",
1792            std::sync::Arc::from("file:///a.php"),
1793            4,
1794            0,
1795            7,
1796        );
1797
1798        // a.php: Foo with private execute + a call to $this->execute() inside the class.
1799        let src_a = "<?php\nclass Foo {\n    private function execute() {}\n    public function run() { $this->execute(); }\n}";
1800        // b.php: also calls ->execute() but is NOT in the codebase index.
1801        let src_b = "<?php\n$other->execute();";
1802
1803        let docs = vec![doc("/a.php", src_a), doc("/b.php", src_b)];
1804        let result = find_references_codebase(
1805            "execute",
1806            &docs,
1807            false,
1808            Some(SymbolKind::Method),
1809            &cb,
1810            &|k: &str| cb.get_reference_locations(k),
1811        );
1812
1813        assert!(
1814            result.is_some(),
1815            "private method must activate the fast path"
1816        );
1817        let locs = result.unwrap();
1818
1819        let uris: Vec<&str> = locs.iter().map(|l| l.uri.as_str()).collect();
1820        assert!(
1821            uris.iter().all(|u| u.ends_with("/a.php")),
1822            "all results must be from a.php (b.php was not in the codebase index), got: {:?}",
1823            locs
1824        );
1825        assert!(
1826            !locs.is_empty(),
1827            "expected at least the $this->execute() call in a.php, got: {:?}",
1828            locs
1829        );
1830    }
1831
1832    #[test]
1833    fn codebase_method_fast_path_final_class_filters_files() {
1834        // Final class: method is on a final class, so the fast path applies.
1835        // File b.php is not tracked → excluded.
1836        let cb = mir_codebase::Codebase::new();
1837        cb.classes.insert(
1838            std::sync::Arc::from("Counter"),
1839            make_class(
1840                "Counter",
1841                true, // is_final
1842                "increment",
1843                mir_codebase::Visibility::Public,
1844            ),
1845        );
1846        cb.mark_method_referenced_at(
1847            "Counter",
1848            "increment",
1849            std::sync::Arc::from("file:///a.php"),
1850            6,
1851            0,
1852            9,
1853        );
1854
1855        let src_a = "<?php\nfinal class Counter {\n    public function increment() {}\n}\n$c = new Counter();\n$c->increment();";
1856        let src_b = "<?php\n$other->increment();";
1857
1858        let docs = vec![doc("/a.php", src_a), doc("/b.php", src_b)];
1859        let result = find_references_codebase(
1860            "increment",
1861            &docs,
1862            false,
1863            Some(SymbolKind::Method),
1864            &cb,
1865            &|k: &str| cb.get_reference_locations(k),
1866        );
1867
1868        assert!(
1869            result.is_some(),
1870            "final class method must activate the fast path"
1871        );
1872        let locs = result.unwrap();
1873
1874        let uris: Vec<&str> = locs.iter().map(|l| l.uri.as_str()).collect();
1875        assert!(
1876            uris.iter().all(|u| u.ends_with("/a.php")),
1877            "all results must be from a.php only, got: {:?}",
1878            locs
1879        );
1880    }
1881
1882    #[test]
1883    fn codebase_method_fast_path_cross_file_reference() {
1884        // Realistic cross-file scenario: class defined in class.php, called from
1885        // caller.php and ignored.php (not tracked).
1886        // The fast path must include caller.php (tracked) and exclude ignored.php.
1887        let cb = mir_codebase::Codebase::new();
1888        cb.classes.insert(
1889            std::sync::Arc::from("Order"),
1890            make_class(
1891                "Order",
1892                true, // is_final
1893                "submit",
1894                mir_codebase::Visibility::Public,
1895            ),
1896        );
1897        // The codebase tracks caller.php as referencing Order::submit.
1898        cb.mark_method_referenced_at(
1899            "Order",
1900            "submit",
1901            std::sync::Arc::from("file:///caller.php"),
1902            3,
1903            0,
1904            6,
1905        );
1906
1907        // a.php: defines the final class (matches `make_class`'s synthetic
1908        // location). No calls here — the decl itself is not a call site.
1909        let src_class = "<?php\nfinal class Order {\n    public function submit() {}\n}";
1910        // caller.php: calls $order->submit() — tracked in codebase.
1911        let src_caller = "<?php\n$order = new Order();\n$order->submit();";
1912        // ignored.php: also calls ->submit() on an unknown type — NOT tracked.
1913        let src_ignored = "<?php\n$unknown->submit();";
1914
1915        let docs = vec![
1916            doc("/a.php", src_class),
1917            doc("/caller.php", src_caller),
1918            doc("/ignored.php", src_ignored),
1919        ];
1920
1921        let result = find_references_codebase(
1922            "submit",
1923            &docs,
1924            false,
1925            Some(SymbolKind::Method),
1926            &cb,
1927            &|k: &str| cb.get_reference_locations(k),
1928        );
1929
1930        assert!(result.is_some(), "fast path must activate for final class");
1931        let locs = result.unwrap();
1932
1933        let uris: Vec<&str> = locs.iter().map(|l| l.uri.as_str()).collect();
1934        assert!(
1935            uris.iter().any(|u| u.ends_with("/caller.php")),
1936            "caller.php (tracked) must appear in results, got: {:?}",
1937            locs
1938        );
1939        assert!(
1940            !uris.iter().any(|u| u.ends_with("/ignored.php")),
1941            "ignored.php (not tracked) must be excluded, got: {:?}",
1942            locs
1943        );
1944    }
1945
1946    #[test]
1947    fn codebase_method_fast_path_empty_codebase_falls_back() {
1948        // Empty codebase: no qualifying class found → None → caller falls back to full AST.
1949        let cb = mir_codebase::Codebase::new();
1950        let src = "<?php\n$obj->doWork();";
1951        let docs = vec![doc("/a.php", src)];
1952        let result = find_references_codebase(
1953            "doWork",
1954            &docs,
1955            false,
1956            Some(SymbolKind::Method),
1957            &cb,
1958            &|k: &str| cb.get_reference_locations(k),
1959        );
1960        assert!(
1961            result.is_none(),
1962            "empty codebase must return None for Method kind, got: {:?}",
1963            result
1964        );
1965    }
1966
1967    // ── SymbolKind::Property ─────────────────────────────────────────────────
1968
1969    #[test]
1970    fn property_kind_finds_instance_property_access() {
1971        // $obj->status should be found; free function `status()` must not appear.
1972        let src = "<?php\nclass Order {\n    public string $status = '';\n}\nfunction status() {}\n$o->status;\nstatus();";
1973        let docs = vec![doc("/a.php", src)];
1974        let refs = find_references("status", &docs, false, Some(SymbolKind::Property));
1975        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1976        assert!(
1977            lines.contains(&5),
1978            "$o->status access (line 5) must be present, got: {:?}",
1979            lines
1980        );
1981        assert!(
1982            !lines.contains(&6),
1983            "free function call status() (line 6) must not appear with kind=Property, got: {:?}",
1984            lines
1985        );
1986    }
1987
1988    #[test]
1989    fn property_kind_with_include_declaration_finds_decl() {
1990        // include_declaration=true must return the property declaration on the class body.
1991        let src = "<?php\nclass Foo {\n    public int $count = 0;\n}\n$f->count;\n$f->count;";
1992        let docs = vec![doc("/a.php", src)];
1993        let refs_with = find_references("count", &docs, true, Some(SymbolKind::Property));
1994        let lines_with: Vec<u32> = refs_with.iter().map(|r| r.range.start.line).collect();
1995        assert!(
1996            lines_with.contains(&2),
1997            "property declaration (line 2) must be included with include_declaration=true, got: {:?}",
1998            lines_with
1999        );
2000        assert!(
2001            lines_with.contains(&4),
2002            "first access (line 4) must be included, got: {:?}",
2003            lines_with
2004        );
2005        assert!(
2006            lines_with.contains(&5),
2007            "second access (line 5) must be included, got: {:?}",
2008            lines_with
2009        );
2010    }
2011
2012    #[test]
2013    fn property_kind_excludes_declaration_when_flag_false() {
2014        // include_declaration=false must suppress the property declaration but keep accesses.
2015        let src = "<?php\nclass Foo {\n    public int $count = 0;\n}\n$f->count;";
2016        let docs = vec![doc("/a.php", src)];
2017        let refs = find_references("count", &docs, false, Some(SymbolKind::Property));
2018        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
2019        assert!(
2020            !lines.contains(&2),
2021            "property declaration (line 2) must be excluded when include_declaration=false, got: {:?}",
2022            lines
2023        );
2024        assert!(
2025            lines.contains(&4),
2026            "access (line 4) must be included, got: {:?}",
2027            lines
2028        );
2029    }
2030
2031    #[test]
2032    fn property_kind_does_not_match_method_with_same_name() {
2033        // $obj->run is a property access; $obj->run() is a method call.
2034        // kind=Property must not return the method call.
2035        let src = "<?php\nclass Task {\n    public bool $run = false;\n    public function run(): void {}\n}\n$t->run;\n$t->run();";
2036        let docs = vec![doc("/a.php", src)];
2037        let refs = find_references("run", &docs, false, Some(SymbolKind::Property));
2038        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
2039        assert!(
2040            lines.contains(&5),
2041            "property access $t->run (line 5) must be present, got: {:?}",
2042            lines
2043        );
2044        // The method call $t->run() on line 6 is an ExprKind::MethodCall, not PropertyAccess,
2045        // so the property walker must not emit it.
2046        assert!(
2047            !lines.contains(&6),
2048            "method call $t->run() (line 6) must not appear with kind=Property, got: {:?}",
2049            lines
2050        );
2051    }
2052
2053    // ── Static method call ────────────────────────────────────────────────────
2054
2055    #[test]
2056    fn method_kind_finds_static_method_call() {
2057        // ClassName::method() is a StaticMethodCall; the method walker must capture it.
2058        let src = "<?php\nclass Builder {\n    public static function create(): self { return new self(); }\n}\nBuilder::create();\n$b->create();";
2059        let docs = vec![doc("/a.php", src)];
2060        let refs = find_references("create", &docs, false, Some(SymbolKind::Method));
2061        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
2062        assert!(
2063            lines.contains(&4),
2064            "Builder::create() static call (line 4) must be present, got: {:?}",
2065            lines
2066        );
2067        assert!(
2068            lines.contains(&5),
2069            "$b->create() instance call (line 5) must be present, got: {:?}",
2070            lines
2071        );
2072    }
2073
2074    // ── find_references_with_target namespace filtering ───────────────────────
2075
2076    #[test]
2077    fn find_references_with_target_includes_file_whose_namespace_resolves_to_target() {
2078        // file_a.php is in namespace Alpha — calling `Widget` resolves to `Alpha\Widget`.
2079        // find_references_with_target with target `Alpha\Widget` must include it.
2080        let src_a = "<?php\nnamespace Alpha;\nfunction make(): void { $w = new Widget(); }";
2081        let docs = vec![doc("/a.php", src_a)];
2082        let refs = find_references_with_target(
2083            "Widget",
2084            &docs,
2085            false,
2086            Some(SymbolKind::Class),
2087            "Alpha\\Widget",
2088        );
2089        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
2090        assert!(
2091            lines.contains(&2),
2092            "new Widget() in Alpha namespace (line 2) must be included, got: {:?}",
2093            lines
2094        );
2095    }
2096
2097    #[test]
2098    fn find_references_with_target_excludes_file_with_different_namespace() {
2099        // file_b.php is in namespace Beta — `Widget` there resolves to `Beta\Widget`,
2100        // so it must be excluded when the target is `Alpha\Widget`.
2101        let src_a = "<?php\nnamespace Alpha;\n$w = new Widget();";
2102        let src_b = "<?php\nnamespace Beta;\n$w = new Widget();";
2103        let docs = vec![doc("/a.php", src_a), doc("/b.php", src_b)];
2104        let refs = find_references_with_target(
2105            "Widget",
2106            &docs,
2107            false,
2108            Some(SymbolKind::Class),
2109            "Alpha\\Widget",
2110        );
2111        let uris: Vec<&str> = refs.iter().map(|r| r.uri.as_str()).collect();
2112        assert!(
2113            uris.iter().any(|u| u.ends_with("/a.php")),
2114            "Alpha\\Widget in a.php must be included, got: {:?}",
2115            refs
2116        );
2117        assert!(
2118            !uris.iter().any(|u| u.ends_with("/b.php")),
2119            "Beta\\Widget in b.php must be excluded, got: {:?}",
2120            refs
2121        );
2122    }
2123
2124    #[test]
2125    fn find_references_with_target_global_function_fallback() {
2126        // A file with no namespace calls `strlen` — PHP falls back to the global
2127        // namespace, so the target `strlen` (no backslash) must match.
2128        let src = "<?php\n$n = strlen('hello');";
2129        let docs = vec![doc("/a.php", src)];
2130        let refs = find_references_with_target(
2131            "strlen",
2132            &docs,
2133            false,
2134            Some(SymbolKind::Function),
2135            "strlen",
2136        );
2137        assert!(
2138            !refs.is_empty(),
2139            "strlen() in global-namespace file must be included, got: {:?}",
2140            refs
2141        );
2142    }
2143}