Skip to main content

php_lsp/
walk.rs

1/// AST walkers — collect all spans where a name, variable, property, function,
2/// method, or class reference appears in the given statements.
3use std::ops::ControlFlow;
4
5use php_ast::{
6    CatchClause, ClassMember, ClassMemberKind, EnumMember, EnumMemberKind, Expr, ExprKind, Name,
7    NamespaceBody, Span, Stmt, StmtKind, TypeHint, TypeHintKind,
8    visitor::{
9        Visitor, walk_catch_clause, walk_class_member, walk_enum_member, walk_expr, walk_stmt,
10        walk_type_hint,
11    },
12};
13
14use crate::ast::str_offset;
15
16// ── Public entry points ───────────────────────────────────────────────────────
17
18pub fn refs_in_stmts(source: &str, stmts: &[Stmt<'_, '_>], word: &str, out: &mut Vec<Span>) {
19    let mut v = AllRefsVisitor {
20        source,
21        word,
22        out: Vec::new(),
23    };
24    for stmt in stmts {
25        let _ = v.visit_stmt(stmt);
26    }
27    out.append(&mut v.out);
28}
29
30/// Like `refs_in_stmts`, but also matches spans inside `use` statements.
31/// Needed so that renaming a class also renames its `use` import.
32pub fn refs_in_stmts_with_use(
33    source: &str,
34    stmts: &[Stmt<'_, '_>],
35    word: &str,
36    out: &mut Vec<Span>,
37) {
38    refs_in_stmts(source, stmts, word, out);
39    use_refs(stmts, word, out);
40}
41
42fn use_refs(stmts: &[Stmt<'_, '_>], word: &str, out: &mut Vec<Span>) {
43    for stmt in stmts {
44        match &stmt.kind {
45            StmtKind::Use(u) => {
46                for use_item in u.uses.iter() {
47                    let fqn = use_item.name.to_string_repr().into_owned();
48                    let alias_match = use_item.alias.map(|a| a == word).unwrap_or(false);
49                    let last_seg = fqn.rsplit('\\').next().unwrap_or(&fqn);
50                    if alias_match || last_seg == word {
51                        let name_span = use_item.name.span();
52                        let offset = (fqn.len() - last_seg.len()) as u32;
53                        let syn_span = Span {
54                            start: name_span.start + offset,
55                            end: name_span.start + fqn.len() as u32,
56                        };
57                        out.push(syn_span);
58                    }
59                }
60            }
61            StmtKind::Namespace(ns) => {
62                if let NamespaceBody::Braced(inner) = &ns.body {
63                    use_refs(inner, word, out);
64                }
65            }
66            _ => {}
67        }
68    }
69}
70
71// ── AllRefsVisitor ────────────────────────────────────────────────────────────
72
73struct AllRefsVisitor<'a> {
74    source: &'a str,
75    word: &'a str,
76    out: Vec<Span>,
77}
78
79impl AllRefsVisitor<'_> {
80    fn push_name_str(&mut self, name: &str) {
81        if name == self.word {
82            let start = str_offset(self.source, name);
83            self.out.push(Span {
84                start,
85                end: start + name.len() as u32,
86            });
87        }
88    }
89}
90
91impl<'arena, 'src> Visitor<'arena, 'src> for AllRefsVisitor<'_> {
92    fn visit_stmt(&mut self, stmt: &Stmt<'arena, 'src>) -> ControlFlow<()> {
93        match &stmt.kind {
94            StmtKind::Function(f) => self.push_name_str(f.name),
95            StmtKind::Class(c) => {
96                if let Some(name) = c.name {
97                    self.push_name_str(name);
98                }
99            }
100            StmtKind::Interface(i) => self.push_name_str(i.name),
101            StmtKind::Trait(t) => self.push_name_str(t.name),
102            StmtKind::Enum(e) => self.push_name_str(e.name),
103            _ => {}
104        }
105        walk_stmt(self, stmt)
106    }
107
108    fn visit_class_member(&mut self, member: &ClassMember<'arena, 'src>) -> ControlFlow<()> {
109        if let ClassMemberKind::Method(m) = &member.kind {
110            self.push_name_str(m.name);
111        }
112        walk_class_member(self, member)
113    }
114
115    fn visit_enum_member(&mut self, member: &EnumMember<'arena, 'src>) -> ControlFlow<()> {
116        if let EnumMemberKind::Method(m) = &member.kind {
117            self.push_name_str(m.name);
118        }
119        walk_enum_member(self, member)
120    }
121
122    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
123        if let ExprKind::Identifier(name) = &expr.kind
124            && name.as_str() == self.word
125        {
126            self.out.push(expr.span);
127        }
128        walk_expr(self, expr)
129    }
130}
131
132// ── Variable rename helpers ───────────────────────────────────────────────────
133
134/// Collect all spans where `$var_name` (the variable name WITHOUT `$`) appears as an
135/// `ExprKind::Variable` within `stmts`. Stops at nested function/closure/arrow-function
136/// scope boundaries so that `$x` in an inner function is not conflated with `$x` in
137/// the outer function.
138pub fn var_refs_in_stmts(stmts: &[Stmt<'_, '_>], var_name: &str, out: &mut Vec<Span>) {
139    let mut v = VarRefsVisitor {
140        var_name,
141        out: Vec::new(),
142    };
143    for stmt in stmts {
144        let _ = v.visit_stmt(stmt);
145    }
146    out.append(&mut v.out);
147}
148
149struct VarRefsVisitor<'a> {
150    var_name: &'a str,
151    out: Vec<Span>,
152}
153
154impl<'arena, 'src> Visitor<'arena, 'src> for VarRefsVisitor<'_> {
155    fn visit_stmt(&mut self, stmt: &Stmt<'arena, 'src>) -> ControlFlow<()> {
156        // Stop at scope-defining statement boundaries.
157        match &stmt.kind {
158            StmtKind::Function(_)
159            | StmtKind::Class(_)
160            | StmtKind::Trait(_)
161            | StmtKind::Enum(_)
162            | StmtKind::Interface(_) => ControlFlow::Continue(()),
163            _ => walk_stmt(self, stmt),
164        }
165    }
166
167    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
168        match &expr.kind {
169            // Collect matching variable references.
170            ExprKind::Variable(name) => {
171                if name.as_str() == self.var_name {
172                    self.out.push(expr.span);
173                }
174                ControlFlow::Continue(())
175            }
176            // Stop at expression-level scope boundaries.
177            ExprKind::Closure(_) | ExprKind::ArrowFunction(_) => ControlFlow::Continue(()),
178            _ => walk_expr(self, expr),
179        }
180    }
181}
182
183/// Collect all `$var_name` spans within the innermost function/method scope
184/// that contains `byte_off`. If `byte_off` is not inside any function, collects
185/// from the top-level stmts (respecting scope boundaries). Also collects the
186/// parameter declaration span when the variable is a parameter of the scope.
187pub fn collect_var_refs_in_scope(
188    stmts: &[Stmt<'_, '_>],
189    var_name: &str,
190    byte_off: usize,
191    out: &mut Vec<Span>,
192) {
193    for stmt in stmts {
194        if collect_in_fn_at(stmt, var_name, byte_off, out) {
195            return;
196        }
197    }
198    // Not inside any function — collect top-level
199    var_refs_in_stmts(stmts, var_name, out);
200}
201
202/// Returns `true` if `stmt` is (or contains) the function/method that owns `byte_off`
203/// and has populated `out` with variable + param spans for `var_name`.
204fn collect_in_fn_at(
205    stmt: &Stmt<'_, '_>,
206    var_name: &str,
207    byte_off: usize,
208    out: &mut Vec<Span>,
209) -> bool {
210    match &stmt.kind {
211        StmtKind::Function(f) => {
212            if byte_off < stmt.span.start as usize || byte_off >= stmt.span.end as usize {
213                return false;
214            }
215            // Check nested functions first.
216            for inner in f.body.iter() {
217                if collect_in_fn_at(inner, var_name, byte_off, out) {
218                    return true;
219                }
220            }
221            // This is the enclosing function — collect param + body refs.
222            for p in f.params.iter() {
223                if p.name == var_name {
224                    out.push(p.span);
225                }
226            }
227            var_refs_in_stmts(&f.body, var_name, out);
228            true
229        }
230        StmtKind::Class(c) => {
231            for member in c.members.iter() {
232                if let ClassMemberKind::Method(m) = &member.kind {
233                    if byte_off < member.span.start as usize || byte_off >= member.span.end as usize
234                    {
235                        continue;
236                    }
237                    if let Some(body) = &m.body {
238                        for inner in body.iter() {
239                            if collect_in_fn_at(inner, var_name, byte_off, out) {
240                                return true;
241                            }
242                        }
243                        for p in m.params.iter() {
244                            if p.name == var_name {
245                                out.push(p.span);
246                            }
247                        }
248                        var_refs_in_stmts(body, var_name, out);
249                    }
250                    return true;
251                }
252            }
253            false
254        }
255        StmtKind::Trait(t) => {
256            for member in t.members.iter() {
257                if let ClassMemberKind::Method(m) = &member.kind {
258                    if byte_off < member.span.start as usize || byte_off >= member.span.end as usize
259                    {
260                        continue;
261                    }
262                    if let Some(body) = &m.body {
263                        for inner in body.iter() {
264                            if collect_in_fn_at(inner, var_name, byte_off, out) {
265                                return true;
266                            }
267                        }
268                        for p in m.params.iter() {
269                            if p.name == var_name {
270                                out.push(p.span);
271                            }
272                        }
273                        var_refs_in_stmts(body, var_name, out);
274                    }
275                    return true;
276                }
277            }
278            false
279        }
280        StmtKind::Enum(e) => {
281            for member in e.members.iter() {
282                if let EnumMemberKind::Method(m) = &member.kind {
283                    if byte_off < member.span.start as usize || byte_off >= member.span.end as usize
284                    {
285                        continue;
286                    }
287                    if let Some(body) = &m.body {
288                        for inner in body.iter() {
289                            if collect_in_fn_at(inner, var_name, byte_off, out) {
290                                return true;
291                            }
292                        }
293                        for p in m.params.iter() {
294                            if p.name == var_name {
295                                out.push(p.span);
296                            }
297                        }
298                        var_refs_in_stmts(body, var_name, out);
299                    }
300                    return true;
301                }
302            }
303            false
304        }
305        StmtKind::Interface(i) => {
306            for member in i.members.iter() {
307                if let ClassMemberKind::Method(m) = &member.kind {
308                    if byte_off < member.span.start as usize || byte_off >= member.span.end as usize
309                    {
310                        continue;
311                    }
312                    if let Some(body) = &m.body {
313                        for inner in body.iter() {
314                            if collect_in_fn_at(inner, var_name, byte_off, out) {
315                                return true;
316                            }
317                        }
318                        for p in m.params.iter() {
319                            if p.name == var_name {
320                                out.push(p.span);
321                            }
322                        }
323                        var_refs_in_stmts(body, var_name, out);
324                    }
325                    return true;
326                }
327            }
328            false
329        }
330        StmtKind::Namespace(ns) => {
331            if let NamespaceBody::Braced(inner) = &ns.body {
332                for s in inner.iter() {
333                    if collect_in_fn_at(s, var_name, byte_off, out) {
334                        return true;
335                    }
336                }
337            }
338            false
339        }
340        _ => false,
341    }
342}
343
344// ── Property rename helpers ───────────────────────────────────────────────────
345
346/// Collect all spans where `prop_name` is accessed (`->prop`, `?->prop`) or
347/// declared as a class/trait property, across all statements.
348pub fn property_refs_in_stmts(
349    source: &str,
350    stmts: &[Stmt<'_, '_>],
351    prop_name: &str,
352    out: &mut Vec<Span>,
353) {
354    let mut v = PropertyRefsVisitor {
355        source,
356        prop_name,
357        out: Vec::new(),
358    };
359    for stmt in stmts {
360        let _ = v.visit_stmt(stmt);
361    }
362    out.append(&mut v.out);
363}
364
365struct PropertyRefsVisitor<'a> {
366    source: &'a str,
367    prop_name: &'a str,
368    out: Vec<Span>,
369}
370
371impl<'arena, 'src> Visitor<'arena, 'src> for PropertyRefsVisitor<'_> {
372    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
373        match &expr.kind {
374            ExprKind::PropertyAccess(p) | ExprKind::NullsafePropertyAccess(p) => {
375                let span = p.property.span;
376                let name_in_src = self
377                    .source
378                    .get(span.start as usize..span.end as usize)
379                    .unwrap_or("");
380                if name_in_src == self.prop_name {
381                    self.out.push(span);
382                }
383            }
384            _ => {}
385        }
386        walk_expr(self, expr)
387    }
388
389    fn visit_class_member(&mut self, member: &ClassMember<'arena, 'src>) -> ControlFlow<()> {
390        if let ClassMemberKind::Property(p) = &member.kind
391            && p.name == self.prop_name
392        {
393            let offset = str_offset(self.source, p.name);
394            self.out.push(Span {
395                start: offset,
396                end: offset + p.name.len() as u32,
397            });
398        }
399        walk_class_member(self, member)
400    }
401}
402
403// ── Function-reference walker ─────────────────────────────────────────────────
404
405/// Collect spans where `name` is called as a free function (not a method).
406/// Only matches `name(...)` calls where the callee is a bare identifier, not
407/// `$obj->name()` or `Class::name()`.
408pub fn function_refs_in_stmts(stmts: &[Stmt<'_, '_>], name: &str, out: &mut Vec<Span>) {
409    let mut v = FunctionRefsVisitor {
410        name,
411        out: Vec::new(),
412    };
413    for stmt in stmts {
414        let _ = v.visit_stmt(stmt);
415    }
416    out.append(&mut v.out);
417}
418
419struct FunctionRefsVisitor<'a> {
420    name: &'a str,
421    out: Vec<Span>,
422}
423
424impl<'arena, 'src> Visitor<'arena, 'src> for FunctionRefsVisitor<'_> {
425    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
426        if let ExprKind::FunctionCall(f) = &expr.kind
427            && let ExprKind::Identifier(id) = &f.name.kind
428            && id.as_str() == self.name
429        {
430            self.out.push(f.name.span);
431        }
432        walk_expr(self, expr)
433    }
434}
435
436// ── Method-reference walker ───────────────────────────────────────────────────
437
438/// Collect spans where `name` is used as a method: `->name()`, `?->name()`, `::name()`.
439/// Does NOT match free function calls or class-name identifiers.
440pub fn method_refs_in_stmts(stmts: &[Stmt<'_, '_>], name: &str, out: &mut Vec<Span>) {
441    let mut v = MethodRefsVisitor {
442        name,
443        out: Vec::new(),
444    };
445    for stmt in stmts {
446        let _ = v.visit_stmt(stmt);
447    }
448    out.append(&mut v.out);
449}
450
451struct MethodRefsVisitor<'a> {
452    name: &'a str,
453    out: Vec<Span>,
454}
455
456impl<'arena, 'src> Visitor<'arena, 'src> for MethodRefsVisitor<'_> {
457    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
458        match &expr.kind {
459            ExprKind::MethodCall(m) | ExprKind::NullsafeMethodCall(m) => {
460                if let ExprKind::Identifier(id) = &m.method.kind
461                    && id.as_str() == self.name
462                {
463                    self.out.push(m.method.span);
464                }
465            }
466            ExprKind::StaticMethodCall(s) => {
467                if s.method.name_str() == Some(self.name) {
468                    self.out.push(s.method.span);
469                }
470            }
471            _ => {}
472        }
473        walk_expr(self, expr)
474    }
475}
476
477// ── Class-reference walker ────────────────────────────────────────────────────
478
479/// Collect spans for `new ClassName(...)` expressions only — excludes type hints,
480/// `instanceof`, `extends`, `implements`, and static calls.
481///
482/// `class_fqn` — when `Some`, FQN-qualified identifiers in the source (those
483/// containing `\`) are compared against the FQN rather than just the short name,
484/// preventing false positives when two classes share a short name across namespaces.
485pub fn new_refs_in_stmts(
486    stmts: &[Stmt<'_, '_>],
487    class_name: &str,
488    class_fqn: Option<&str>,
489    out: &mut Vec<Span>,
490) {
491    let mut v = NewRefsVisitor {
492        class_name,
493        class_fqn,
494        out: Vec::new(),
495    };
496    for stmt in stmts {
497        let _ = v.visit_stmt(stmt);
498    }
499    out.append(&mut v.out);
500}
501
502struct NewRefsVisitor<'a> {
503    class_name: &'a str,
504    class_fqn: Option<&'a str>,
505    out: Vec<Span>,
506}
507
508impl<'arena, 'src> Visitor<'arena, 'src> for NewRefsVisitor<'_> {
509    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
510        if let ExprKind::New(n) = &expr.kind
511            && let ExprKind::Identifier(id) = &n.class.kind
512        {
513            let matches = if id.contains('\\')
514                && let Some(fqn) = self.class_fqn
515            {
516                // Fully-qualified identifier: compare by FQN for exact namespace match.
517                id.trim_start_matches('\\') == fqn.trim_start_matches('\\')
518            } else {
519                id.rsplit('\\').next().unwrap_or(id) == self.class_name
520            };
521            if matches {
522                self.out.push(n.class.span);
523            }
524        }
525        walk_expr(self, expr)
526    }
527}
528
529/// `new ClassName`, `extends ClassName`, `implements ClassName`, type hints,
530/// and `$x instanceof ClassName`. Does NOT match free function calls or
531/// method names with the same spelling.
532pub fn class_refs_in_stmts(stmts: &[Stmt<'_, '_>], class_name: &str, out: &mut Vec<Span>) {
533    let mut v = ClassRefsVisitor {
534        class_name,
535        out: Vec::new(),
536    };
537    for stmt in stmts {
538        let _ = v.visit_stmt(stmt);
539    }
540    out.append(&mut v.out);
541}
542
543struct ClassRefsVisitor<'a> {
544    class_name: &'a str,
545    out: Vec<Span>,
546}
547
548impl ClassRefsVisitor<'_> {
549    /// Push the span of the last segment of `name` if it matches `class_name`.
550    fn collect_name<'a, 'b>(&mut self, name: &Name<'a, 'b>) {
551        let repr = name.to_string_repr();
552        let last = repr.rsplit('\\').next().unwrap_or(repr.as_ref());
553        if last == self.class_name {
554            let span = name.span();
555            let offset = (repr.len() - last.len()) as u32;
556            self.out.push(Span {
557                start: span.start + offset,
558                end: span.end,
559            });
560        }
561    }
562}
563
564impl<'arena, 'src> Visitor<'arena, 'src> for ClassRefsVisitor<'_> {
565    fn visit_stmt(&mut self, stmt: &Stmt<'arena, 'src>) -> ControlFlow<()> {
566        match &stmt.kind {
567            StmtKind::Class(c) => {
568                if let Some(ext) = &c.extends {
569                    self.collect_name(ext);
570                }
571                for iface in c.implements.iter() {
572                    self.collect_name(iface);
573                }
574            }
575            StmtKind::Interface(i) => {
576                for parent in i.extends.iter() {
577                    self.collect_name(parent);
578                }
579            }
580            _ => {}
581        }
582        walk_stmt(self, stmt)
583    }
584
585    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
586        match &expr.kind {
587            ExprKind::New(n) => {
588                if let ExprKind::Identifier(id) = &n.class.kind
589                    && id.rsplit('\\').next().unwrap_or(id) == self.class_name
590                {
591                    self.out.push(n.class.span);
592                }
593            }
594            ExprKind::Binary(b) => {
595                if let ExprKind::Identifier(id) = &b.right.kind
596                    && id.rsplit('\\').next().unwrap_or(id) == self.class_name
597                {
598                    self.out.push(b.right.span);
599                }
600            }
601            ExprKind::StaticMethodCall(s) => {
602                if let ExprKind::Identifier(id) = &s.class.kind
603                    && id.rsplit('\\').next().unwrap_or(id) == self.class_name
604                {
605                    self.out.push(s.class.span);
606                }
607            }
608            ExprKind::StaticPropertyAccess(s) => {
609                if let ExprKind::Identifier(id) = &s.class.kind
610                    && id.rsplit('\\').next().unwrap_or(id) == self.class_name
611                {
612                    self.out.push(s.class.span);
613                }
614            }
615            ExprKind::ClassConstAccess(c) => {
616                if let ExprKind::Identifier(id) = &c.class.kind
617                    && id.rsplit('\\').next().unwrap_or(id) == self.class_name
618                {
619                    self.out.push(c.class.span);
620                }
621            }
622            _ => {}
623        }
624        walk_expr(self, expr)
625    }
626
627    fn visit_type_hint(&mut self, type_hint: &TypeHint<'arena, 'src>) -> ControlFlow<()> {
628        if let TypeHintKind::Named(name) = &type_hint.kind {
629            self.collect_name(name);
630        }
631        walk_type_hint(self, type_hint)
632    }
633
634    fn visit_catch_clause(&mut self, catch: &CatchClause<'arena, 'src>) -> ControlFlow<()> {
635        for ty in catch.types.iter() {
636            self.collect_name(ty);
637        }
638        walk_catch_clause(self, catch)
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use crate::ast::ParsedDoc;
646
647    /// Return all substrings of `source` at the given spans.
648    fn spans_to_strs<'a>(source: &'a str, spans: &[Span]) -> Vec<&'a str> {
649        spans
650            .iter()
651            .map(|s| &source[s.start as usize..s.end as usize])
652            .collect()
653    }
654
655    fn parse(src: &str) -> ParsedDoc {
656        ParsedDoc::parse(src.to_string())
657    }
658
659    // ── refs_in_stmts ────────────────────────────────────────────────────────
660
661    #[test]
662    fn refs_finds_function_declaration_and_call() {
663        let src = "<?php\nfunction greet() {}\ngreet();";
664        let doc = parse(src);
665        let mut out = vec![];
666        refs_in_stmts(src, &doc.program().stmts, "greet", &mut out);
667        let texts = spans_to_strs(src, &out);
668        assert!(texts.contains(&"greet"), "expected function decl name");
669        assert_eq!(texts.iter().filter(|&&t| t == "greet").count(), 2);
670    }
671
672    #[test]
673    fn refs_finds_class_declaration_and_new() {
674        let src = "<?php\nclass Foo {}\n$x = new Foo();";
675        let doc = parse(src);
676        let mut out = vec![];
677        refs_in_stmts(src, &doc.program().stmts, "Foo", &mut out);
678        let texts = spans_to_strs(src, &out);
679        assert!(texts.iter().all(|&t| t == "Foo"));
680        assert_eq!(texts.len(), 2);
681    }
682
683    #[test]
684    fn refs_finds_method_declaration_inside_class() {
685        let src = "<?php\nclass Bar { function run() { $this->run(); } }";
686        let doc = parse(src);
687        let mut out = vec![];
688        refs_in_stmts(src, &doc.program().stmts, "run", &mut out);
689        let texts = spans_to_strs(src, &out);
690        // method decl name + method call name both appear
691        assert!(texts.iter().any(|&t| t == "run"));
692    }
693
694    #[test]
695    fn refs_returns_empty_for_unknown_name() {
696        let src = "<?php\nfunction greet() {}";
697        let doc = parse(src);
698        let mut out = vec![];
699        refs_in_stmts(src, &doc.program().stmts, "nope", &mut out);
700        assert!(out.is_empty());
701    }
702
703    // ── refs_in_stmts_with_use ───────────────────────────────────────────────
704
705    #[test]
706    fn refs_with_use_includes_use_import() {
707        let src = "<?php\nuse Vendor\\Lib\\Foo;\n$x = new Foo();";
708        let doc = parse(src);
709        let mut out = vec![];
710        refs_in_stmts_with_use(src, &doc.program().stmts, "Foo", &mut out);
711        let texts = spans_to_strs(src, &out);
712        // Should see the `Foo` segment in the use statement + the new Foo()
713        assert!(
714            texts.iter().filter(|&&t| t == "Foo").count() >= 2,
715            "got: {texts:?}"
716        );
717    }
718
719    #[test]
720    fn refs_without_use_misses_use_import() {
721        let src = "<?php\nuse Vendor\\Lib\\Foo;\n$x = new Foo();";
722        let doc = parse(src);
723        let mut out = vec![];
724        refs_in_stmts(src, &doc.program().stmts, "Foo", &mut out);
725        let texts = spans_to_strs(src, &out);
726        // refs_in_stmts does NOT walk use statements
727        assert!(
728            texts.iter().filter(|&&t| t == "Foo").count() < 2,
729            "refs_in_stmts should not include use import; got: {texts:?}"
730        );
731    }
732
733    // ── var_refs_in_stmts ────────────────────────────────────────────────────
734
735    #[test]
736    fn var_refs_finds_variable_in_assignment_and_echo() {
737        let src = "<?php\n$x = 1;\necho $x;";
738        let doc = parse(src);
739        let mut out = vec![];
740        var_refs_in_stmts(&doc.program().stmts, "x", &mut out);
741        assert_eq!(out.len(), 2, "expected $x in assignment and echo");
742    }
743
744    #[test]
745    fn var_refs_respects_function_scope_boundary() {
746        // $x inside the nested function is a separate scope — must not be collected.
747        let src = "<?php\n$x = 1;\nfunction inner() { $x = 2; }";
748        let doc = parse(src);
749        let mut out = vec![];
750        var_refs_in_stmts(&doc.program().stmts, "x", &mut out);
751        // Only the top-level $x = 1; should be found (function is a scope boundary).
752        assert_eq!(out.len(), 1, "inner $x must not cross scope boundary");
753    }
754
755    #[test]
756    fn var_refs_traverses_if_while_for_foreach() {
757        let src = "<?php\n$x = 0;\nif ($x) { $x++; }\nwhile ($x > 0) { $x--; }\nfor ($x = 0; $x < 3; $x++) {}\nforeach ([$x] as $v) {}";
758        let doc = parse(src);
759        let mut out = vec![];
760        var_refs_in_stmts(&doc.program().stmts, "x", &mut out);
761        assert!(
762            out.len() >= 5,
763            "expected multiple $x refs, got {}",
764            out.len()
765        );
766    }
767
768    #[test]
769    fn var_refs_does_not_cross_closure_boundary() {
770        let src = "<?php\n$x = 1;\n$f = function() { $x = 2; };";
771        let doc = parse(src);
772        let mut out = vec![];
773        var_refs_in_stmts(&doc.program().stmts, "x", &mut out);
774        // Closure is a scope boundary — inner $x not collected.
775        assert_eq!(
776            out.len(),
777            1,
778            "closure $x must not be collected by outer scope walk"
779        );
780    }
781
782    // ── collect_var_refs_in_scope ────────────────────────────────────────────
783
784    #[test]
785    fn collect_scope_finds_var_inside_function() {
786        let src = "<?php\nfunction foo($x) { return $x + 1; }";
787        let doc = parse(src);
788        // byte_off somewhere inside the function body
789        let byte_off = src.find("return").unwrap();
790        let mut out = vec![];
791        collect_var_refs_in_scope(&doc.program().stmts, "x", byte_off, &mut out);
792        // Should find the param span and the $x in return
793        assert!(
794            out.len() >= 2,
795            "expected param + body ref, got {}",
796            out.len()
797        );
798    }
799
800    #[test]
801    fn collect_scope_top_level_when_no_function() {
802        let src = "<?php\n$x = 1;\necho $x;";
803        let doc = parse(src);
804        let byte_off = src.find("echo").unwrap();
805        let mut out = vec![];
806        collect_var_refs_in_scope(&doc.program().stmts, "x", byte_off, &mut out);
807        assert_eq!(out.len(), 2);
808    }
809
810    #[test]
811    fn collect_scope_finds_var_inside_enum_method() {
812        let src = "<?php\nenum Status {\n    public function label($arg) { return $arg; }\n}";
813        let doc = parse(src);
814        let byte_off = src.find("return").unwrap();
815        let mut out = vec![];
816        collect_var_refs_in_scope(&doc.program().stmts, "arg", byte_off, &mut out);
817        assert!(
818            out.len() >= 2,
819            "expected param + body ref in enum method, got {}",
820            out.len()
821        );
822    }
823
824    #[test]
825    fn collect_scope_does_not_bleed_enum_method_into_outer_scope() {
826        let src =
827            "<?php\n$arg = 1;\nenum Status {\n    public function label($arg) { return $arg; }\n}";
828        let doc = parse(src);
829        // cursor is at the top-level $arg = 1, outside the enum
830        let byte_off = src.find("$arg").unwrap();
831        let mut out = vec![];
832        collect_var_refs_in_scope(&doc.program().stmts, "arg", byte_off, &mut out);
833        // only the top-level $arg should be found, not the enum method param
834        assert_eq!(
835            out.len(),
836            1,
837            "enum method $arg must not bleed into outer scope"
838        );
839    }
840
841    // ── property_refs_in_stmts ───────────────────────────────────────────────
842
843    #[test]
844    fn property_refs_finds_declaration_and_access() {
845        let src = "<?php\nclass Baz { public int $val = 0; function get() { return $this->val; } }";
846        let doc = parse(src);
847        let mut out = vec![];
848        property_refs_in_stmts(src, &doc.program().stmts, "val", &mut out);
849        // property declaration + $this->val access
850        assert_eq!(out.len(), 2, "expected decl + access, got {}", out.len());
851    }
852
853    #[test]
854    fn property_refs_finds_nullsafe_access() {
855        let src = "<?php\n$r = $obj?->name;";
856        let doc = parse(src);
857        let mut out = vec![];
858        property_refs_in_stmts(src, &doc.program().stmts, "name", &mut out);
859        assert_eq!(out.len(), 1);
860    }
861
862    // ── function_refs_in_stmts ───────────────────────────────────────────────
863
864    #[test]
865    fn function_refs_only_matches_free_calls_not_methods() {
866        let src = "<?php\nfunction run() {}\nrun();\n$obj->run();";
867        let doc = parse(src);
868        let mut out = vec![];
869        function_refs_in_stmts(&doc.program().stmts, "run", &mut out);
870        // Only the free call `run()` should match; `$obj->run()` must not.
871        assert_eq!(out.len(), 1, "got: {out:?}");
872    }
873
874    // ── method_refs_in_stmts ─────────────────────────────────────────────────
875
876    #[test]
877    fn method_refs_only_matches_method_calls_not_free_functions() {
878        let src = "<?php\nfunction run() {}\nrun();\n$obj->run();";
879        let doc = parse(src);
880        let mut out = vec![];
881        method_refs_in_stmts(&doc.program().stmts, "run", &mut out);
882        // Only `$obj->run()` method name span should match.
883        assert_eq!(out.len(), 1, "got: {out:?}");
884    }
885
886    #[test]
887    fn method_refs_finds_nullsafe_method_call() {
888        let src = "<?php\n$obj?->process();";
889        let doc = parse(src);
890        let mut out = vec![];
891        method_refs_in_stmts(&doc.program().stmts, "process", &mut out);
892        assert_eq!(out.len(), 1);
893    }
894
895    // ── class_refs_in_stmts ──────────────────────────────────────────────────
896
897    #[test]
898    fn class_refs_finds_new_and_extends() {
899        let src = "<?php\nclass Child extends Base {}\n$x = new Base();";
900        let doc = parse(src);
901        let mut out = vec![];
902        class_refs_in_stmts(&doc.program().stmts, "Base", &mut out);
903        assert!(out.len() >= 2, "expected extends + new, got {}", out.len());
904    }
905
906    #[test]
907    fn class_refs_does_not_match_free_function_with_same_name() {
908        let src = "<?php\nfunction Foo() {}\nFoo();";
909        let doc = parse(src);
910        let mut out = vec![];
911        class_refs_in_stmts(&doc.program().stmts, "Foo", &mut out);
912        assert!(
913            out.is_empty(),
914            "free function call must not be a class ref; got: {out:?}"
915        );
916    }
917
918    #[test]
919    fn class_refs_finds_type_hint_in_function_param() {
920        let src = "<?php\nfunction take(MyClass $obj): MyClass { return $obj; }";
921        let doc = parse(src);
922        let mut out = vec![];
923        class_refs_in_stmts(&doc.program().stmts, "MyClass", &mut out);
924        // param type hint + return type hint
925        assert_eq!(out.len(), 2, "got {out:?}");
926    }
927}