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_in_range};
9use crate::util::utf16_code_units;
10use crate::walk::{
11    class_refs_in_stmts, fqn_new_class_refs_in_stmts, function_refs_in_stmts, method_refs_in_stmts,
12    new_refs_in_stmts, property_refs_in_stmts, refs_in_stmts, refs_in_stmts_with_use,
13};
14
15/// Callback signature for the mir-codebase reference-lookup fast path:
16/// `(key) -> Vec<(file_uri, start_byte, end_byte)>`.
17pub type RefLookup<'a> = dyn Fn(&str) -> Vec<(Arc<str>, u32, u16, u16)> + 'a;
18
19/// What kind of symbol the cursor is on.  Used to dispatch to the
20/// appropriate semantic walker so that, e.g., searching for `get` as a
21/// *method* doesn't return free-function calls named `get`.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum SymbolKind {
24    /// A free (top-level) function.
25    Function,
26    /// An instance or static method (`->name`, `?->name`, `::name`).
27    Method,
28    /// A class, interface, trait, or enum name used as a type.
29    Class,
30    /// A class / trait property (`->name`, `?->name`, promoted or declared).
31    Property,
32}
33
34fn class_has_ancestor(
35    codebase: &mir_analyzer::db::MirDb,
36    class_fqcn: &str,
37    target_fqcn: &str,
38) -> bool {
39    mir_analyzer::db::extends_or_implements_via_db(codebase, class_fqcn, target_fqcn)
40}
41
42/// Find all locations where `word` is referenced across the given documents.
43/// If `include_declaration` is true, also includes the declaration site.
44/// Pass `kind` to restrict results to a particular symbol category; `None`
45/// falls back to the original word-based walker (better some results than none).
46pub fn find_references(
47    word: &str,
48    all_docs: &[(Url, Arc<ParsedDoc>)],
49    include_declaration: bool,
50    kind: Option<SymbolKind>,
51) -> Vec<Location> {
52    find_references_inner(word, all_docs, include_declaration, false, kind, None)
53}
54
55/// Like [`find_references`] but narrows scanning to docs whose namespace +
56/// `use` imports would resolve `word` to `target_fqn`. Used by
57/// `textDocument/references` for the AST fallback so it doesn't match
58/// same-short-name symbols in unrelated namespaces.
59pub fn find_references_with_target(
60    word: &str,
61    all_docs: &[(Url, Arc<ParsedDoc>)],
62    include_declaration: bool,
63    kind: Option<SymbolKind>,
64    target_fqn: &str,
65) -> Vec<Location> {
66    // Default: include `use` statement spans so callers that pass
67    // `kind=None` (notably the rename handler) get their use-import edits.
68    // For typed kinds we want the kind-specific walker (so a Method search
69    // doesn't pick up free functions sharing the name); the general walker
70    // would falsely widen those results.
71    let include_use = kind.is_none();
72    find_references_inner(
73        word,
74        all_docs,
75        include_declaration,
76        include_use,
77        kind,
78        Some(target_fqn),
79    )
80}
81
82/// Like `find_references` but also includes `use` statement spans.
83/// Used by rename so that `use Foo;` statements are also updated.
84/// Always uses the general walker (rename must update all occurrence kinds).
85pub fn find_references_with_use(
86    word: &str,
87    all_docs: &[(Url, Arc<ParsedDoc>)],
88    include_declaration: bool,
89) -> Vec<Location> {
90    find_references_inner(word, all_docs, include_declaration, true, None, None)
91}
92
93/// Find only `new ClassName(...)` instantiation sites across all docs.
94///
95/// Used by the `__construct` references handler — `SymbolKind::Class` (the normal
96/// class-kind path) is too broad because mir's `ClassReference` key covers type
97/// hints, `instanceof`, `extends`, and `implements` in addition to `new` calls.
98/// This function walks the AST using `new_refs_in_stmts` which only emits spans
99/// for `ExprKind::New` nodes, giving the caller exactly the call sites.
100///
101/// `class_fqn` is the fully-qualified name (e.g. `"Alpha\\Widget"`) used to
102/// filter files where the short name resolves to a different class. Pass `None`
103/// for global-namespace classes.
104pub fn find_constructor_references(
105    short_name: &str,
106    all_docs: &[(Url, Arc<ParsedDoc>)],
107    class_fqn: Option<&str>,
108) -> Vec<Location> {
109    all_docs
110        .par_iter()
111        .flat_map_iter(|(uri, doc)| {
112            // Skip files that can't reference the target unless they may use the FQN
113            // directly (without a `use` statement). FQN-qualified identifiers in the
114            // AST are disambiguated inside `new_refs_in_stmts` via `class_fqn`.
115            if let Some(fqn) = class_fqn
116                && !doc_can_reference_target(doc, short_name, fqn)
117                && !doc.view().source().contains(fqn.trim_start_matches('\\'))
118            {
119                return Vec::new();
120            }
121            let mut spans = Vec::new();
122            new_refs_in_stmts(&doc.program().stmts, short_name, class_fqn, &mut spans);
123            let sv = doc.view();
124            spans
125                .into_iter()
126                .map(|span| {
127                    let start = sv.position_of(span.start);
128                    let end = sv.position_of(span.end);
129                    Location {
130                        uri: uri.clone(),
131                        range: Range { start, end },
132                    }
133                })
134                .collect::<Vec<_>>()
135        })
136        .collect()
137}
138
139/// Fast path: look up pre-computed reference locations from the mir codebase index.
140///
141/// Handles `Function`, `Class`, and (partially) `Method` kinds.  For `Function` and
142/// `Class` the mir analyzer records every call-site / instantiation via
143/// `mark_*_referenced_at` and the index is authoritative.
144///
145/// For `Method`, the index is used as a pre-filter: only files that contain a tracked
146/// call site for the method are scanned with the AST walker.  This fast path is
147/// activated for two cases where the tracked set is reliably complete or narrows the
148/// search scope without missing real references:
149///   • `private` methods — PHP semantics guarantee that private methods are only
150///     callable from within the class body, so mir always resolves the receiver type.
151///   • methods on `final` classes — no subclassing means call sites on the concrete
152///     type are unambiguous; the codebase set covers all statically-typed callers.
153///
154/// Returns `None` for public/protected methods on non-final classes and for `None`
155/// kind (caller should use the general AST walker instead).  Also returns `None` when
156/// no matching symbol is found in the codebase.
157pub fn find_references_codebase(
158    word: &str,
159    all_docs: &[(Url, Arc<ParsedDoc>)],
160    include_declaration: bool,
161    kind: Option<SymbolKind>,
162    codebase: &mir_analyzer::db::MirDb,
163    lookup_refs: &RefLookup<'_>,
164) -> Option<Vec<Location>> {
165    find_references_codebase_with_target(
166        word,
167        all_docs,
168        include_declaration,
169        kind,
170        None,
171        codebase,
172        lookup_refs,
173    )
174}
175
176/// Like [`find_references_codebase`] but accepts an exact FQN (for Function/Class)
177/// or owning FQCN (for Method) to avoid short-name collisions across namespaces
178/// and unrelated classes. When `target_fqn` is `None`, behaves identically to
179/// `find_references_codebase`.
180pub fn find_references_codebase_with_target(
181    _word: &str,
182    _all_docs: &[(Url, Arc<ParsedDoc>)],
183    _include_declaration: bool,
184    kind: Option<SymbolKind>,
185    _target_fqn: Option<&str>,
186    _codebase: &mir_analyzer::db::MirDb,
187    _lookup_refs: &RefLookup<'_>,
188) -> Option<Vec<Location>> {
189    match kind {
190        Some(SymbolKind::Function) => {
191            // For now, fall back to the AST walker for functions.
192            // In the future, we could query the MirDb for function info.
193            None
194        }
195
196        // The mir index records ClassReference only for `new Foo()` expressions, not
197        // for type hints, `extends`, `implements`, or `instanceof`. Using the index
198        // would silently drop those sites when any `new` call exists. Always fall
199        // through to the AST walker (class_refs_in_stmts) which covers all sites.
200        Some(SymbolKind::Class) => None,
201
202        Some(SymbolKind::Method) => {
203            // For now, fall back to the AST walker for methods.
204            // In the future, we could use the MirDb to optimize this.
205            None
206        }
207
208        // General walker already handles None kind; codebase index adds no value.
209        None => None,
210
211        // Properties aren't tracked in the mir codebase index; fall through to
212        // the general AST walker by returning None.
213        Some(SymbolKind::Property) => None,
214    }
215}
216
217fn find_references_inner(
218    word: &str,
219    all_docs: &[(Url, Arc<ParsedDoc>)],
220    include_declaration: bool,
221    include_use: bool,
222    kind: Option<SymbolKind>,
223    target_fqn: Option<&str>,
224) -> Vec<Location> {
225    // Each document is scanned independently: substring pre-filter, AST walk,
226    // then span → position translation. Rayon parallelizes across docs; the
227    // per-doc work is CPU-bound and 100% independent, so this scales linearly
228    // with cores on large workspaces (Laravel: ~1,600 files).
229    // Per-file namespace pre-filter only applies to Function and Class kinds,
230    // where the target FQN refers to the symbol itself. For methods the
231    // target is the *owning* FQCN, which can't be compared against the
232    // method name via namespace resolution.
233    let namespace_filter_active =
234        matches!(kind, Some(SymbolKind::Function) | Some(SymbolKind::Class));
235    all_docs
236        .par_iter()
237        .flat_map_iter(|(uri, doc)| {
238            if namespace_filter_active
239                && let Some(target) = target_fqn
240                && !doc_can_reference_target(doc, word, target)
241            {
242                return Vec::new();
243            }
244            scan_doc(word, uri, doc, include_declaration, include_use, kind)
245        })
246        .collect()
247}
248
249/// Return true when this doc's namespace + `use` imports could plausibly
250/// refer to `target_fqn` under the short name `word`.  Used as a pre-filter
251/// so the AST walker doesn't emit refs in files whose namespace would resolve
252/// `word` to a different FQN.
253fn doc_can_reference_target(doc: &ParsedDoc, word: &str, target_fqn: &str) -> bool {
254    let target = target_fqn.trim_start_matches('\\');
255    let imports = collect_file_imports(doc);
256    let resolved = crate::moniker::resolve_fqn(doc, word, &imports);
257    // PHP falls back to the global namespace for unqualified *function* calls
258    // when the namespaced version doesn't exist.  We don't know at this point
259    // which symbol category the target is, so accept either an exact match
260    // or a global-namespace fallback match.
261    resolved == target
262        || (resolved == word && !target.contains('\\'))
263        || (resolved == word && target == format!("\\{word}"))
264}
265
266/// Build a local-name → FQN map from a doc's `use` statements.  Mirrors
267/// `Backend::file_imports` but self-contained so the reference walker can
268/// run without a persistent codebase.
269pub(crate) fn collect_file_imports(doc: &ParsedDoc) -> std::collections::HashMap<String, String> {
270    let mut out = std::collections::HashMap::new();
271    fn walk(stmts: &[Stmt<'_, '_>], out: &mut std::collections::HashMap<String, String>) {
272        for stmt in stmts {
273            match &stmt.kind {
274                StmtKind::Use(u) => {
275                    for item in u.uses.iter() {
276                        let fqn = item.name.to_string_repr().into_owned();
277                        let short = item
278                            .alias
279                            .map(|a| a.to_string())
280                            .unwrap_or_else(|| fqn.rsplit('\\').next().unwrap_or(&fqn).to_string());
281                        out.insert(short, fqn);
282                    }
283                }
284                StmtKind::Namespace(ns) => {
285                    if let NamespaceBody::Braced(inner) = &ns.body {
286                        walk(inner, out);
287                    }
288                }
289                _ => {}
290            }
291        }
292    }
293    walk(&doc.program().stmts, &mut out);
294    out
295}
296
297/// Collect every FQN class name (e.g. `\App\Model\Entity`) referenced in a
298/// `new` expression that has no corresponding `use` import (i.e. written with
299/// a leading `\`).  Returns de-duplicated strings with the leading `\` stripped,
300/// ready for `session.lazy_load_class`.
301pub(crate) fn collect_fqn_new_class_refs(doc: &ParsedDoc) -> Vec<String> {
302    fqn_new_class_refs_in_stmts(&doc.program().stmts)
303}
304
305fn scan_doc(
306    word: &str,
307    uri: &Url,
308    doc: &Arc<ParsedDoc>,
309    include_declaration: bool,
310    include_use: bool,
311    kind: Option<SymbolKind>,
312) -> Vec<Location> {
313    let source = doc.source();
314    // Substring pre-filter: every walker below pushes a span only when an
315    // identifier's bytes equal `word`, so if `word` does not appear in the
316    // source it cannot produce any reference. `str::contains` is memchr-fast
317    // and skips the full AST traversal for the vast majority of files.
318    if !source.contains(word) {
319        return Vec::new();
320    }
321    let stmts = &doc.program().stmts;
322    let mut spans = Vec::new();
323
324    if include_use {
325        // Rename path: general walker covers call sites, `use` imports, and declarations.
326        refs_in_stmts_with_use(source, stmts, word, &mut spans);
327        if !include_declaration {
328            let mut decl_spans = Vec::new();
329            collect_declaration_spans(source, stmts, word, None, &mut decl_spans);
330            let decl_set: HashSet<(u32, u32)> =
331                decl_spans.iter().map(|s| (s.start, s.end)).collect();
332            spans.retain(|span| !decl_set.contains(&(span.start, span.end)));
333        }
334    } else {
335        match kind {
336            Some(SymbolKind::Function) => function_refs_in_stmts(stmts, word, &mut spans),
337            Some(SymbolKind::Method) => method_refs_in_stmts(stmts, word, &mut spans),
338            Some(SymbolKind::Class) => class_refs_in_stmts(stmts, word, &mut spans),
339            // Property walker emits both access sites *and* declaration spans
340            // (used by rename). Strip decls here when the caller doesn't want them.
341            Some(SymbolKind::Property) => {
342                property_refs_in_stmts(source, stmts, word, &mut spans);
343                if !include_declaration {
344                    let mut decl_spans = Vec::new();
345                    collect_declaration_spans(
346                        source,
347                        stmts,
348                        word,
349                        Some(SymbolKind::Property),
350                        &mut decl_spans,
351                    );
352                    let decl_set: HashSet<(u32, u32)> =
353                        decl_spans.iter().map(|s| (s.start, s.end)).collect();
354                    spans.retain(|span| !decl_set.contains(&(span.start, span.end)));
355                }
356            }
357            // General walker already includes declarations; filter them out if unwanted.
358            None => {
359                refs_in_stmts(source, stmts, word, &mut spans);
360                if !include_declaration {
361                    let mut decl_spans = Vec::new();
362                    collect_declaration_spans(source, stmts, word, None, &mut decl_spans);
363                    let decl_set: HashSet<(u32, u32)> =
364                        decl_spans.iter().map(|s| (s.start, s.end)).collect();
365                    spans.retain(|span| !decl_set.contains(&(span.start, span.end)));
366                }
367            }
368        }
369        // Typed walkers (except Property, which already includes decls) don't emit
370        // declaration spans, so add them separately when wanted. Pass `kind` so only
371        // declarations of the matching category are appended — a Method search must
372        // not return a free-function declaration with the same name.
373        if include_declaration
374            && matches!(
375                kind,
376                Some(SymbolKind::Function) | Some(SymbolKind::Method) | Some(SymbolKind::Class)
377            )
378        {
379            collect_declaration_spans(source, stmts, word, kind, &mut spans);
380        }
381    }
382
383    let sv = doc.view();
384    let word_utf16_len: u32 = utf16_code_units(word);
385    spans
386        .into_iter()
387        .map(|span| {
388            let start = sv.position_of(span.start);
389            let end = Position {
390                line: start.line,
391                character: start.character + word_utf16_len,
392            };
393            Location {
394                uri: uri.clone(),
395                range: Range { start, end },
396            }
397        })
398        .collect()
399}
400
401/// Build a span covering exactly the declared name (not the keyword before it).
402/// Uses the stmt_span to search within the statement's context, avoiding false
403/// matches from earlier occurrences of the same name in the file.
404fn declaration_name_span(source: &str, name: &str, stmt_span: Span) -> Span {
405    let start = str_offset_in_range(source, stmt_span, name).unwrap_or(stmt_span.start);
406    Span {
407        start,
408        end: start + name.len() as u32,
409    }
410}
411
412/// Collect method-name declaration spans for a method named `method_word`
413/// inside the class/interface/trait/enum whose short name is `class_short`.
414/// Used by the Method fast path to emit precise declaration spans that are
415/// scoped to the target owning type, so unrelated same-named methods in the
416/// same file don't pollute the results.
417fn collect_method_decls_in_class(
418    source: &str,
419    stmts: &[Stmt<'_, '_>],
420    class_short: &str,
421    method_word: &str,
422    out: &mut Vec<Span>,
423) {
424    for stmt in stmts {
425        match &stmt.kind {
426            StmtKind::Class(c)
427                if c.name.as_ref().map(|n| n.to_string()) == Some(class_short.to_string()) =>
428            {
429                for member in c.members.iter() {
430                    if let ClassMemberKind::Method(m) = &member.kind
431                        && m.name == method_word
432                    {
433                        out.push(declaration_name_span(
434                            source,
435                            &m.name.to_string(),
436                            stmt.span,
437                        ));
438                    }
439                }
440            }
441            StmtKind::Interface(i) if i.name == class_short => {
442                for member in i.members.iter() {
443                    if let ClassMemberKind::Method(m) = &member.kind
444                        && m.name == method_word
445                    {
446                        out.push(declaration_name_span(
447                            source,
448                            &m.name.to_string(),
449                            stmt.span,
450                        ));
451                    }
452                }
453            }
454            StmtKind::Trait(t) if t.name == class_short => {
455                for member in t.members.iter() {
456                    if let ClassMemberKind::Method(m) = &member.kind
457                        && m.name == method_word
458                    {
459                        out.push(declaration_name_span(
460                            source,
461                            &m.name.to_string(),
462                            stmt.span,
463                        ));
464                    }
465                }
466            }
467            StmtKind::Enum(e) if e.name == class_short => {
468                for member in e.members.iter() {
469                    if let EnumMemberKind::Method(m) = &member.kind
470                        && m.name == method_word
471                    {
472                        out.push(declaration_name_span(
473                            source,
474                            &m.name.to_string(),
475                            stmt.span,
476                        ));
477                    }
478                }
479            }
480            StmtKind::Namespace(ns) => {
481                if let NamespaceBody::Braced(inner) = &ns.body {
482                    collect_method_decls_in_class(source, inner, class_short, method_word, out);
483                }
484            }
485            _ => {}
486        }
487    }
488}
489
490/// Collect every span where `word` is *declared* within `stmts`.
491///
492/// When `kind` is `Some`, only declarations of the matching category are collected:
493/// - `Function` → free (`StmtKind::Function`) declarations only
494/// - `Method`   → method declarations inside classes / traits / enums only
495/// - `Class`    → class / interface / trait / enum type declarations only
496///
497/// `None` collects every declaration kind (used by `is_declaration_span`).
498fn collect_declaration_spans(
499    source: &str,
500    stmts: &[Stmt<'_, '_>],
501    word: &str,
502    kind: Option<SymbolKind>,
503    out: &mut Vec<Span>,
504) {
505    let want_free = matches!(kind, None | Some(SymbolKind::Function));
506    let want_method = matches!(kind, None | Some(SymbolKind::Method));
507    let want_type = matches!(kind, None | Some(SymbolKind::Class));
508    let want_property = matches!(kind, None | Some(SymbolKind::Property));
509
510    for stmt in stmts {
511        match &stmt.kind {
512            StmtKind::Function(f) if want_free && f.name == word => {
513                out.push(declaration_name_span(
514                    source,
515                    &f.name.to_string(),
516                    stmt.span,
517                ));
518            }
519            StmtKind::Class(c) => {
520                if want_type
521                    && let Some(name) = c.name
522                    && name == word
523                {
524                    out.push(declaration_name_span(source, &name.to_string(), stmt.span));
525                }
526                if want_method || want_property {
527                    for member in c.members.iter() {
528                        match &member.kind {
529                            ClassMemberKind::Method(m) if want_method && m.name == word => {
530                                // Scope the name search to the member span,
531                                // not the whole class — otherwise a class
532                                // named the same as one of its members
533                                // (`class get { function get() {} }`) resolves
534                                // both decls to the class name's position.
535                                out.push(declaration_name_span(
536                                    source,
537                                    &m.name.to_string(),
538                                    member.span,
539                                ));
540                            }
541                            ClassMemberKind::Method(m)
542                                if want_property && m.name == "__construct" =>
543                            {
544                                // Promoted constructor params act as property declarations.
545                                for p in m.params.iter() {
546                                    if p.visibility.is_some() && p.name == word {
547                                        out.push(declaration_name_span(
548                                            source,
549                                            &p.name.to_string(),
550                                            p.span,
551                                        ));
552                                    }
553                                }
554                            }
555                            ClassMemberKind::Property(p) if want_property && p.name == word => {
556                                out.push(declaration_name_span(
557                                    source,
558                                    &p.name.to_string(),
559                                    member.span,
560                                ));
561                            }
562                            _ => {}
563                        }
564                    }
565                }
566            }
567            StmtKind::Interface(i) => {
568                if want_type && i.name == word {
569                    out.push(declaration_name_span(
570                        source,
571                        &i.name.to_string(),
572                        stmt.span,
573                    ));
574                }
575                if want_method {
576                    for member in i.members.iter() {
577                        if let ClassMemberKind::Method(m) = &member.kind
578                            && m.name == word
579                        {
580                            out.push(declaration_name_span(
581                                source,
582                                &m.name.to_string(),
583                                member.span,
584                            ));
585                        }
586                    }
587                }
588            }
589            StmtKind::Trait(t) => {
590                if want_type && t.name == word {
591                    out.push(declaration_name_span(
592                        source,
593                        &t.name.to_string(),
594                        stmt.span,
595                    ));
596                }
597                if want_method || want_property {
598                    for member in t.members.iter() {
599                        match &member.kind {
600                            ClassMemberKind::Method(m) if want_method && m.name == word => {
601                                out.push(declaration_name_span(
602                                    source,
603                                    &m.name.to_string(),
604                                    stmt.span,
605                                ));
606                            }
607                            ClassMemberKind::Property(p) if want_property && p.name == word => {
608                                out.push(declaration_name_span(
609                                    source,
610                                    &p.name.to_string(),
611                                    stmt.span,
612                                ));
613                            }
614                            _ => {}
615                        }
616                    }
617                }
618            }
619            StmtKind::Enum(e) => {
620                if want_type && e.name == word {
621                    out.push(declaration_name_span(
622                        source,
623                        &e.name.to_string(),
624                        stmt.span,
625                    ));
626                }
627                for member in e.members.iter() {
628                    match &member.kind {
629                        EnumMemberKind::Method(m) if want_method && m.name == word => {
630                            out.push(declaration_name_span(
631                                source,
632                                &m.name.to_string(),
633                                stmt.span,
634                            ));
635                        }
636                        EnumMemberKind::Case(c) if want_type && c.name == word => {
637                            out.push(declaration_name_span(
638                                source,
639                                &c.name.to_string(),
640                                stmt.span,
641                            ));
642                        }
643                        _ => {}
644                    }
645                }
646            }
647            StmtKind::Namespace(ns) => {
648                if let NamespaceBody::Braced(inner) = &ns.body {
649                    collect_declaration_spans(source, inner, word, kind, out);
650                }
651            }
652            _ => {}
653        }
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    fn uri(path: &str) -> Url {
662        Url::parse(&format!("file://{path}")).unwrap()
663    }
664
665    fn doc(path: &str, source: &str) -> (Url, Arc<ParsedDoc>) {
666        (uri(path), Arc::new(ParsedDoc::parse(source.to_string())))
667    }
668
669    #[test]
670    fn finds_function_call_reference() {
671        let src = "<?php\nfunction greet() {}\ngreet();\ngreet();";
672        let docs = vec![doc("/a.php", src)];
673        let refs = find_references("greet", &docs, false, None);
674        assert_eq!(refs.len(), 2, "expected 2 call-site refs, got {:?}", refs);
675    }
676
677    #[test]
678    fn include_declaration_adds_def_site() {
679        let src = "<?php\nfunction greet() {}\ngreet();";
680        let docs = vec![doc("/a.php", src)];
681        let with_decl = find_references("greet", &docs, true, None);
682        let without_decl = find_references("greet", &docs, false, None);
683        // Without declaration: only the call site (line 2)
684        assert_eq!(
685            without_decl.len(),
686            1,
687            "expected 1 call-site ref without declaration"
688        );
689        assert_eq!(
690            without_decl[0].range.start.line, 2,
691            "call site should be on line 2"
692        );
693        // With declaration: 2 refs total (decl on line 1, call on line 2)
694        assert_eq!(
695            with_decl.len(),
696            2,
697            "expected 2 refs with declaration included"
698        );
699    }
700
701    #[test]
702    fn finds_new_expression_reference() {
703        let src = "<?php\nclass Foo {}\n$x = new Foo();";
704        let docs = vec![doc("/a.php", src)];
705        let refs = find_references("Foo", &docs, false, None);
706        assert_eq!(
707            refs.len(),
708            1,
709            "expected exactly 1 reference to Foo in new expr"
710        );
711        assert_eq!(
712            refs[0].range.start.line, 2,
713            "new Foo() reference should be on line 2"
714        );
715    }
716
717    #[test]
718    fn finds_reference_in_nested_function_call() {
719        let src = "<?php\nfunction greet() {}\necho(greet());";
720        let docs = vec![doc("/a.php", src)];
721        let refs = find_references("greet", &docs, false, None);
722        assert_eq!(
723            refs.len(),
724            1,
725            "expected exactly 1 nested function call reference"
726        );
727        assert_eq!(
728            refs[0].range.start.line, 2,
729            "nested greet() call should be on line 2"
730        );
731    }
732
733    #[test]
734    fn finds_references_across_multiple_docs() {
735        let a = doc("/a.php", "<?php\nfunction helper() {}");
736        let b = doc("/b.php", "<?php\nhelper();\nhelper();");
737        let refs = find_references("helper", &[a, b], false, None);
738        assert_eq!(refs.len(), 2, "expected 2 cross-file references");
739        assert!(refs.iter().all(|r| r.uri.path().ends_with("/b.php")));
740    }
741
742    #[test]
743    fn finds_method_call_reference() {
744        let src = "<?php\nclass Calc { public function add() {} }\n$c = new Calc();\n$c->add();";
745        let docs = vec![doc("/a.php", src)];
746        let refs = find_references("add", &docs, false, None);
747        assert_eq!(
748            refs.len(),
749            1,
750            "expected exactly 1 method call reference to 'add'"
751        );
752        assert_eq!(
753            refs[0].range.start.line, 3,
754            "add() call should be on line 3"
755        );
756    }
757
758    #[test]
759    fn finds_reference_inside_if_body() {
760        let src = "<?php\nfunction check() {}\nif (true) { check(); }";
761        let docs = vec![doc("/a.php", src)];
762        let refs = find_references("check", &docs, false, None);
763        assert_eq!(refs.len(), 1, "expected exactly 1 reference inside if body");
764        assert_eq!(
765            refs[0].range.start.line, 2,
766            "check() inside if should be on line 2"
767        );
768    }
769
770    #[test]
771    fn finds_use_statement_reference() {
772        // Renaming MyClass — the `use MyClass;` statement should be in the results
773        // when using find_references_with_use.
774        let src = "<?php\nuse MyClass;\n$x = new MyClass();";
775        let docs = vec![doc("/a.php", src)];
776        let refs = find_references_with_use("MyClass", &docs, false);
777        // Exactly 2 references: the `use MyClass;` on line 1 and `new MyClass()` on line 2.
778        assert_eq!(
779            refs.len(),
780            2,
781            "expected exactly 2 references, got: {:?}",
782            refs
783        );
784        let mut lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
785        lines.sort_unstable();
786        assert_eq!(
787            lines,
788            vec![1, 2],
789            "references should be on lines 1 (use) and 2 (new)"
790        );
791    }
792
793    #[test]
794    fn find_references_returns_correct_lines() {
795        // `helper` is called on lines 1 and 2 (0-based); check exact line numbers.
796        let src = "<?php\nhelper();\nhelper();\nfunction helper() {}";
797        let docs = vec![doc("/a.php", src)];
798        let refs = find_references("helper", &docs, false, None);
799        assert_eq!(refs.len(), 2, "expected exactly 2 call-site references");
800        let mut lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
801        lines.sort_unstable();
802        assert_eq!(lines, vec![1, 2], "references should be on lines 1 and 2");
803    }
804
805    #[test]
806    fn declaration_excluded_when_flag_false() {
807        // When include_declaration=false the declaration line must not appear.
808        let src = "<?php\nfunction doWork() {}\ndoWork();\ndoWork();";
809        let docs = vec![doc("/a.php", src)];
810        let refs = find_references("doWork", &docs, false, None);
811        // Declaration is on line 1; call sites are on lines 2 and 3.
812        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
813        assert!(
814            !lines.contains(&1),
815            "declaration line (1) must not appear when include_declaration=false, got: {:?}",
816            lines
817        );
818        assert_eq!(refs.len(), 2, "expected 2 call-site references only");
819    }
820
821    #[test]
822    fn partial_match_not_included() {
823        // Searching for references to `greet` should NOT include occurrences of `greeting`.
824        let src = "<?php\nfunction greet() {}\nfunction greeting() {}\ngreet();\ngreeting();";
825        let docs = vec![doc("/a.php", src)];
826        let refs = find_references("greet", &docs, false, None);
827        // Only `greet()` call site should be included, not `greeting()`.
828        for r in &refs {
829            // Each reference range should span exactly the length of "greet" (5 chars),
830            // not longer (which would indicate "greeting" was matched).
831            let span_len = r.range.end.character - r.range.start.character;
832            assert_eq!(
833                span_len, 5,
834                "reference span length should equal len('greet')=5, got {} at {:?}",
835                span_len, r
836            );
837        }
838        // There should be exactly 1 call-site reference (the greet() call, not greeting()).
839        assert_eq!(
840            refs.len(),
841            1,
842            "expected exactly 1 reference to 'greet' (not 'greeting'), got: {:?}",
843            refs
844        );
845    }
846
847    #[test]
848    fn finds_reference_in_class_property_default() {
849        // A class constant used as a property default value should be found by find_references.
850        let src = "<?php\nclass Foo {\n    public string $status = Status::ACTIVE;\n}";
851        let docs = vec![doc("/a.php", src)];
852        let refs = find_references("Status", &docs, false, None);
853        assert_eq!(
854            refs.len(),
855            1,
856            "expected exactly 1 reference to Status in property default, got: {:?}",
857            refs
858        );
859        assert_eq!(refs[0].range.start.line, 2, "reference should be on line 2");
860    }
861
862    #[test]
863    fn class_const_access_span_covers_only_member_name() {
864        // Searching for the constant name `ACTIVE` in `Status::ACTIVE` must highlight
865        // only `ACTIVE`, not the whole `Status::ACTIVE` expression.
866        // Line 0: <?php
867        // Line 1: $x = Status::ACTIVE;
868        //                       ^ character 13
869        let src = "<?php\n$x = Status::ACTIVE;";
870        let docs = vec![doc("/a.php", src)];
871        let refs = find_references("ACTIVE", &docs, false, None);
872        assert_eq!(refs.len(), 1, "expected 1 reference, got: {:?}", refs);
873        let r = &refs[0].range;
874        assert_eq!(r.start.line, 1, "reference must be on line 1");
875        // "$x = Status::" is 13 chars; "ACTIVE" starts at character 13.
876        // Before the fix this was 5 (the start of "Status"), not 13.
877        assert_eq!(
878            r.start.character, 13,
879            "range must start at 'ACTIVE' (char 13), not at 'Status' (char 5); got {:?}",
880            r
881        );
882    }
883
884    #[test]
885    fn class_const_access_no_duplicate_when_name_equals_class() {
886        // Edge case: enum case named the same as the enum itself — `Status::Status`.
887        // The general walker finds two distinct references:
888        //   - the class-side `Status` at character 5  ($x = [S]tatus::Status)
889        //   - the member-side `Status` at character 13 ($x = Status::[S]tatus)
890        // Before the fix, both pushed a span starting at character 5, producing a duplicate.
891        // Line 0: <?php
892        // Line 1: $x = Status::Status;
893        //              ^    char 5 (class)
894        //                       ^ char 13 (member)
895        let src = "<?php\n$x = Status::Status;";
896        let docs = vec![doc("/a.php", src)];
897        let refs = find_references("Status", &docs, false, None);
898        assert_eq!(
899            refs.len(),
900            2,
901            "expected exactly 2 refs (class side + member side), got: {:?}",
902            refs
903        );
904        let mut chars: Vec<u32> = refs.iter().map(|r| r.range.start.character).collect();
905        chars.sort_unstable();
906        assert_eq!(
907            chars,
908            vec![5, 13],
909            "class-side ref must be at char 5 and member-side at char 13, got: {:?}",
910            refs
911        );
912    }
913
914    #[test]
915    fn finds_reference_inside_enum_method_body() {
916        // A function call inside an enum method body should be found by find_references.
917        let src = "<?php\nfunction helper() {}\nenum Status {\n    public function label(): string { return helper(); }\n}";
918        let docs = vec![doc("/a.php", src)];
919        let refs = find_references("helper", &docs, false, None);
920        assert_eq!(
921            refs.len(),
922            1,
923            "expected exactly 1 reference to helper() inside enum method, got: {:?}",
924            refs
925        );
926        assert_eq!(refs[0].range.start.line, 3, "reference should be on line 3");
927    }
928
929    #[test]
930    fn finds_reference_in_for_init_and_update() {
931        // Function calls in `for` init and update expressions should be found.
932        let src = "<?php\nfunction tick() {}\nfor (tick(); $i < 10; tick()) {}";
933        let docs = vec![doc("/a.php", src)];
934        let refs = find_references("tick", &docs, false, None);
935        assert_eq!(
936            refs.len(),
937            2,
938            "expected exactly 2 references to tick() (init + update), got: {:?}",
939            refs
940        );
941        // Both are on line 2.
942        assert!(refs.iter().all(|r| r.range.start.line == 2));
943    }
944
945    // ── Semantic (kind-aware) tests ───────────────────────────────────────────
946
947    #[test]
948    fn function_kind_skips_method_call_with_same_name() {
949        // When looking for the free function `get`, method calls `$obj->get()` must be excluded.
950        let src = "<?php\nfunction get() {}\nget();\n$obj->get();";
951        let docs = vec![doc("/a.php", src)];
952        let refs = find_references("get", &docs, false, Some(SymbolKind::Function));
953        // Only the free call `get()` on line 2 should appear; not the method call on line 3.
954        assert_eq!(
955            refs.len(),
956            1,
957            "expected 1 free-function ref, got: {:?}",
958            refs
959        );
960        assert_eq!(refs[0].range.start.line, 2);
961    }
962
963    #[test]
964    fn method_kind_skips_free_function_call_with_same_name() {
965        // When looking for the method `add`, the free function call `add()` must be excluded.
966        let src = "<?php\nfunction add() {}\nadd();\n$calc->add();";
967        let docs = vec![doc("/a.php", src)];
968        let refs = find_references("add", &docs, false, Some(SymbolKind::Method));
969        // Only the method call on line 3 should appear.
970        assert_eq!(refs.len(), 1, "expected 1 method ref, got: {:?}", refs);
971        assert_eq!(refs[0].range.start.line, 3);
972    }
973
974    #[test]
975    fn class_kind_finds_new_expression() {
976        // SymbolKind::Class should find `new Foo()` but not a free function call `Foo()`.
977        let src = "<?php\nclass Foo {}\n$x = new Foo();\nFoo();";
978        let docs = vec![doc("/a.php", src)];
979        let refs = find_references("Foo", &docs, false, Some(SymbolKind::Class));
980        // `new Foo()` on line 2 yes; `Foo()` on line 3 should NOT appear as a class ref.
981        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
982        assert!(
983            lines.contains(&2),
984            "expected new Foo() on line 2, got: {:?}",
985            refs
986        );
987        assert!(
988            !lines.contains(&3),
989            "free call Foo() should not appear as class ref, got: {:?}",
990            refs
991        );
992    }
993
994    #[test]
995    fn class_kind_finds_extends_and_implements() {
996        let src = "<?php\nclass Base {}\ninterface Iface {}\nclass Child extends Base implements Iface {}";
997        let docs = vec![doc("/a.php", src)];
998
999        let base_refs = find_references("Base", &docs, false, Some(SymbolKind::Class));
1000        let lines_base: Vec<u32> = base_refs.iter().map(|r| r.range.start.line).collect();
1001        assert!(
1002            lines_base.contains(&3),
1003            "expected extends Base on line 3, got: {:?}",
1004            base_refs
1005        );
1006
1007        let iface_refs = find_references("Iface", &docs, false, Some(SymbolKind::Class));
1008        let lines_iface: Vec<u32> = iface_refs.iter().map(|r| r.range.start.line).collect();
1009        assert!(
1010            lines_iface.contains(&3),
1011            "expected implements Iface on line 3, got: {:?}",
1012            iface_refs
1013        );
1014    }
1015
1016    #[test]
1017    fn class_kind_finds_type_hint() {
1018        // SymbolKind::Class should find `Foo` as a parameter type hint.
1019        let src = "<?php\nclass Foo {}\nfunction take(Foo $x): void {}";
1020        let docs = vec![doc("/a.php", src)];
1021        let refs = find_references("Foo", &docs, false, Some(SymbolKind::Class));
1022        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1023        assert!(
1024            lines.contains(&2),
1025            "expected type hint Foo on line 2, got: {:?}",
1026            refs
1027        );
1028    }
1029
1030    // ── Declaration span precision tests ────────────────────────────────────────
1031
1032    #[test]
1033    fn function_declaration_span_points_to_name_not_keyword() {
1034        // `include_declaration: true` — the declaration ref must start at `greet`,
1035        // not at the `function` keyword.
1036        let src = "<?php\nfunction greet() {}";
1037        let docs = vec![doc("/a.php", src)];
1038        let refs = find_references("greet", &docs, true, None);
1039        assert_eq!(refs.len(), 1, "expected exactly 1 ref (the declaration)");
1040        // "function " is 9 bytes; "greet" starts at byte 15 (after "<?php\n").
1041        // As a position, line 1, character 9.
1042        assert_eq!(
1043            refs[0].range.start.line, 1,
1044            "declaration should be on line 1"
1045        );
1046        assert_eq!(
1047            refs[0].range.start.character, 9,
1048            "declaration should start at the function name, not the 'function' keyword"
1049        );
1050        assert_eq!(
1051            refs[0].range.end.character,
1052            refs[0].range.start.character + utf16_code_units("greet"),
1053            "range should span exactly the function name"
1054        );
1055    }
1056
1057    #[test]
1058    fn class_declaration_span_points_to_name_not_keyword() {
1059        let src = "<?php\nclass MyClass {}";
1060        let docs = vec![doc("/a.php", src)];
1061        let refs = find_references("MyClass", &docs, true, None);
1062        assert_eq!(refs.len(), 1);
1063        // "class " is 6 bytes; "MyClass" starts at character 6.
1064        assert_eq!(refs[0].range.start.line, 1);
1065        assert_eq!(
1066            refs[0].range.start.character, 6,
1067            "declaration should start at 'MyClass', not 'class'"
1068        );
1069    }
1070
1071    #[test]
1072    fn method_declaration_span_points_to_name_not_keyword() {
1073        let src = "<?php\nclass C {\n    public function doThing() {}\n}\n(new C())->doThing();";
1074        let docs = vec![doc("/a.php", src)];
1075        // include_declaration=true so we get the method declaration too.
1076        let refs = find_references("doThing", &docs, true, None);
1077        // Declaration on line 2, call on line 4.
1078        let decl_ref = refs
1079            .iter()
1080            .find(|r| r.range.start.line == 2)
1081            .expect("no declaration ref on line 2");
1082        // "    public function " is 20 chars; "doThing" starts at character 20.
1083        assert_eq!(
1084            decl_ref.range.start.character, 20,
1085            "method declaration should start at the method name, not 'public function'"
1086        );
1087    }
1088
1089    #[test]
1090    fn method_kind_with_include_declaration_does_not_return_free_function() {
1091        // Regression: kind precision must be preserved even when include_declaration=true.
1092        // A free function `get` and a method `get` coexist; searching with
1093        // SymbolKind::Method must NOT return either the free function call or its declaration.
1094        //
1095        // Line 0: <?php
1096        // Line 1: function get() {}          ← free function declaration
1097        // Line 2: get();                     ← free function call
1098        // Line 3: class C { public function get() {} }  ← method declaration
1099        // Line 4: $c->get();                 ← method call
1100        let src =
1101            "<?php\nfunction get() {}\nget();\nclass C { public function get() {} }\n$c->get();";
1102        let docs = vec![doc("/a.php", src)];
1103        let refs = find_references("get", &docs, true, Some(SymbolKind::Method));
1104        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1105        assert!(
1106            lines.contains(&3),
1107            "method declaration (line 3) must be present, got: {:?}",
1108            lines
1109        );
1110        assert!(
1111            lines.contains(&4),
1112            "method call (line 4) must be present, got: {:?}",
1113            lines
1114        );
1115        assert!(
1116            !lines.contains(&1),
1117            "free function declaration (line 1) must not appear when kind=Method, got: {:?}",
1118            lines
1119        );
1120        assert!(
1121            !lines.contains(&2),
1122            "free function call (line 2) must not appear when kind=Method, got: {:?}",
1123            lines
1124        );
1125    }
1126
1127    #[test]
1128    fn function_kind_with_include_declaration_does_not_return_method_call() {
1129        // Symmetric: SymbolKind::Function + include_declaration=true must not return method
1130        // calls or method declarations with the same name.
1131        //
1132        // Line 0: <?php
1133        // Line 1: function add() {}          ← free function declaration
1134        // Line 2: add();                     ← free function call
1135        // Line 3: class C { public function add() {} }  ← method declaration
1136        // Line 4: $c->add();                 ← method call
1137        let src =
1138            "<?php\nfunction add() {}\nadd();\nclass C { public function add() {} }\n$c->add();";
1139        let docs = vec![doc("/a.php", src)];
1140        let refs = find_references("add", &docs, true, Some(SymbolKind::Function));
1141        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1142        assert!(
1143            lines.contains(&1),
1144            "function declaration (line 1) must be present, got: {:?}",
1145            lines
1146        );
1147        assert!(
1148            lines.contains(&2),
1149            "function call (line 2) must be present, got: {:?}",
1150            lines
1151        );
1152        assert!(
1153            !lines.contains(&3),
1154            "method declaration (line 3) must not appear when kind=Function, got: {:?}",
1155            lines
1156        );
1157        assert!(
1158            !lines.contains(&4),
1159            "method call (line 4) must not appear when kind=Function, got: {:?}",
1160            lines
1161        );
1162    }
1163
1164    #[test]
1165    fn interface_method_declaration_included_when_flag_true() {
1166        // Regression: collect_declaration_spans must cover interface members, not only
1167        // classes/traits/enums. When include_declaration=true and kind=Method the
1168        // abstract method stub inside the interface must appear.
1169        //
1170        // Line 0: <?php
1171        // Line 1: interface I {
1172        // Line 2:     public function add(): void;   ← interface method declaration
1173        // Line 3: }
1174        // Line 4: $obj->add();                        ← call site
1175        let src = "<?php\ninterface I {\n    public function add(): void;\n}\n$obj->add();";
1176        let docs = vec![doc("/a.php", src)];
1177
1178        let refs = find_references("add", &docs, true, Some(SymbolKind::Method));
1179        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1180        assert!(
1181            lines.contains(&2),
1182            "interface method declaration (line 2) must appear with include_declaration=true, got: {:?}",
1183            lines
1184        );
1185        assert!(
1186            lines.contains(&4),
1187            "call site (line 4) must appear, got: {:?}",
1188            lines
1189        );
1190
1191        // With include_declaration=false only the call site should remain.
1192        let refs_no_decl = find_references("add", &docs, false, Some(SymbolKind::Method));
1193        let lines_no_decl: Vec<u32> = refs_no_decl.iter().map(|r| r.range.start.line).collect();
1194        assert!(
1195            !lines_no_decl.contains(&2),
1196            "interface method declaration must be excluded when include_declaration=false, got: {:?}",
1197            lines_no_decl
1198        );
1199    }
1200
1201    #[test]
1202    fn declaration_filter_finds_method_inside_same_named_class() {
1203        // Edge case: a class named `get` contains a method also named `get`.
1204        // collect_declaration_spans(kind=None) must find BOTH the class declaration
1205        // and the method declaration so is_declaration_span correctly filters both
1206        // when include_declaration=false.
1207        //
1208        // Line 0: <?php
1209        // Line 1: class get { public function get() {} }
1210        // Line 2: $obj->get();
1211        let src = "<?php\nclass get { public function get() {} }\n$obj->get();";
1212        let docs = vec![doc("/a.php", src)];
1213
1214        // With include_declaration=false, neither the class name nor the method
1215        // declaration should appear — only the call site on line 2.
1216        let refs = find_references("get", &docs, false, None);
1217        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1218        assert!(
1219            !lines.contains(&1),
1220            "declaration line (1) must not appear when include_declaration=false, got: {:?}",
1221            lines
1222        );
1223        assert!(
1224            lines.contains(&2),
1225            "call site (line 2) must be present, got: {:?}",
1226            lines
1227        );
1228
1229        // With include_declaration=true, the class declaration AND method declaration
1230        // are both on line 1; the call site is on line 2.
1231        let refs_with = find_references("get", &docs, true, None);
1232        assert_eq!(
1233            refs_with.len(),
1234            3,
1235            "expected 3 refs (class decl + method decl + call), got: {:?}",
1236            refs_with
1237        );
1238    }
1239
1240    #[test]
1241    fn interface_method_declaration_included_with_kind_none() {
1242        // Regression: the general walker must emit interface method name spans so that
1243        // kind=None + include_declaration=true returns the declaration, matching the
1244        // behaviour already present for class and trait methods.
1245        //
1246        // Line 0: <?php
1247        // Line 1: interface I {
1248        // Line 2:     public function add(): void;   ← declaration
1249        // Line 3: }
1250        // Line 4: $obj->add();                        ← call site
1251        let src = "<?php\ninterface I {\n    public function add(): void;\n}\n$obj->add();";
1252        let docs = vec![doc("/a.php", src)];
1253
1254        let refs = find_references("add", &docs, true, None);
1255        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1256        assert!(
1257            lines.contains(&2),
1258            "interface method declaration (line 2) must appear with kind=None + include_declaration=true, got: {:?}",
1259            lines
1260        );
1261    }
1262
1263    #[test]
1264    fn interface_method_declaration_excluded_with_kind_none_flag_false() {
1265        // Counterpart to interface_method_declaration_included_with_kind_none.
1266        // is_declaration_span calls collect_declaration_spans(kind=None), which after
1267        // the fix now emits interface method name spans. Verify that
1268        // include_declaration=false correctly suppresses the declaration.
1269        //
1270        // Line 0: <?php
1271        // Line 1: interface I {
1272        // Line 2:     public function add(): void;   ← declaration — must be absent
1273        // Line 3: }
1274        // Line 4: $obj->add();                        ← call site — must be present
1275        let src = "<?php\ninterface I {\n    public function add(): void;\n}\n$obj->add();";
1276        let docs = vec![doc("/a.php", src)];
1277
1278        let refs = find_references("add", &docs, false, None);
1279        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1280        assert!(
1281            !lines.contains(&2),
1282            "interface method declaration (line 2) must be excluded with kind=None + include_declaration=false, got: {:?}",
1283            lines
1284        );
1285        assert!(
1286            lines.contains(&4),
1287            "call site (line 4) must be present, got: {:?}",
1288            lines
1289        );
1290    }
1291
1292    #[test]
1293    fn function_kind_does_not_include_interface_method_declaration() {
1294        // kind=Function must not return interface method declarations. The existing
1295        // function_kind_with_include_declaration_does_not_return_method_call test
1296        // covers class methods; this covers the interface case specifically.
1297        //
1298        // Line 0: <?php
1299        // Line 1: function add() {}              ← free function declaration
1300        // Line 2: add();                         ← free function call
1301        // Line 3: interface I {
1302        // Line 4:     public function add(): void;  ← interface method — must be absent
1303        // Line 5: }
1304        let src =
1305            "<?php\nfunction add() {}\nadd();\ninterface I {\n    public function add(): void;\n}";
1306        let docs = vec![doc("/a.php", src)];
1307
1308        let refs = find_references("add", &docs, true, Some(SymbolKind::Function));
1309        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1310        assert!(
1311            lines.contains(&1),
1312            "free function declaration (line 1) must be present, got: {:?}",
1313            lines
1314        );
1315        assert!(
1316            lines.contains(&2),
1317            "free function call (line 2) must be present, got: {:?}",
1318            lines
1319        );
1320        assert!(
1321            !lines.contains(&4),
1322            "interface method declaration (line 4) must not appear with kind=Function, got: {:?}",
1323            lines
1324        );
1325    }
1326
1327    // ── switch / throw / unset / property-default coverage ──────────────────
1328
1329    #[test]
1330    fn finds_function_call_inside_switch_case() {
1331        // Line 1: function tick() {}
1332        // Line 2: switch ($x) { case 1: tick(); break; }
1333        let src = "<?php\nfunction tick() {}\nswitch ($x) {\n    case 1: tick(); break;\n}";
1334        let docs = vec![doc("/a.php", src)];
1335        let lines: Vec<u32> = find_references("tick", &docs, false, Some(SymbolKind::Function))
1336            .iter()
1337            .map(|r| r.range.start.line)
1338            .collect();
1339        assert!(
1340            lines.contains(&3),
1341            "tick() call inside switch case (line 3) must be present, got: {:?}",
1342            lines
1343        );
1344    }
1345
1346    #[test]
1347    fn finds_method_call_inside_switch_case() {
1348        // Line 1: switch ($x) { case 1: $obj->process(); break; }
1349        let src = "<?php\nswitch ($x) {\n    case 1: $obj->process(); break;\n}";
1350        let docs = vec![doc("/a.php", src)];
1351        let lines: Vec<u32> = find_references("process", &docs, false, Some(SymbolKind::Method))
1352            .iter()
1353            .map(|r| r.range.start.line)
1354            .collect();
1355        assert!(
1356            lines.contains(&2),
1357            "process() call inside switch case (line 2) must be present, got: {:?}",
1358            lines
1359        );
1360    }
1361
1362    #[test]
1363    fn finds_function_call_inside_switch_condition() {
1364        // Line 1: function classify() {}
1365        // Line 2: switch (classify()) { default: break; }
1366        let src = "<?php\nfunction classify() {}\nswitch (classify()) { default: break; }";
1367        let docs = vec![doc("/a.php", src)];
1368        let lines: Vec<u32> = find_references("classify", &docs, false, Some(SymbolKind::Function))
1369            .iter()
1370            .map(|r| r.range.start.line)
1371            .collect();
1372        assert!(
1373            lines.contains(&2),
1374            "classify() in switch subject (line 2) must be present, got: {:?}",
1375            lines
1376        );
1377    }
1378
1379    #[test]
1380    fn finds_function_call_inside_throw() {
1381        // Line 1: function makeException() {}
1382        // Line 2: throw makeException();
1383        let src = "<?php\nfunction makeException() {}\nthrow makeException();";
1384        let docs = vec![doc("/a.php", src)];
1385        let lines: Vec<u32> =
1386            find_references("makeException", &docs, false, Some(SymbolKind::Function))
1387                .iter()
1388                .map(|r| r.range.start.line)
1389                .collect();
1390        assert!(
1391            lines.contains(&2),
1392            "makeException() inside throw (line 2) must be present, got: {:?}",
1393            lines
1394        );
1395    }
1396
1397    #[test]
1398    fn finds_method_call_inside_throw() {
1399        // Line 1: throw $factory->create();
1400        let src = "<?php\nthrow $factory->create();";
1401        let docs = vec![doc("/a.php", src)];
1402        let lines: Vec<u32> = find_references("create", &docs, false, Some(SymbolKind::Method))
1403            .iter()
1404            .map(|r| r.range.start.line)
1405            .collect();
1406        assert!(
1407            lines.contains(&1),
1408            "create() inside throw (line 1) must be present, got: {:?}",
1409            lines
1410        );
1411    }
1412
1413    #[test]
1414    fn finds_method_call_inside_unset() {
1415        // Line 1: unset($obj->getProp());
1416        let src = "<?php\nunset($obj->getProp());";
1417        let docs = vec![doc("/a.php", src)];
1418        let lines: Vec<u32> = find_references("getProp", &docs, false, Some(SymbolKind::Method))
1419            .iter()
1420            .map(|r| r.range.start.line)
1421            .collect();
1422        assert!(
1423            lines.contains(&1),
1424            "getProp() inside unset (line 1) must be present, got: {:?}",
1425            lines
1426        );
1427    }
1428
1429    #[test]
1430    fn finds_static_method_call_in_class_property_default() {
1431        // Line 1: class Config {
1432        // Line 2:     public array $data = self::defaults();
1433        // Line 3:     public static function defaults(): array { return []; }
1434        // Line 4: }
1435        let src = "<?php\nclass Config {\n    public array $data = self::defaults();\n    public static function defaults(): array { return []; }\n}";
1436        let docs = vec![doc("/a.php", src)];
1437        let lines: Vec<u32> = find_references("defaults", &docs, false, Some(SymbolKind::Method))
1438            .iter()
1439            .map(|r| r.range.start.line)
1440            .collect();
1441        assert!(
1442            lines.contains(&2),
1443            "defaults() in class property default (line 2) must be present, got: {:?}",
1444            lines
1445        );
1446    }
1447
1448    #[test]
1449    fn finds_static_method_call_in_trait_property_default() {
1450        // Line 1: trait T {
1451        // Line 2:     public int $x = self::init();
1452        // Line 3:     public static function init(): int { return 0; }
1453        // Line 4: }
1454        let src = "<?php\ntrait T {\n    public int $x = self::init();\n    public static function init(): int { return 0; }\n}";
1455        let docs = vec![doc("/a.php", src)];
1456        let lines: Vec<u32> = find_references("init", &docs, false, Some(SymbolKind::Method))
1457            .iter()
1458            .map(|r| r.range.start.line)
1459            .collect();
1460        assert!(
1461            lines.contains(&2),
1462            "init() in trait property default (line 2) must be present, got: {:?}",
1463            lines
1464        );
1465    }
1466
1467    // ── SymbolKind::Property ─────────────────────────────────────────────────
1468
1469    #[test]
1470    fn property_kind_finds_instance_property_access() {
1471        // $obj->status should be found; free function `status()` must not appear.
1472        let src = "<?php\nclass Order {\n    public string $status = '';\n}\nfunction status() {}\n$o->status;\nstatus();";
1473        let docs = vec![doc("/a.php", src)];
1474        let refs = find_references("status", &docs, false, Some(SymbolKind::Property));
1475        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1476        assert!(
1477            lines.contains(&5),
1478            "$o->status access (line 5) must be present, got: {:?}",
1479            lines
1480        );
1481        assert!(
1482            !lines.contains(&6),
1483            "free function call status() (line 6) must not appear with kind=Property, got: {:?}",
1484            lines
1485        );
1486    }
1487
1488    #[test]
1489    fn property_kind_with_include_declaration_finds_decl() {
1490        // include_declaration=true must return the property declaration on the class body.
1491        let src = "<?php\nclass Foo {\n    public int $count = 0;\n}\n$f->count;\n$f->count;";
1492        let docs = vec![doc("/a.php", src)];
1493        let refs_with = find_references("count", &docs, true, Some(SymbolKind::Property));
1494        let lines_with: Vec<u32> = refs_with.iter().map(|r| r.range.start.line).collect();
1495        assert!(
1496            lines_with.contains(&2),
1497            "property declaration (line 2) must be included with include_declaration=true, got: {:?}",
1498            lines_with
1499        );
1500        assert!(
1501            lines_with.contains(&4),
1502            "first access (line 4) must be included, got: {:?}",
1503            lines_with
1504        );
1505        assert!(
1506            lines_with.contains(&5),
1507            "second access (line 5) must be included, got: {:?}",
1508            lines_with
1509        );
1510    }
1511
1512    #[test]
1513    fn property_kind_excludes_declaration_when_flag_false() {
1514        // include_declaration=false must suppress the property declaration but keep accesses.
1515        let src = "<?php\nclass Foo {\n    public int $count = 0;\n}\n$f->count;";
1516        let docs = vec![doc("/a.php", src)];
1517        let refs = find_references("count", &docs, false, Some(SymbolKind::Property));
1518        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1519        assert!(
1520            !lines.contains(&2),
1521            "property declaration (line 2) must be excluded when include_declaration=false, got: {:?}",
1522            lines
1523        );
1524        assert!(
1525            lines.contains(&4),
1526            "access (line 4) must be included, got: {:?}",
1527            lines
1528        );
1529    }
1530
1531    #[test]
1532    fn property_kind_does_not_match_method_with_same_name() {
1533        // $obj->run is a property access; $obj->run() is a method call.
1534        // kind=Property must not return the method call.
1535        let src = "<?php\nclass Task {\n    public bool $run = false;\n    public function run(): void {}\n}\n$t->run;\n$t->run();";
1536        let docs = vec![doc("/a.php", src)];
1537        let refs = find_references("run", &docs, false, Some(SymbolKind::Property));
1538        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1539        assert!(
1540            lines.contains(&5),
1541            "property access $t->run (line 5) must be present, got: {:?}",
1542            lines
1543        );
1544        // The method call $t->run() on line 6 is an ExprKind::MethodCall, not PropertyAccess,
1545        // so the property walker must not emit it.
1546        assert!(
1547            !lines.contains(&6),
1548            "method call $t->run() (line 6) must not appear with kind=Property, got: {:?}",
1549            lines
1550        );
1551    }
1552
1553    // ── Static method call ────────────────────────────────────────────────────
1554
1555    #[test]
1556    fn method_kind_finds_static_method_call() {
1557        // ClassName::method() is a StaticMethodCall; the method walker must capture it.
1558        let src = "<?php\nclass Builder {\n    public static function create(): self { return new self(); }\n}\nBuilder::create();\n$b->create();";
1559        let docs = vec![doc("/a.php", src)];
1560        let refs = find_references("create", &docs, false, Some(SymbolKind::Method));
1561        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1562        assert!(
1563            lines.contains(&4),
1564            "Builder::create() static call (line 4) must be present, got: {:?}",
1565            lines
1566        );
1567        assert!(
1568            lines.contains(&5),
1569            "$b->create() instance call (line 5) must be present, got: {:?}",
1570            lines
1571        );
1572    }
1573
1574    // ── find_references_with_target namespace filtering ───────────────────────
1575
1576    #[test]
1577    fn find_references_with_target_includes_file_whose_namespace_resolves_to_target() {
1578        // file_a.php is in namespace Alpha — calling `Widget` resolves to `Alpha\Widget`.
1579        // find_references_with_target with target `Alpha\Widget` must include it.
1580        let src_a = "<?php\nnamespace Alpha;\nfunction make(): void { $w = new Widget(); }";
1581        let docs = vec![doc("/a.php", src_a)];
1582        let refs = find_references_with_target(
1583            "Widget",
1584            &docs,
1585            false,
1586            Some(SymbolKind::Class),
1587            "Alpha\\Widget",
1588        );
1589        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
1590        assert!(
1591            lines.contains(&2),
1592            "new Widget() in Alpha namespace (line 2) must be included, got: {:?}",
1593            lines
1594        );
1595    }
1596
1597    #[test]
1598    fn find_references_with_target_excludes_file_with_different_namespace() {
1599        // file_b.php is in namespace Beta — `Widget` there resolves to `Beta\Widget`,
1600        // so it must be excluded when the target is `Alpha\Widget`.
1601        let src_a = "<?php\nnamespace Alpha;\n$w = new Widget();";
1602        let src_b = "<?php\nnamespace Beta;\n$w = new Widget();";
1603        let docs = vec![doc("/a.php", src_a), doc("/b.php", src_b)];
1604        let refs = find_references_with_target(
1605            "Widget",
1606            &docs,
1607            false,
1608            Some(SymbolKind::Class),
1609            "Alpha\\Widget",
1610        );
1611        let uris: Vec<&str> = refs.iter().map(|r| r.uri.as_str()).collect();
1612        assert!(
1613            uris.iter().any(|u| u.ends_with("/a.php")),
1614            "Alpha\\Widget in a.php must be included, got: {:?}",
1615            refs
1616        );
1617        assert!(
1618            !uris.iter().any(|u| u.ends_with("/b.php")),
1619            "Beta\\Widget in b.php must be excluded, got: {:?}",
1620            refs
1621        );
1622    }
1623
1624    #[test]
1625    fn find_references_with_target_global_function_fallback() {
1626        // A file with no namespace calls `strlen` — PHP falls back to the global
1627        // namespace, so the target `strlen` (no backslash) must match.
1628        let src = "<?php\n$n = strlen('hello');";
1629        let docs = vec![doc("/a.php", src)];
1630        let refs = find_references_with_target(
1631            "strlen",
1632            &docs,
1633            false,
1634            Some(SymbolKind::Function),
1635            "strlen",
1636        );
1637        assert!(
1638            !refs.is_empty(),
1639            "strlen() in global-namespace file must be included, got: {:?}",
1640            refs
1641        );
1642    }
1643}