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