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 tower_lsp::lsp_types::{Location, Position, Range, Url};
6
7use crate::ast::str_offset;
8use crate::ast::{ParsedDoc, offset_to_position};
9use crate::walk::{
10    class_refs_in_stmts, function_refs_in_stmts, method_refs_in_stmts, refs_in_stmts,
11    refs_in_stmts_with_use,
12};
13
14/// What kind of symbol the cursor is on.  Used to dispatch to the
15/// appropriate semantic walker so that, e.g., searching for `get` as a
16/// *method* doesn't return free-function calls named `get`.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SymbolKind {
19    /// A free (top-level) function.
20    Function,
21    /// An instance or static method (`->name`, `?->name`, `::name`).
22    Method,
23    /// A class, interface, trait, or enum name used as a type.
24    Class,
25}
26
27/// Find all locations where `word` is referenced across the given documents.
28/// If `include_declaration` is true, also includes the declaration site.
29/// Pass `kind` to restrict results to a particular symbol category; `None`
30/// falls back to the original word-based walker (better some results than none).
31pub fn find_references(
32    word: &str,
33    all_docs: &[(Url, Arc<ParsedDoc>)],
34    include_declaration: bool,
35    kind: Option<SymbolKind>,
36) -> Vec<Location> {
37    find_references_inner(word, all_docs, include_declaration, false, kind)
38}
39
40/// Like `find_references` but also includes `use` statement spans.
41/// Used by rename so that `use Foo;` statements are also updated.
42/// Always uses the general walker (rename must update all occurrence kinds).
43pub fn find_references_with_use(
44    word: &str,
45    all_docs: &[(Url, Arc<ParsedDoc>)],
46    include_declaration: bool,
47) -> Vec<Location> {
48    find_references_inner(word, all_docs, include_declaration, true, None)
49}
50
51fn find_references_inner(
52    word: &str,
53    all_docs: &[(Url, Arc<ParsedDoc>)],
54    include_declaration: bool,
55    include_use: bool,
56    kind: Option<SymbolKind>,
57) -> Vec<Location> {
58    let mut locations = Vec::new();
59
60    for (uri, doc) in all_docs {
61        let source = doc.source();
62        let stmts = &doc.program().stmts;
63        let mut spans = Vec::new();
64
65        if include_use {
66            // Rename path: general walker covers call sites, `use` imports, and declarations.
67            refs_in_stmts_with_use(source, stmts, word, &mut spans);
68            if !include_declaration {
69                let mut decl_spans = Vec::new();
70                collect_declaration_spans(source, stmts, word, None, &mut decl_spans);
71                let decl_set: HashSet<(u32, u32)> =
72                    decl_spans.iter().map(|s| (s.start, s.end)).collect();
73                spans.retain(|span| !decl_set.contains(&(span.start, span.end)));
74            }
75        } else {
76            match kind {
77                Some(SymbolKind::Function) => function_refs_in_stmts(stmts, word, &mut spans),
78                Some(SymbolKind::Method) => method_refs_in_stmts(stmts, word, &mut spans),
79                Some(SymbolKind::Class) => class_refs_in_stmts(stmts, word, &mut spans),
80                // General walker already includes declarations; filter them out if unwanted.
81                None => {
82                    refs_in_stmts(source, stmts, word, &mut spans);
83                    if !include_declaration {
84                        let mut decl_spans = Vec::new();
85                        collect_declaration_spans(source, stmts, word, None, &mut decl_spans);
86                        let decl_set: HashSet<(u32, u32)> =
87                            decl_spans.iter().map(|s| (s.start, s.end)).collect();
88                        spans.retain(|span| !decl_set.contains(&(span.start, span.end)));
89                    }
90                }
91            }
92            // Typed walkers never emit declaration spans, so add them separately when wanted.
93            // Pass `kind` so only declarations of the matching category are appended —
94            // a Method search must not return a free-function declaration with the same name.
95            if include_declaration && kind.is_some() {
96                collect_declaration_spans(source, stmts, word, kind, &mut spans);
97            }
98        }
99
100        for span in spans {
101            let start = offset_to_position(source, span.start);
102            let end = Position {
103                line: start.line,
104                character: start.character
105                    + word.chars().map(|c| c.len_utf16() as u32).sum::<u32>(),
106            };
107            locations.push(Location {
108                uri: uri.clone(),
109                range: Range { start, end },
110            });
111        }
112    }
113
114    locations
115}
116
117/// Build a span covering exactly the declared name (not the keyword before it).
118fn declaration_name_span(source: &str, name: &str) -> Span {
119    let start = str_offset(source, name);
120    Span {
121        start,
122        end: start + name.len() as u32,
123    }
124}
125
126/// Collect every span where `word` is *declared* within `stmts`.
127///
128/// When `kind` is `Some`, only declarations of the matching category are collected:
129/// - `Function` → free (`StmtKind::Function`) declarations only
130/// - `Method`   → method declarations inside classes / traits / enums only
131/// - `Class`    → class / interface / trait / enum type declarations only
132///
133/// `None` collects every declaration kind (used by `is_declaration_span`).
134fn collect_declaration_spans(
135    source: &str,
136    stmts: &[Stmt<'_, '_>],
137    word: &str,
138    kind: Option<SymbolKind>,
139    out: &mut Vec<Span>,
140) {
141    let want_free = matches!(kind, None | Some(SymbolKind::Function));
142    let want_method = matches!(kind, None | Some(SymbolKind::Method));
143    let want_type = matches!(kind, None | Some(SymbolKind::Class));
144
145    for stmt in stmts {
146        match &stmt.kind {
147            StmtKind::Function(f) => {
148                if want_free && f.name == word {
149                    out.push(declaration_name_span(source, f.name));
150                }
151            }
152            StmtKind::Class(c) => {
153                if want_type
154                    && let Some(name) = c.name
155                    && name == word
156                {
157                    out.push(declaration_name_span(source, name));
158                }
159                if want_method {
160                    for member in c.members.iter() {
161                        if let ClassMemberKind::Method(m) = &member.kind
162                            && m.name == word
163                        {
164                            out.push(declaration_name_span(source, m.name));
165                        }
166                    }
167                }
168            }
169            StmtKind::Interface(i) => {
170                if want_type && i.name == word {
171                    out.push(declaration_name_span(source, i.name));
172                }
173                if want_method {
174                    for member in i.members.iter() {
175                        if let ClassMemberKind::Method(m) = &member.kind
176                            && m.name == word
177                        {
178                            out.push(declaration_name_span(source, m.name));
179                        }
180                    }
181                }
182            }
183            StmtKind::Trait(t) => {
184                if want_type && t.name == word {
185                    out.push(declaration_name_span(source, t.name));
186                }
187                if want_method {
188                    for member in t.members.iter() {
189                        if let ClassMemberKind::Method(m) = &member.kind
190                            && m.name == word
191                        {
192                            out.push(declaration_name_span(source, m.name));
193                        }
194                    }
195                }
196            }
197            StmtKind::Enum(e) => {
198                if want_type && e.name == word {
199                    out.push(declaration_name_span(source, e.name));
200                }
201                for member in e.members.iter() {
202                    match &member.kind {
203                        EnumMemberKind::Method(m) if want_method && m.name == word => {
204                            out.push(declaration_name_span(source, m.name));
205                        }
206                        EnumMemberKind::Case(c) if want_type && c.name == word => {
207                            out.push(declaration_name_span(source, c.name));
208                        }
209                        _ => {}
210                    }
211                }
212            }
213            StmtKind::Namespace(ns) => {
214                if let NamespaceBody::Braced(inner) = &ns.body {
215                    collect_declaration_spans(source, inner, word, kind, out);
216                }
217            }
218            _ => {}
219        }
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    fn uri(path: &str) -> Url {
228        Url::parse(&format!("file://{path}")).unwrap()
229    }
230
231    fn doc(path: &str, source: &str) -> (Url, Arc<ParsedDoc>) {
232        (uri(path), Arc::new(ParsedDoc::parse(source.to_string())))
233    }
234
235    #[test]
236    fn finds_function_call_reference() {
237        let src = "<?php\nfunction greet() {}\ngreet();\ngreet();";
238        let docs = vec![doc("/a.php", src)];
239        let refs = find_references("greet", &docs, false, None);
240        assert_eq!(refs.len(), 2, "expected 2 call-site refs, got {:?}", refs);
241    }
242
243    #[test]
244    fn include_declaration_adds_def_site() {
245        let src = "<?php\nfunction greet() {}\ngreet();";
246        let docs = vec![doc("/a.php", src)];
247        let with_decl = find_references("greet", &docs, true, None);
248        let without_decl = find_references("greet", &docs, false, None);
249        // Without declaration: only the call site (line 2)
250        assert_eq!(
251            without_decl.len(),
252            1,
253            "expected 1 call-site ref without declaration"
254        );
255        assert_eq!(
256            without_decl[0].range.start.line, 2,
257            "call site should be on line 2"
258        );
259        // With declaration: 2 refs total (decl on line 1, call on line 2)
260        assert_eq!(
261            with_decl.len(),
262            2,
263            "expected 2 refs with declaration included"
264        );
265    }
266
267    #[test]
268    fn finds_new_expression_reference() {
269        let src = "<?php\nclass Foo {}\n$x = new Foo();";
270        let docs = vec![doc("/a.php", src)];
271        let refs = find_references("Foo", &docs, false, None);
272        assert_eq!(
273            refs.len(),
274            1,
275            "expected exactly 1 reference to Foo in new expr"
276        );
277        assert_eq!(
278            refs[0].range.start.line, 2,
279            "new Foo() reference should be on line 2"
280        );
281    }
282
283    #[test]
284    fn finds_reference_in_nested_function_call() {
285        let src = "<?php\nfunction greet() {}\necho(greet());";
286        let docs = vec![doc("/a.php", src)];
287        let refs = find_references("greet", &docs, false, None);
288        assert_eq!(
289            refs.len(),
290            1,
291            "expected exactly 1 nested function call reference"
292        );
293        assert_eq!(
294            refs[0].range.start.line, 2,
295            "nested greet() call should be on line 2"
296        );
297    }
298
299    #[test]
300    fn finds_references_across_multiple_docs() {
301        let a = doc("/a.php", "<?php\nfunction helper() {}");
302        let b = doc("/b.php", "<?php\nhelper();\nhelper();");
303        let refs = find_references("helper", &[a, b], false, None);
304        assert_eq!(refs.len(), 2, "expected 2 cross-file references");
305        assert!(refs.iter().all(|r| r.uri.path().ends_with("/b.php")));
306    }
307
308    #[test]
309    fn finds_method_call_reference() {
310        let src = "<?php\nclass Calc { public function add() {} }\n$c = new Calc();\n$c->add();";
311        let docs = vec![doc("/a.php", src)];
312        let refs = find_references("add", &docs, false, None);
313        assert_eq!(
314            refs.len(),
315            1,
316            "expected exactly 1 method call reference to 'add'"
317        );
318        assert_eq!(
319            refs[0].range.start.line, 3,
320            "add() call should be on line 3"
321        );
322    }
323
324    #[test]
325    fn finds_reference_inside_if_body() {
326        let src = "<?php\nfunction check() {}\nif (true) { check(); }";
327        let docs = vec![doc("/a.php", src)];
328        let refs = find_references("check", &docs, false, None);
329        assert_eq!(refs.len(), 1, "expected exactly 1 reference inside if body");
330        assert_eq!(
331            refs[0].range.start.line, 2,
332            "check() inside if should be on line 2"
333        );
334    }
335
336    #[test]
337    fn finds_use_statement_reference() {
338        // Renaming MyClass — the `use MyClass;` statement should be in the results
339        // when using find_references_with_use.
340        let src = "<?php\nuse MyClass;\n$x = new MyClass();";
341        let docs = vec![doc("/a.php", src)];
342        let refs = find_references_with_use("MyClass", &docs, false);
343        // Exactly 2 references: the `use MyClass;` on line 1 and `new MyClass()` on line 2.
344        assert_eq!(
345            refs.len(),
346            2,
347            "expected exactly 2 references, got: {:?}",
348            refs
349        );
350        let mut lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
351        lines.sort_unstable();
352        assert_eq!(
353            lines,
354            vec![1, 2],
355            "references should be on lines 1 (use) and 2 (new)"
356        );
357    }
358
359    #[test]
360    fn find_references_returns_correct_lines() {
361        // `helper` is called on lines 1 and 2 (0-based); check exact line numbers.
362        let src = "<?php\nhelper();\nhelper();\nfunction helper() {}";
363        let docs = vec![doc("/a.php", src)];
364        let refs = find_references("helper", &docs, false, None);
365        assert_eq!(refs.len(), 2, "expected exactly 2 call-site references");
366        let mut lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
367        lines.sort_unstable();
368        assert_eq!(lines, vec![1, 2], "references should be on lines 1 and 2");
369    }
370
371    #[test]
372    fn declaration_excluded_when_flag_false() {
373        // When include_declaration=false the declaration line must not appear.
374        let src = "<?php\nfunction doWork() {}\ndoWork();\ndoWork();";
375        let docs = vec![doc("/a.php", src)];
376        let refs = find_references("doWork", &docs, false, None);
377        // Declaration is on line 1; call sites are on lines 2 and 3.
378        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
379        assert!(
380            !lines.contains(&1),
381            "declaration line (1) must not appear when include_declaration=false, got: {:?}",
382            lines
383        );
384        assert_eq!(refs.len(), 2, "expected 2 call-site references only");
385    }
386
387    #[test]
388    fn partial_match_not_included() {
389        // Searching for references to `greet` should NOT include occurrences of `greeting`.
390        let src = "<?php\nfunction greet() {}\nfunction greeting() {}\ngreet();\ngreeting();";
391        let docs = vec![doc("/a.php", src)];
392        let refs = find_references("greet", &docs, false, None);
393        // Only `greet()` call site should be included, not `greeting()`.
394        for r in &refs {
395            // Each reference range should span exactly the length of "greet" (5 chars),
396            // not longer (which would indicate "greeting" was matched).
397            let span_len = r.range.end.character - r.range.start.character;
398            assert_eq!(
399                span_len, 5,
400                "reference span length should equal len('greet')=5, got {} at {:?}",
401                span_len, r
402            );
403        }
404        // There should be exactly 1 call-site reference (the greet() call, not greeting()).
405        assert_eq!(
406            refs.len(),
407            1,
408            "expected exactly 1 reference to 'greet' (not 'greeting'), got: {:?}",
409            refs
410        );
411    }
412
413    #[test]
414    fn finds_reference_in_class_property_default() {
415        // A class constant used as a property default value should be found by find_references.
416        let src = "<?php\nclass Foo {\n    public string $status = Status::ACTIVE;\n}";
417        let docs = vec![doc("/a.php", src)];
418        let refs = find_references("Status", &docs, false, None);
419        assert_eq!(
420            refs.len(),
421            1,
422            "expected exactly 1 reference to Status in property default, got: {:?}",
423            refs
424        );
425        assert_eq!(refs[0].range.start.line, 2, "reference should be on line 2");
426    }
427
428    #[test]
429    fn finds_reference_inside_enum_method_body() {
430        // A function call inside an enum method body should be found by find_references.
431        let src = "<?php\nfunction helper() {}\nenum Status {\n    public function label(): string { return helper(); }\n}";
432        let docs = vec![doc("/a.php", src)];
433        let refs = find_references("helper", &docs, false, None);
434        assert_eq!(
435            refs.len(),
436            1,
437            "expected exactly 1 reference to helper() inside enum method, got: {:?}",
438            refs
439        );
440        assert_eq!(refs[0].range.start.line, 3, "reference should be on line 3");
441    }
442
443    #[test]
444    fn finds_reference_in_for_init_and_update() {
445        // Function calls in `for` init and update expressions should be found.
446        let src = "<?php\nfunction tick() {}\nfor (tick(); $i < 10; tick()) {}";
447        let docs = vec![doc("/a.php", src)];
448        let refs = find_references("tick", &docs, false, None);
449        assert_eq!(
450            refs.len(),
451            2,
452            "expected exactly 2 references to tick() (init + update), got: {:?}",
453            refs
454        );
455        // Both are on line 2.
456        assert!(refs.iter().all(|r| r.range.start.line == 2));
457    }
458
459    // ── Semantic (kind-aware) tests ───────────────────────────────────────────
460
461    #[test]
462    fn function_kind_skips_method_call_with_same_name() {
463        // When looking for the free function `get`, method calls `$obj->get()` must be excluded.
464        let src = "<?php\nfunction get() {}\nget();\n$obj->get();";
465        let docs = vec![doc("/a.php", src)];
466        let refs = find_references("get", &docs, false, Some(SymbolKind::Function));
467        // Only the free call `get()` on line 2 should appear; not the method call on line 3.
468        assert_eq!(
469            refs.len(),
470            1,
471            "expected 1 free-function ref, got: {:?}",
472            refs
473        );
474        assert_eq!(refs[0].range.start.line, 2);
475    }
476
477    #[test]
478    fn method_kind_skips_free_function_call_with_same_name() {
479        // When looking for the method `add`, the free function call `add()` must be excluded.
480        let src = "<?php\nfunction add() {}\nadd();\n$calc->add();";
481        let docs = vec![doc("/a.php", src)];
482        let refs = find_references("add", &docs, false, Some(SymbolKind::Method));
483        // Only the method call on line 3 should appear.
484        assert_eq!(refs.len(), 1, "expected 1 method ref, got: {:?}", refs);
485        assert_eq!(refs[0].range.start.line, 3);
486    }
487
488    #[test]
489    fn class_kind_finds_new_expression() {
490        // SymbolKind::Class should find `new Foo()` but not a free function call `Foo()`.
491        let src = "<?php\nclass Foo {}\n$x = new Foo();\nFoo();";
492        let docs = vec![doc("/a.php", src)];
493        let refs = find_references("Foo", &docs, false, Some(SymbolKind::Class));
494        // `new Foo()` on line 2 yes; `Foo()` on line 3 should NOT appear as a class ref.
495        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
496        assert!(
497            lines.contains(&2),
498            "expected new Foo() on line 2, got: {:?}",
499            refs
500        );
501        assert!(
502            !lines.contains(&3),
503            "free call Foo() should not appear as class ref, got: {:?}",
504            refs
505        );
506    }
507
508    #[test]
509    fn class_kind_finds_extends_and_implements() {
510        let src = "<?php\nclass Base {}\ninterface Iface {}\nclass Child extends Base implements Iface {}";
511        let docs = vec![doc("/a.php", src)];
512
513        let base_refs = find_references("Base", &docs, false, Some(SymbolKind::Class));
514        let lines_base: Vec<u32> = base_refs.iter().map(|r| r.range.start.line).collect();
515        assert!(
516            lines_base.contains(&3),
517            "expected extends Base on line 3, got: {:?}",
518            base_refs
519        );
520
521        let iface_refs = find_references("Iface", &docs, false, Some(SymbolKind::Class));
522        let lines_iface: Vec<u32> = iface_refs.iter().map(|r| r.range.start.line).collect();
523        assert!(
524            lines_iface.contains(&3),
525            "expected implements Iface on line 3, got: {:?}",
526            iface_refs
527        );
528    }
529
530    #[test]
531    fn class_kind_finds_type_hint() {
532        // SymbolKind::Class should find `Foo` as a parameter type hint.
533        let src = "<?php\nclass Foo {}\nfunction take(Foo $x): void {}";
534        let docs = vec![doc("/a.php", src)];
535        let refs = find_references("Foo", &docs, false, Some(SymbolKind::Class));
536        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
537        assert!(
538            lines.contains(&2),
539            "expected type hint Foo on line 2, got: {:?}",
540            refs
541        );
542    }
543
544    // ── Declaration span precision tests ────────────────────────────────────────
545
546    #[test]
547    fn function_declaration_span_points_to_name_not_keyword() {
548        // `include_declaration: true` — the declaration ref must start at `greet`,
549        // not at the `function` keyword.
550        let src = "<?php\nfunction greet() {}";
551        let docs = vec![doc("/a.php", src)];
552        let refs = find_references("greet", &docs, true, None);
553        assert_eq!(refs.len(), 1, "expected exactly 1 ref (the declaration)");
554        // "function " is 9 bytes; "greet" starts at byte 15 (after "<?php\n").
555        // As a position, line 1, character 9.
556        assert_eq!(
557            refs[0].range.start.line, 1,
558            "declaration should be on line 1"
559        );
560        assert_eq!(
561            refs[0].range.start.character, 9,
562            "declaration should start at the function name, not the 'function' keyword"
563        );
564        assert_eq!(
565            refs[0].range.end.character,
566            refs[0].range.start.character
567                + "greet".chars().map(|c| c.len_utf16() as u32).sum::<u32>(),
568            "range should span exactly the function name"
569        );
570    }
571
572    #[test]
573    fn class_declaration_span_points_to_name_not_keyword() {
574        let src = "<?php\nclass MyClass {}";
575        let docs = vec![doc("/a.php", src)];
576        let refs = find_references("MyClass", &docs, true, None);
577        assert_eq!(refs.len(), 1);
578        // "class " is 6 bytes; "MyClass" starts at character 6.
579        assert_eq!(refs[0].range.start.line, 1);
580        assert_eq!(
581            refs[0].range.start.character, 6,
582            "declaration should start at 'MyClass', not 'class'"
583        );
584    }
585
586    #[test]
587    fn method_declaration_span_points_to_name_not_keyword() {
588        let src = "<?php\nclass C {\n    public function doThing() {}\n}\n(new C())->doThing();";
589        let docs = vec![doc("/a.php", src)];
590        // include_declaration=true so we get the method declaration too.
591        let refs = find_references("doThing", &docs, true, None);
592        // Declaration on line 2, call on line 4.
593        let decl_ref = refs
594            .iter()
595            .find(|r| r.range.start.line == 2)
596            .expect("no declaration ref on line 2");
597        // "    public function " is 20 chars; "doThing" starts at character 20.
598        assert_eq!(
599            decl_ref.range.start.character, 20,
600            "method declaration should start at the method name, not 'public function'"
601        );
602    }
603
604    #[test]
605    fn method_kind_with_include_declaration_does_not_return_free_function() {
606        // Regression: kind precision must be preserved even when include_declaration=true.
607        // A free function `get` and a method `get` coexist; searching with
608        // SymbolKind::Method must NOT return either the free function call or its declaration.
609        //
610        // Line 0: <?php
611        // Line 1: function get() {}          ← free function declaration
612        // Line 2: get();                     ← free function call
613        // Line 3: class C { public function get() {} }  ← method declaration
614        // Line 4: $c->get();                 ← method call
615        let src =
616            "<?php\nfunction get() {}\nget();\nclass C { public function get() {} }\n$c->get();";
617        let docs = vec![doc("/a.php", src)];
618        let refs = find_references("get", &docs, true, Some(SymbolKind::Method));
619        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
620        assert!(
621            lines.contains(&3),
622            "method declaration (line 3) must be present, got: {:?}",
623            lines
624        );
625        assert!(
626            lines.contains(&4),
627            "method call (line 4) must be present, got: {:?}",
628            lines
629        );
630        assert!(
631            !lines.contains(&1),
632            "free function declaration (line 1) must not appear when kind=Method, got: {:?}",
633            lines
634        );
635        assert!(
636            !lines.contains(&2),
637            "free function call (line 2) must not appear when kind=Method, got: {:?}",
638            lines
639        );
640    }
641
642    #[test]
643    fn function_kind_with_include_declaration_does_not_return_method_call() {
644        // Symmetric: SymbolKind::Function + include_declaration=true must not return method
645        // calls or method declarations with the same name.
646        //
647        // Line 0: <?php
648        // Line 1: function add() {}          ← free function declaration
649        // Line 2: add();                     ← free function call
650        // Line 3: class C { public function add() {} }  ← method declaration
651        // Line 4: $c->add();                 ← method call
652        let src =
653            "<?php\nfunction add() {}\nadd();\nclass C { public function add() {} }\n$c->add();";
654        let docs = vec![doc("/a.php", src)];
655        let refs = find_references("add", &docs, true, Some(SymbolKind::Function));
656        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
657        assert!(
658            lines.contains(&1),
659            "function declaration (line 1) must be present, got: {:?}",
660            lines
661        );
662        assert!(
663            lines.contains(&2),
664            "function call (line 2) must be present, got: {:?}",
665            lines
666        );
667        assert!(
668            !lines.contains(&3),
669            "method declaration (line 3) must not appear when kind=Function, got: {:?}",
670            lines
671        );
672        assert!(
673            !lines.contains(&4),
674            "method call (line 4) must not appear when kind=Function, got: {:?}",
675            lines
676        );
677    }
678
679    #[test]
680    fn interface_method_declaration_included_when_flag_true() {
681        // Regression: collect_declaration_spans must cover interface members, not only
682        // classes/traits/enums. When include_declaration=true and kind=Method the
683        // abstract method stub inside the interface must appear.
684        //
685        // Line 0: <?php
686        // Line 1: interface I {
687        // Line 2:     public function add(): void;   ← interface method declaration
688        // Line 3: }
689        // Line 4: $obj->add();                        ← call site
690        let src = "<?php\ninterface I {\n    public function add(): void;\n}\n$obj->add();";
691        let docs = vec![doc("/a.php", src)];
692
693        let refs = find_references("add", &docs, true, Some(SymbolKind::Method));
694        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
695        assert!(
696            lines.contains(&2),
697            "interface method declaration (line 2) must appear with include_declaration=true, got: {:?}",
698            lines
699        );
700        assert!(
701            lines.contains(&4),
702            "call site (line 4) must appear, got: {:?}",
703            lines
704        );
705
706        // With include_declaration=false only the call site should remain.
707        let refs_no_decl = find_references("add", &docs, false, Some(SymbolKind::Method));
708        let lines_no_decl: Vec<u32> = refs_no_decl.iter().map(|r| r.range.start.line).collect();
709        assert!(
710            !lines_no_decl.contains(&2),
711            "interface method declaration must be excluded when include_declaration=false, got: {:?}",
712            lines_no_decl
713        );
714    }
715
716    #[test]
717    fn declaration_filter_finds_method_inside_same_named_class() {
718        // Edge case: a class named `get` contains a method also named `get`.
719        // collect_declaration_spans(kind=None) must find BOTH the class declaration
720        // and the method declaration so is_declaration_span correctly filters both
721        // when include_declaration=false.
722        //
723        // Line 0: <?php
724        // Line 1: class get { public function get() {} }
725        // Line 2: $obj->get();
726        let src = "<?php\nclass get { public function get() {} }\n$obj->get();";
727        let docs = vec![doc("/a.php", src)];
728
729        // With include_declaration=false, neither the class name nor the method
730        // declaration should appear — only the call site on line 2.
731        let refs = find_references("get", &docs, false, None);
732        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
733        assert!(
734            !lines.contains(&1),
735            "declaration line (1) must not appear when include_declaration=false, got: {:?}",
736            lines
737        );
738        assert!(
739            lines.contains(&2),
740            "call site (line 2) must be present, got: {:?}",
741            lines
742        );
743
744        // With include_declaration=true, the class declaration AND method declaration
745        // are both on line 1; the call site is on line 2.
746        let refs_with = find_references("get", &docs, true, None);
747        assert_eq!(
748            refs_with.len(),
749            3,
750            "expected 3 refs (class decl + method decl + call), got: {:?}",
751            refs_with
752        );
753    }
754
755    #[test]
756    fn interface_method_declaration_included_with_kind_none() {
757        // Regression: the general walker must emit interface method name spans so that
758        // kind=None + include_declaration=true returns the declaration, matching the
759        // behaviour already present for class and trait methods.
760        //
761        // Line 0: <?php
762        // Line 1: interface I {
763        // Line 2:     public function add(): void;   ← declaration
764        // Line 3: }
765        // Line 4: $obj->add();                        ← call site
766        let src = "<?php\ninterface I {\n    public function add(): void;\n}\n$obj->add();";
767        let docs = vec![doc("/a.php", src)];
768
769        let refs = find_references("add", &docs, true, None);
770        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
771        assert!(
772            lines.contains(&2),
773            "interface method declaration (line 2) must appear with kind=None + include_declaration=true, got: {:?}",
774            lines
775        );
776    }
777
778    #[test]
779    fn interface_method_declaration_excluded_with_kind_none_flag_false() {
780        // Counterpart to interface_method_declaration_included_with_kind_none.
781        // is_declaration_span calls collect_declaration_spans(kind=None), which after
782        // the fix now emits interface method name spans. Verify that
783        // include_declaration=false correctly suppresses the declaration.
784        //
785        // Line 0: <?php
786        // Line 1: interface I {
787        // Line 2:     public function add(): void;   ← declaration — must be absent
788        // Line 3: }
789        // Line 4: $obj->add();                        ← call site — must be present
790        let src = "<?php\ninterface I {\n    public function add(): void;\n}\n$obj->add();";
791        let docs = vec![doc("/a.php", src)];
792
793        let refs = find_references("add", &docs, false, None);
794        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
795        assert!(
796            !lines.contains(&2),
797            "interface method declaration (line 2) must be excluded with kind=None + include_declaration=false, got: {:?}",
798            lines
799        );
800        assert!(
801            lines.contains(&4),
802            "call site (line 4) must be present, got: {:?}",
803            lines
804        );
805    }
806
807    #[test]
808    fn function_kind_does_not_include_interface_method_declaration() {
809        // kind=Function must not return interface method declarations. The existing
810        // function_kind_with_include_declaration_does_not_return_method_call test
811        // covers class methods; this covers the interface case specifically.
812        //
813        // Line 0: <?php
814        // Line 1: function add() {}              ← free function declaration
815        // Line 2: add();                         ← free function call
816        // Line 3: interface I {
817        // Line 4:     public function add(): void;  ← interface method — must be absent
818        // Line 5: }
819        let src =
820            "<?php\nfunction add() {}\nadd();\ninterface I {\n    public function add(): void;\n}";
821        let docs = vec![doc("/a.php", src)];
822
823        let refs = find_references("add", &docs, true, Some(SymbolKind::Function));
824        let lines: Vec<u32> = refs.iter().map(|r| r.range.start.line).collect();
825        assert!(
826            lines.contains(&1),
827            "free function declaration (line 1) must be present, got: {:?}",
828            lines
829        );
830        assert!(
831            lines.contains(&2),
832            "free function call (line 2) must be present, got: {:?}",
833            lines
834        );
835        assert!(
836            !lines.contains(&4),
837            "interface method declaration (line 4) must not appear with kind=Function, got: {:?}",
838            lines
839        );
840    }
841}