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