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                        var_refs_in_stmts(body, var_name, out);
244                    }
245                    for p in m.params.iter() {
246                        if p.name == var_name {
247                            out.push(p.span);
248                        }
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                        var_refs_in_stmts(body, var_name, out);
269                    }
270                    for p in m.params.iter() {
271                        if p.name == var_name {
272                            out.push(p.span);
273                        }
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                        var_refs_in_stmts(body, var_name, out);
319                    }
320                    for p in m.params.iter() {
321                        if p.name == var_name {
322                            out.push(p.span);
323                        }
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        match &member.kind {
391            ClassMemberKind::Property(p) if p.name == self.prop_name => {
392                let offset = str_offset(self.source, p.name);
393                self.out.push(Span {
394                    start: offset,
395                    end: offset + p.name.len() as u32,
396                });
397            }
398            // Constructor-promoted parameters act as property declarations.
399            ClassMemberKind::Method(m) if m.name == "__construct" => {
400                for p in m.params.iter() {
401                    if p.visibility.is_some() && p.name == self.prop_name {
402                        let offset = str_offset(self.source, p.name);
403                        self.out.push(Span {
404                            start: offset,
405                            end: offset + p.name.len() as u32,
406                        });
407                    }
408                }
409            }
410            _ => {}
411        }
412        walk_class_member(self, member)
413    }
414}
415
416// ── Function-reference walker ─────────────────────────────────────────────────
417
418/// Collect spans where `name` is called as a free function (not a method).
419/// Only matches `name(...)` calls where the callee is a bare identifier, not
420/// `$obj->name()` or `Class::name()`.
421pub fn function_refs_in_stmts(stmts: &[Stmt<'_, '_>], name: &str, out: &mut Vec<Span>) {
422    let mut v = FunctionRefsVisitor {
423        name,
424        out: Vec::new(),
425    };
426    for stmt in stmts {
427        let _ = v.visit_stmt(stmt);
428    }
429    out.append(&mut v.out);
430}
431
432struct FunctionRefsVisitor<'a> {
433    name: &'a str,
434    out: Vec<Span>,
435}
436
437impl<'arena, 'src> Visitor<'arena, 'src> for FunctionRefsVisitor<'_> {
438    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
439        if let ExprKind::FunctionCall(f) = &expr.kind
440            && let ExprKind::Identifier(id) = &f.name.kind
441            && id.as_str() == self.name
442        {
443            self.out.push(f.name.span);
444        }
445        walk_expr(self, expr)
446    }
447}
448
449// ── Method-reference walker ───────────────────────────────────────────────────
450
451/// Collect spans where `name` is used as a method: `->name()`, `?->name()`, `::name()`.
452/// Does NOT match free function calls or class-name identifiers.
453pub fn method_refs_in_stmts(stmts: &[Stmt<'_, '_>], name: &str, out: &mut Vec<Span>) {
454    let mut v = MethodRefsVisitor {
455        name,
456        out: Vec::new(),
457    };
458    for stmt in stmts {
459        let _ = v.visit_stmt(stmt);
460    }
461    out.append(&mut v.out);
462}
463
464struct MethodRefsVisitor<'a> {
465    name: &'a str,
466    out: Vec<Span>,
467}
468
469impl<'arena, 'src> Visitor<'arena, 'src> for MethodRefsVisitor<'_> {
470    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
471        match &expr.kind {
472            ExprKind::MethodCall(m) | ExprKind::NullsafeMethodCall(m) => {
473                if let ExprKind::Identifier(id) = &m.method.kind
474                    && id.as_str() == self.name
475                {
476                    self.out.push(m.method.span);
477                }
478            }
479            ExprKind::StaticMethodCall(s) => {
480                if s.method.name_str() == Some(self.name) {
481                    self.out.push(s.method.span);
482                }
483            }
484            _ => {}
485        }
486        walk_expr(self, expr)
487    }
488}
489
490// ── Class-reference walker ────────────────────────────────────────────────────
491
492/// Collect spans for `new ClassName(...)` expressions only — excludes type hints,
493/// `instanceof`, `extends`, `implements`, and static calls.
494///
495/// `class_fqn` — when `Some`, FQN-qualified identifiers in the source (those
496/// containing `\`) are compared against the FQN rather than just the short name,
497/// preventing false positives when two classes share a short name across namespaces.
498pub fn new_refs_in_stmts(
499    stmts: &[Stmt<'_, '_>],
500    class_name: &str,
501    class_fqn: Option<&str>,
502    out: &mut Vec<Span>,
503) {
504    let mut v = NewRefsVisitor {
505        class_name,
506        class_fqn,
507        out: Vec::new(),
508    };
509    for stmt in stmts {
510        let _ = v.visit_stmt(stmt);
511    }
512    out.append(&mut v.out);
513}
514
515struct NewRefsVisitor<'a> {
516    class_name: &'a str,
517    class_fqn: Option<&'a str>,
518    out: Vec<Span>,
519}
520
521impl<'arena, 'src> Visitor<'arena, 'src> for NewRefsVisitor<'_> {
522    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
523        if let ExprKind::New(n) = &expr.kind
524            && let ExprKind::Identifier(id) = &n.class.kind
525        {
526            let matches = if id.contains('\\')
527                && let Some(fqn) = self.class_fqn
528            {
529                // Fully-qualified identifier: compare by FQN for exact namespace match.
530                id.trim_start_matches('\\') == fqn.trim_start_matches('\\')
531            } else {
532                id.rsplit('\\').next().unwrap_or(id) == self.class_name
533            };
534            if matches {
535                self.out.push(n.class.span);
536            }
537        }
538        walk_expr(self, expr)
539    }
540}
541
542/// `new ClassName`, `extends ClassName`, `implements ClassName`, type hints,
543/// and `$x instanceof ClassName`. Does NOT match free function calls or
544/// method names with the same spelling.
545pub fn class_refs_in_stmts(stmts: &[Stmt<'_, '_>], class_name: &str, out: &mut Vec<Span>) {
546    let mut v = ClassRefsVisitor {
547        class_name,
548        out: Vec::new(),
549    };
550    for stmt in stmts {
551        let _ = v.visit_stmt(stmt);
552    }
553    out.append(&mut v.out);
554}
555
556struct ClassRefsVisitor<'a> {
557    class_name: &'a str,
558    out: Vec<Span>,
559}
560
561impl ClassRefsVisitor<'_> {
562    /// Push the span of the last segment of `name` if it matches `class_name`.
563    fn collect_name<'a, 'b>(&mut self, name: &Name<'a, 'b>) {
564        let repr = name.to_string_repr();
565        let last = repr.rsplit('\\').next().unwrap_or(repr.as_ref());
566        if last == self.class_name {
567            let span = name.span();
568            let offset = (repr.len() - last.len()) as u32;
569            self.out.push(Span {
570                start: span.start + offset,
571                end: span.end,
572            });
573        }
574    }
575}
576
577impl<'arena, 'src> Visitor<'arena, 'src> for ClassRefsVisitor<'_> {
578    fn visit_stmt(&mut self, stmt: &Stmt<'arena, 'src>) -> ControlFlow<()> {
579        match &stmt.kind {
580            StmtKind::Class(c) => {
581                if let Some(ext) = &c.extends {
582                    self.collect_name(ext);
583                }
584                for iface in c.implements.iter() {
585                    self.collect_name(iface);
586                }
587            }
588            StmtKind::Interface(i) => {
589                for parent in i.extends.iter() {
590                    self.collect_name(parent);
591                }
592            }
593            _ => {}
594        }
595        walk_stmt(self, stmt)
596    }
597
598    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
599        match &expr.kind {
600            ExprKind::New(n) => {
601                if let ExprKind::Identifier(id) = &n.class.kind
602                    && id.rsplit('\\').next().unwrap_or(id) == self.class_name
603                {
604                    self.out.push(n.class.span);
605                }
606            }
607            ExprKind::Binary(b) => {
608                if let ExprKind::Identifier(id) = &b.right.kind
609                    && id.rsplit('\\').next().unwrap_or(id) == self.class_name
610                {
611                    self.out.push(b.right.span);
612                }
613            }
614            ExprKind::StaticMethodCall(s) => {
615                if let ExprKind::Identifier(id) = &s.class.kind
616                    && id.rsplit('\\').next().unwrap_or(id) == self.class_name
617                {
618                    self.out.push(s.class.span);
619                }
620            }
621            ExprKind::StaticPropertyAccess(s) => {
622                if let ExprKind::Identifier(id) = &s.class.kind
623                    && id.rsplit('\\').next().unwrap_or(id) == self.class_name
624                {
625                    self.out.push(s.class.span);
626                }
627            }
628            ExprKind::ClassConstAccess(c) => {
629                if let ExprKind::Identifier(id) = &c.class.kind
630                    && id.rsplit('\\').next().unwrap_or(id) == self.class_name
631                {
632                    self.out.push(c.class.span);
633                }
634            }
635            _ => {}
636        }
637        walk_expr(self, expr)
638    }
639
640    fn visit_type_hint(&mut self, type_hint: &TypeHint<'arena, 'src>) -> ControlFlow<()> {
641        if let TypeHintKind::Named(name) = &type_hint.kind {
642            self.collect_name(name);
643        }
644        walk_type_hint(self, type_hint)
645    }
646
647    fn visit_catch_clause(&mut self, catch: &CatchClause<'arena, 'src>) -> ControlFlow<()> {
648        for ty in catch.types.iter() {
649            self.collect_name(ty);
650        }
651        walk_catch_clause(self, catch)
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658    use crate::ast::ParsedDoc;
659
660    /// Return all substrings of `source` at the given spans.
661    fn spans_to_strs<'a>(source: &'a str, spans: &[Span]) -> Vec<&'a str> {
662        spans
663            .iter()
664            .map(|s| &source[s.start as usize..s.end as usize])
665            .collect()
666    }
667
668    fn parse(src: &str) -> ParsedDoc {
669        ParsedDoc::parse(src.to_string())
670    }
671
672    // ── refs_in_stmts ────────────────────────────────────────────────────────
673
674    #[test]
675    fn refs_finds_function_declaration_and_call() {
676        let src = "<?php\nfunction greet() {}\ngreet();";
677        let doc = parse(src);
678        let mut out = vec![];
679        refs_in_stmts(src, &doc.program().stmts, "greet", &mut out);
680        let texts = spans_to_strs(src, &out);
681        assert!(texts.contains(&"greet"), "expected function decl name");
682        assert_eq!(texts.iter().filter(|&&t| t == "greet").count(), 2);
683    }
684
685    #[test]
686    fn refs_finds_class_declaration_and_new() {
687        let src = "<?php\nclass Foo {}\n$x = new Foo();";
688        let doc = parse(src);
689        let mut out = vec![];
690        refs_in_stmts(src, &doc.program().stmts, "Foo", &mut out);
691        let texts = spans_to_strs(src, &out);
692        assert!(texts.iter().all(|&t| t == "Foo"));
693        assert_eq!(texts.len(), 2);
694    }
695
696    #[test]
697    fn refs_finds_method_declaration_inside_class() {
698        let src = "<?php\nclass Bar { function run() { $this->run(); } }";
699        let doc = parse(src);
700        let mut out = vec![];
701        refs_in_stmts(src, &doc.program().stmts, "run", &mut out);
702        let texts = spans_to_strs(src, &out);
703        // method decl name + method call name both appear
704        assert!(texts.iter().any(|&t| t == "run"));
705    }
706
707    #[test]
708    fn refs_returns_empty_for_unknown_name() {
709        let src = "<?php\nfunction greet() {}";
710        let doc = parse(src);
711        let mut out = vec![];
712        refs_in_stmts(src, &doc.program().stmts, "nope", &mut out);
713        assert!(out.is_empty());
714    }
715
716    // ── refs_in_stmts_with_use ───────────────────────────────────────────────
717
718    #[test]
719    fn refs_with_use_includes_use_import() {
720        let src = "<?php\nuse Vendor\\Lib\\Foo;\n$x = new Foo();";
721        let doc = parse(src);
722        let mut out = vec![];
723        refs_in_stmts_with_use(src, &doc.program().stmts, "Foo", &mut out);
724        let texts = spans_to_strs(src, &out);
725        // Should see the `Foo` segment in the use statement + the new Foo()
726        assert!(
727            texts.iter().filter(|&&t| t == "Foo").count() >= 2,
728            "got: {texts:?}"
729        );
730    }
731
732    #[test]
733    fn refs_without_use_misses_use_import() {
734        let src = "<?php\nuse Vendor\\Lib\\Foo;\n$x = new Foo();";
735        let doc = parse(src);
736        let mut out = vec![];
737        refs_in_stmts(src, &doc.program().stmts, "Foo", &mut out);
738        let texts = spans_to_strs(src, &out);
739        // refs_in_stmts does NOT walk use statements
740        assert!(
741            texts.iter().filter(|&&t| t == "Foo").count() < 2,
742            "refs_in_stmts should not include use import; got: {texts:?}"
743        );
744    }
745
746    // ── var_refs_in_stmts ────────────────────────────────────────────────────
747
748    #[test]
749    fn var_refs_finds_variable_in_assignment_and_echo() {
750        let src = "<?php\n$x = 1;\necho $x;";
751        let doc = parse(src);
752        let mut out = vec![];
753        var_refs_in_stmts(&doc.program().stmts, "x", &mut out);
754        assert_eq!(out.len(), 2, "expected $x in assignment and echo");
755    }
756
757    #[test]
758    fn var_refs_respects_function_scope_boundary() {
759        // $x inside the nested function is a separate scope — must not be collected.
760        let src = "<?php\n$x = 1;\nfunction inner() { $x = 2; }";
761        let doc = parse(src);
762        let mut out = vec![];
763        var_refs_in_stmts(&doc.program().stmts, "x", &mut out);
764        // Only the top-level $x = 1; should be found (function is a scope boundary).
765        assert_eq!(out.len(), 1, "inner $x must not cross scope boundary");
766    }
767
768    #[test]
769    fn var_refs_traverses_if_while_for_foreach() {
770        let src = "<?php\n$x = 0;\nif ($x) { $x++; }\nwhile ($x > 0) { $x--; }\nfor ($x = 0; $x < 3; $x++) {}\nforeach ([$x] as $v) {}";
771        let doc = parse(src);
772        let mut out = vec![];
773        var_refs_in_stmts(&doc.program().stmts, "x", &mut out);
774        assert!(
775            out.len() >= 5,
776            "expected multiple $x refs, got {}",
777            out.len()
778        );
779    }
780
781    #[test]
782    fn var_refs_does_not_cross_closure_boundary() {
783        let src = "<?php\n$x = 1;\n$f = function() { $x = 2; };";
784        let doc = parse(src);
785        let mut out = vec![];
786        var_refs_in_stmts(&doc.program().stmts, "x", &mut out);
787        // Closure is a scope boundary — inner $x not collected.
788        assert_eq!(
789            out.len(),
790            1,
791            "closure $x must not be collected by outer scope walk"
792        );
793    }
794
795    // ── collect_var_refs_in_scope ────────────────────────────────────────────
796
797    #[test]
798    fn collect_scope_finds_var_inside_function() {
799        let src = "<?php\nfunction foo($x) { return $x + 1; }";
800        let doc = parse(src);
801        // byte_off somewhere inside the function body
802        let byte_off = src.find("return").unwrap();
803        let mut out = vec![];
804        collect_var_refs_in_scope(&doc.program().stmts, "x", byte_off, &mut out);
805        // Should find the param span and the $x in return
806        assert!(
807            out.len() >= 2,
808            "expected param + body ref, got {}",
809            out.len()
810        );
811    }
812
813    #[test]
814    fn collect_scope_top_level_when_no_function() {
815        let src = "<?php\n$x = 1;\necho $x;";
816        let doc = parse(src);
817        let byte_off = src.find("echo").unwrap();
818        let mut out = vec![];
819        collect_var_refs_in_scope(&doc.program().stmts, "x", byte_off, &mut out);
820        assert_eq!(out.len(), 2);
821    }
822
823    #[test]
824    fn collect_scope_finds_var_inside_enum_method() {
825        let src = "<?php\nenum Status {\n    public function label($arg) { return $arg; }\n}";
826        let doc = parse(src);
827        let byte_off = src.find("return").unwrap();
828        let mut out = vec![];
829        collect_var_refs_in_scope(&doc.program().stmts, "arg", byte_off, &mut out);
830        assert!(
831            out.len() >= 2,
832            "expected param + body ref in enum method, got {}",
833            out.len()
834        );
835    }
836
837    #[test]
838    fn collect_scope_does_not_bleed_enum_method_into_outer_scope() {
839        let src =
840            "<?php\n$arg = 1;\nenum Status {\n    public function label($arg) { return $arg; }\n}";
841        let doc = parse(src);
842        // cursor is at the top-level $arg = 1, outside the enum
843        let byte_off = src.find("$arg").unwrap();
844        let mut out = vec![];
845        collect_var_refs_in_scope(&doc.program().stmts, "arg", byte_off, &mut out);
846        // only the top-level $arg should be found, not the enum method param
847        assert_eq!(
848            out.len(),
849            1,
850            "enum method $arg must not bleed into outer scope"
851        );
852    }
853
854    // ── property_refs_in_stmts ───────────────────────────────────────────────
855
856    #[test]
857    fn property_refs_finds_declaration_and_access() {
858        let src = "<?php\nclass Baz { public int $val = 0; function get() { return $this->val; } }";
859        let doc = parse(src);
860        let mut out = vec![];
861        property_refs_in_stmts(src, &doc.program().stmts, "val", &mut out);
862        // property declaration + $this->val access
863        assert_eq!(out.len(), 2, "expected decl + access, got {}", out.len());
864    }
865
866    #[test]
867    fn property_refs_finds_nullsafe_access() {
868        let src = "<?php\n$r = $obj?->name;";
869        let doc = parse(src);
870        let mut out = vec![];
871        property_refs_in_stmts(src, &doc.program().stmts, "name", &mut out);
872        assert_eq!(out.len(), 1);
873    }
874
875    // ── function_refs_in_stmts ───────────────────────────────────────────────
876
877    #[test]
878    fn function_refs_only_matches_free_calls_not_methods() {
879        let src = "<?php\nfunction run() {}\nrun();\n$obj->run();";
880        let doc = parse(src);
881        let mut out = vec![];
882        function_refs_in_stmts(&doc.program().stmts, "run", &mut out);
883        // Only the free call `run()` should match; `$obj->run()` must not.
884        assert_eq!(out.len(), 1, "got: {out:?}");
885    }
886
887    // ── method_refs_in_stmts ─────────────────────────────────────────────────
888
889    #[test]
890    fn method_refs_only_matches_method_calls_not_free_functions() {
891        let src = "<?php\nfunction run() {}\nrun();\n$obj->run();";
892        let doc = parse(src);
893        let mut out = vec![];
894        method_refs_in_stmts(&doc.program().stmts, "run", &mut out);
895        // Only `$obj->run()` method name span should match.
896        assert_eq!(out.len(), 1, "got: {out:?}");
897    }
898
899    #[test]
900    fn method_refs_finds_nullsafe_method_call() {
901        let src = "<?php\n$obj?->process();";
902        let doc = parse(src);
903        let mut out = vec![];
904        method_refs_in_stmts(&doc.program().stmts, "process", &mut out);
905        assert_eq!(out.len(), 1);
906    }
907
908    // ── class_refs_in_stmts ──────────────────────────────────────────────────
909
910    #[test]
911    fn class_refs_finds_new_and_extends() {
912        let src = "<?php\nclass Child extends Base {}\n$x = new Base();";
913        let doc = parse(src);
914        let mut out = vec![];
915        class_refs_in_stmts(&doc.program().stmts, "Base", &mut out);
916        assert!(out.len() >= 2, "expected extends + new, got {}", out.len());
917    }
918
919    #[test]
920    fn class_refs_does_not_match_free_function_with_same_name() {
921        let src = "<?php\nfunction Foo() {}\nFoo();";
922        let doc = parse(src);
923        let mut out = vec![];
924        class_refs_in_stmts(&doc.program().stmts, "Foo", &mut out);
925        assert!(
926            out.is_empty(),
927            "free function call must not be a class ref; got: {out:?}"
928        );
929    }
930
931    #[test]
932    fn class_refs_finds_type_hint_in_function_param() {
933        let src = "<?php\nfunction take(MyClass $obj): MyClass { return $obj; }";
934        let doc = parse(src);
935        let mut out = vec![];
936        class_refs_in_stmts(&doc.program().stmts, "MyClass", &mut out);
937        // param type hint + return type hint
938        assert_eq!(out.len(), 2, "got {out:?}");
939    }
940}