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::collections::HashSet;
4use std::ops::ControlFlow;
5
6use php_ast::{
7    Attribute, CatchClause, ClassMember, ClassMemberKind, EnumMember, EnumMemberKind, Expr,
8    ExprKind, MethodDecl, Name, NamespaceBody, Span, Stmt, StmtKind, TraitUseDecl, TypeHint,
9    TypeHintKind, UnaryPostfixOp, UnaryPrefixOp,
10    visitor::{
11        Visitor, walk_attribute, walk_catch_clause, walk_class_member, walk_enum_member, walk_expr,
12        walk_stmt, walk_trait_use, walk_type_hint,
13    },
14};
15use tower_lsp::lsp_types::DocumentHighlightKind;
16
17use crate::ast::{str_offset, str_offset_in_range};
18use crate::util::fqn_short_name;
19
20// ── Public entry points ───────────────────────────────────────────────────────
21
22pub fn refs_in_stmts(source: &str, stmts: &[Stmt<'_, '_>], word: &str, out: &mut Vec<Span>) {
23    walk_all_refs(source, stmts, word, false, out);
24}
25
26/// Like `refs_in_stmts`, but also matches spans inside `use` statements.
27/// Needed so that renaming a class also renames its `use` import.
28pub fn refs_in_stmts_with_use(
29    source: &str,
30    stmts: &[Stmt<'_, '_>],
31    word: &str,
32    out: &mut Vec<Span>,
33) {
34    walk_all_refs(source, stmts, word, true, out);
35}
36
37fn walk_all_refs(
38    source: &str,
39    stmts: &[Stmt<'_, '_>],
40    word: &str,
41    include_use: bool,
42    out: &mut Vec<Span>,
43) {
44    let mut v = AllRefsVisitor {
45        source,
46        word,
47        include_use,
48        out: Vec::new(),
49    };
50    for stmt in stmts {
51        let _ = v.visit_stmt(stmt);
52    }
53    out.append(&mut v.out);
54}
55
56// ── AllRefsVisitor ────────────────────────────────────────────────────────────
57
58struct AllRefsVisitor<'a> {
59    source: &'a str,
60    word: &'a str,
61    include_use: bool,
62    out: Vec<Span>,
63}
64
65impl AllRefsVisitor<'_> {
66    fn push_name_str(&mut self, name: &str, stmt_span: Span) {
67        if name == self.word {
68            let start =
69                str_offset_in_range(self.source, stmt_span, name).unwrap_or(stmt_span.start);
70            self.out.push(Span {
71                start,
72                end: start + name.len() as u32,
73            });
74        }
75    }
76}
77
78impl<'arena, 'src> Visitor<'arena, 'src> for AllRefsVisitor<'_> {
79    fn visit_stmt(&mut self, stmt: &Stmt<'arena, 'src>) -> ControlFlow<()> {
80        match &stmt.kind {
81            StmtKind::Function(f) => self.push_name_str(f.name.or_error(), stmt.span),
82            StmtKind::Class(c) => {
83                if let Some(name) = c.name {
84                    self.push_name_str(name.or_error(), stmt.span);
85                }
86            }
87            StmtKind::Interface(i) => self.push_name_str(i.name.or_error(), stmt.span),
88            StmtKind::Trait(t) => self.push_name_str(t.name.or_error(), stmt.span),
89            StmtKind::Enum(e) => self.push_name_str(e.name.or_error(), stmt.span),
90            StmtKind::Use(u) if self.include_use => {
91                for use_item in u.uses.iter() {
92                    let fqn = use_item.name.to_string_repr().into_owned();
93                    if let Some(alias) = use_item.alias {
94                        // If there's an alias and it matches, emit the alias span (not the FQN)
95                        if alias == self.word {
96                            // Find the position of the alias in the source
97                            if let Some(offset) = str_offset(self.source, alias) {
98                                self.out.push(Span {
99                                    start: offset,
100                                    end: offset + alias.len() as u32,
101                                });
102                            }
103                        }
104                    } else {
105                        // No alias: check if the last segment of FQN matches
106                        let last_seg = fqn_short_name(&fqn);
107                        if last_seg == self.word {
108                            let name_span = use_item.name.span();
109                            let offset = (fqn.len() - last_seg.len()) as u32;
110                            self.out.push(Span {
111                                start: name_span.start + offset,
112                                end: name_span.start + fqn.len() as u32,
113                            });
114                        }
115                    }
116                }
117            }
118            _ => {}
119        }
120        walk_stmt(self, stmt)
121    }
122
123    fn visit_class_member(&mut self, member: &ClassMember<'arena, 'src>) -> ControlFlow<()> {
124        match &member.kind {
125            ClassMemberKind::Method(m) if m.name == self.word => {
126                let name_str = m.name.or_error();
127                // Scope the name search to this member's own span — a
128                // global `str_offset` returns the first occurrence in
129                // the file, so when two classes share a method name
130                // both methods would resolve to the same range.
131                let start = str_offset_in_range(self.source, member.span, name_str).unwrap_or(0);
132                self.out.push(Span {
133                    start,
134                    end: start + name_str.len() as u32,
135                });
136            }
137            ClassMemberKind::ClassConst(cc) if cc.name == self.word => {
138                let name_str = cc.name.or_error();
139                let start = str_offset_in_range(self.source, member.span, name_str)
140                    .unwrap_or_else(|| str_offset(self.source, name_str).unwrap_or(0));
141                self.out.push(Span {
142                    start,
143                    end: start + name_str.len() as u32,
144                });
145            }
146            _ => {}
147        }
148        walk_class_member(self, member)
149    }
150
151    fn visit_enum_member(&mut self, member: &EnumMember<'arena, 'src>) -> ControlFlow<()> {
152        if let EnumMemberKind::Method(m) = &member.kind
153            && m.name == self.word
154        {
155            let name_str = m.name.or_error();
156            let start = str_offset(self.source, name_str).unwrap_or(0);
157            self.out.push(Span {
158                start,
159                end: start + name_str.len() as u32,
160            });
161        }
162        walk_enum_member(self, member)
163    }
164
165    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
166        if let ExprKind::Identifier(name) = &expr.kind
167            && name.as_str() == self.word
168        {
169            self.out.push(expr.span);
170        }
171        walk_expr(self, expr)
172    }
173}
174
175// ── Variable rename helpers ───────────────────────────────────────────────────
176
177/// Collect all spans where `$var_name` (the variable name WITHOUT `$`) appears as an
178/// `ExprKind::Variable` within `stmts`. Stops at nested function/closure/arrow-function
179/// scope boundaries so that `$x` in an inner function is not conflated with `$x` in
180/// the outer function.
181pub fn var_refs_in_stmts(
182    stmts: &[Stmt<'_, '_>],
183    var_name: &str,
184    out: &mut Vec<(Span, DocumentHighlightKind)>,
185) {
186    let mut v = VarRefsVisitor {
187        var_name,
188        out: Vec::new(),
189    };
190    for stmt in stmts {
191        let _ = v.visit_stmt(stmt);
192    }
193    out.append(&mut v.out);
194}
195
196struct VarRefsVisitor<'a> {
197    var_name: &'a str,
198    out: Vec<(Span, DocumentHighlightKind)>,
199}
200
201impl<'arena, 'src> Visitor<'arena, 'src> for VarRefsVisitor<'_> {
202    fn visit_stmt(&mut self, stmt: &Stmt<'arena, 'src>) -> ControlFlow<()> {
203        // Stop at scope-defining statement boundaries.
204        match &stmt.kind {
205            StmtKind::Function(_)
206            | StmtKind::Class(_)
207            | StmtKind::Trait(_)
208            | StmtKind::Enum(_)
209            | StmtKind::Interface(_) => ControlFlow::Continue(()),
210            StmtKind::Foreach(f) => {
211                // foreach key/value are write positions (being assigned)
212                if let Some(key) = &f.key
213                    && let ExprKind::Variable(name) = &key.kind
214                    && name.as_str() == self.var_name
215                {
216                    self.out.push((key.span, DocumentHighlightKind::WRITE));
217                }
218                if let ExprKind::Variable(name) = &f.value.kind
219                    && name.as_str() == self.var_name
220                {
221                    self.out.push((f.value.span, DocumentHighlightKind::WRITE));
222                }
223                // Walk the rest of the foreach
224                let _ = self.visit_expr(&f.expr);
225                let _ = self.visit_stmt(f.body);
226                ControlFlow::Continue(())
227            }
228            _ => walk_stmt(self, stmt),
229        }
230    }
231
232    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
233        match &expr.kind {
234            // Collect matching variable references.
235            ExprKind::Variable(name) => {
236                if name.as_str() == self.var_name {
237                    self.out.push((expr.span, DocumentHighlightKind::READ));
238                }
239                ControlFlow::Continue(())
240            }
241            // Assignment: target is WRITE, value is READ
242            ExprKind::Assign(a) => {
243                // Visit target with WRITE kind
244                if let ExprKind::Variable(name) = &a.target.kind {
245                    if name.as_str() == self.var_name {
246                        self.out.push((a.target.span, DocumentHighlightKind::WRITE));
247                    }
248                } else {
249                    let _ = self.visit_expr(a.target);
250                }
251                // Visit value with READ kind (default)
252                let _ = self.visit_expr(a.value);
253                ControlFlow::Continue(())
254            }
255            // Pre/post increment/decrement are both read and write, but mark as WRITE
256            ExprKind::UnaryPrefix(u) => {
257                if matches!(
258                    u.op,
259                    UnaryPrefixOp::PreIncrement | UnaryPrefixOp::PreDecrement
260                ) && let ExprKind::Variable(name) = &u.operand.kind
261                    && name.as_str() == self.var_name
262                {
263                    self.out
264                        .push((u.operand.span, DocumentHighlightKind::WRITE));
265                    return ControlFlow::Continue(());
266                }
267                walk_expr(self, expr)
268            }
269            ExprKind::UnaryPostfix(u) => {
270                if matches!(
271                    u.op,
272                    UnaryPostfixOp::PostIncrement | UnaryPostfixOp::PostDecrement
273                ) && let ExprKind::Variable(name) = &u.operand.kind
274                    && name.as_str() == self.var_name
275                {
276                    self.out
277                        .push((u.operand.span, DocumentHighlightKind::WRITE));
278                    return ControlFlow::Continue(());
279                }
280                walk_expr(self, expr)
281            }
282            // Closures are scope boundaries, but arrow functions auto-capture outer variables.
283            ExprKind::Closure(c) => {
284                // Before stopping, collect variables from the closure's use($x) clause.
285                for use_var in c.use_vars.iter() {
286                    if use_var.name == self.var_name {
287                        self.out.push((use_var.span, DocumentHighlightKind::READ));
288                    }
289                }
290                ControlFlow::Continue(())
291            }
292            // Arrow functions auto-capture and should be traversed.
293            ExprKind::ArrowFunction(_) => walk_expr(self, expr),
294            _ => walk_expr(self, expr),
295        }
296    }
297}
298
299/// Collect all `$var_name` spans within the innermost function/method scope
300/// that contains `byte_off`. If `byte_off` is not inside any function, collects
301/// from the top-level stmts (respecting scope boundaries). Also collects the
302/// parameter declaration span when the variable is a parameter of the scope.
303pub fn collect_var_refs_in_scope(
304    stmts: &[Stmt<'_, '_>],
305    var_name: &str,
306    byte_off: usize,
307    out: &mut Vec<(Span, DocumentHighlightKind)>,
308) {
309    for stmt in stmts {
310        if collect_in_fn_at(stmt, var_name, byte_off, out) {
311            return;
312        }
313    }
314    // Not inside any function — collect top-level
315    var_refs_in_stmts(stmts, var_name, out);
316}
317
318/// Returns `true` if the cursor at `byte_off` falls within `m`'s span, collecting
319/// variable references from `m`'s body and parameters into `out`.
320fn collect_method_scope(
321    m: &MethodDecl<'_, '_>,
322    member_span: Span,
323    var_name: &str,
324    byte_off: usize,
325    out: &mut Vec<(Span, DocumentHighlightKind)>,
326) -> bool {
327    if byte_off < member_span.start as usize || byte_off >= member_span.end as usize {
328        return false;
329    }
330    if let Some(body) = &m.body {
331        for inner in body.stmts.iter() {
332            if collect_in_fn_at(inner, var_name, byte_off, out) {
333                return true;
334            }
335        }
336        var_refs_in_stmts(&body.stmts, var_name, out);
337    }
338    for p in m.params.iter() {
339        if p.name == var_name {
340            out.push((p.span, DocumentHighlightKind::WRITE));
341        }
342    }
343    true
344}
345
346/// Search `members` for the method whose span contains `byte_off` and collect
347/// variable references for `var_name` from it. Returns `true` when found.
348fn collect_in_class_members(
349    members: &[ClassMember<'_, '_>],
350    var_name: &str,
351    byte_off: usize,
352    out: &mut Vec<(Span, DocumentHighlightKind)>,
353) -> bool {
354    for member in members {
355        if let ClassMemberKind::Method(m) = &member.kind
356            && collect_method_scope(m, member.span, var_name, byte_off, out)
357        {
358            return true;
359        }
360    }
361    false
362}
363
364/// Returns `true` if `stmt` is (or contains) the function/method that owns `byte_off`
365/// and has populated `out` with variable + param spans for `var_name`.
366fn collect_in_fn_at(
367    stmt: &Stmt<'_, '_>,
368    var_name: &str,
369    byte_off: usize,
370    out: &mut Vec<(Span, DocumentHighlightKind)>,
371) -> bool {
372    match &stmt.kind {
373        StmtKind::Function(f) => {
374            if byte_off < stmt.span.start as usize || byte_off >= stmt.span.end as usize {
375                return false;
376            }
377            for inner in f.body.stmts.iter() {
378                if collect_in_fn_at(inner, var_name, byte_off, out) {
379                    return true;
380                }
381            }
382            for p in f.params.iter() {
383                if p.name == var_name {
384                    out.push((p.span, DocumentHighlightKind::WRITE));
385                }
386            }
387            var_refs_in_stmts(&f.body.stmts, var_name, out);
388            true
389        }
390        StmtKind::Class(c) => collect_in_class_members(&c.body.members, var_name, byte_off, out),
391        StmtKind::Trait(t) => collect_in_class_members(&t.body.members, var_name, byte_off, out),
392        StmtKind::Interface(i) => {
393            collect_in_class_members(&i.body.members, var_name, byte_off, out)
394        }
395        StmtKind::Enum(e) => {
396            for member in e.body.members.iter() {
397                if let EnumMemberKind::Method(m) = &member.kind
398                    && collect_method_scope(m, member.span, var_name, byte_off, out)
399                {
400                    return true;
401                }
402            }
403            false
404        }
405        StmtKind::Namespace(ns) => {
406            if let NamespaceBody::Braced(inner) = &ns.body {
407                for s in inner.stmts.iter() {
408                    if collect_in_fn_at(s, var_name, byte_off, out) {
409                        return true;
410                    }
411                }
412            }
413            false
414        }
415        _ => false,
416    }
417}
418
419// ── Property rename helpers ───────────────────────────────────────────────────
420
421/// Collect all spans where `prop_name` is accessed (`->prop`, `?->prop`) or
422/// declared as a class/trait property, across all statements.
423pub fn property_refs_in_stmts(
424    source: &str,
425    stmts: &[Stmt<'_, '_>],
426    prop_name: &str,
427    out: &mut Vec<Span>,
428) {
429    let mut v = PropertyRefsVisitor {
430        source,
431        prop_name,
432        out: Vec::new(),
433    };
434    for stmt in stmts {
435        let _ = v.visit_stmt(stmt);
436    }
437    out.append(&mut v.out);
438}
439
440struct PropertyRefsVisitor<'a> {
441    source: &'a str,
442    prop_name: &'a str,
443    out: Vec<Span>,
444}
445
446impl<'arena, 'src> Visitor<'arena, 'src> for PropertyRefsVisitor<'_> {
447    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
448        match &expr.kind {
449            ExprKind::PropertyAccess(p) | ExprKind::NullsafePropertyAccess(p) => {
450                let span = p.property.span;
451                let name_in_src = self
452                    .source
453                    .get(span.start as usize..span.end as usize)
454                    .unwrap_or("");
455                if name_in_src == self.prop_name {
456                    self.out.push(span);
457                }
458            }
459            // Static property access: Class::$prop, self::$prop, parent::$prop.
460            // The member expression is a Variable whose span includes the `$` sigil;
461            // we skip it (+1) so the emitted span covers only the name itself,
462            // matching the convention used by instance-property access above.
463            ExprKind::StaticPropertyAccess(s) => {
464                if let ExprKind::Identifier(name) = &s.member.kind
465                    && name.as_str() == self.prop_name
466                    && s.member.span.start + 1 < s.member.span.end
467                {
468                    self.out.push(Span {
469                        start: s.member.span.start + 1,
470                        end: s.member.span.end,
471                    });
472                }
473            }
474            _ => {}
475        }
476        walk_expr(self, expr)
477    }
478
479    fn visit_class_member(&mut self, member: &ClassMember<'arena, 'src>) -> ControlFlow<()> {
480        match &member.kind {
481            ClassMemberKind::Property(p) if p.name == self.prop_name => {
482                let name_str = p.name.or_error();
483                let offset = str_offset(self.source, name_str).unwrap_or(0);
484                self.out.push(Span {
485                    start: offset,
486                    end: offset + name_str.len() as u32,
487                });
488            }
489            // Constructor-promoted parameters act as property declarations.
490            ClassMemberKind::Method(m) if m.name == "__construct" => {
491                for p in m.params.iter() {
492                    if p.visibility.is_some() && p.name == self.prop_name {
493                        let name_str = p.name.or_error();
494                        let offset = str_offset(self.source, name_str).unwrap_or(0);
495                        self.out.push(Span {
496                            start: offset,
497                            end: offset + name_str.len() as u32,
498                        });
499                    }
500                }
501            }
502            _ => {}
503        }
504        walk_class_member(self, member)
505    }
506}
507
508// ── Function-reference walker ─────────────────────────────────────────────────
509
510/// Collect spans where `name` is called as a free function (not a method).
511/// Only matches `name(...)` calls where the callee is a bare identifier, not
512/// `$obj->name()` or `Class::name()`.
513pub fn function_refs_in_stmts(stmts: &[Stmt<'_, '_>], name: &str, out: &mut Vec<Span>) {
514    let mut v = FunctionRefsVisitor {
515        name,
516        out: Vec::new(),
517    };
518    for stmt in stmts {
519        let _ = v.visit_stmt(stmt);
520    }
521    out.append(&mut v.out);
522}
523
524struct FunctionRefsVisitor<'a> {
525    name: &'a str,
526    out: Vec<Span>,
527}
528
529impl<'arena, 'src> Visitor<'arena, 'src> for FunctionRefsVisitor<'_> {
530    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
531        if let ExprKind::FunctionCall(f) = &expr.kind
532            && let ExprKind::Identifier(id) = &f.name.kind
533            && id.as_str() == self.name
534        {
535            self.out.push(f.name.span);
536        }
537        walk_expr(self, expr)
538    }
539}
540
541// ── Method-reference walker ───────────────────────────────────────────────────
542
543/// Collect spans where `name` is used as a method: `->name()`, `?->name()`, `::name()`.
544/// Does NOT match free function calls or class-name identifiers.
545pub fn method_refs_in_stmts(stmts: &[Stmt<'_, '_>], name: &str, out: &mut Vec<Span>) {
546    let mut v = MethodRefsVisitor {
547        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 MethodRefsVisitor<'a> {
557    name: &'a str,
558    out: Vec<Span>,
559}
560
561impl<'arena, 'src> Visitor<'arena, 'src> for MethodRefsVisitor<'_> {
562    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
563        match &expr.kind {
564            ExprKind::MethodCall(m) | ExprKind::NullsafeMethodCall(m) => {
565                if let ExprKind::Identifier(id) = &m.method.kind
566                    && id.as_str() == self.name
567                {
568                    self.out.push(m.method.span);
569                }
570            }
571            ExprKind::StaticMethodCall(s) if s.method.name_str() == Some(self.name) => {
572                self.out.push(s.method.span);
573            }
574            _ => {}
575        }
576        walk_expr(self, expr)
577    }
578}
579
580// ── Class-constant-reference walker ──────────────────────────────────────────
581
582/// Collect all spans where `const_name` is declared or accessed as a class
583/// constant (`Class::CONST`, `self::CONST`, `parent::CONST`, `static::CONST`).
584///
585/// `class_filter` — when `Some`, only access expressions whose class part
586/// matches the given short name (or is `self`/`parent`/`static`, or is a class
587/// that directly extends the owning class in the same file) are included.
588/// Pass the owning class name to avoid matching same-named constants elsewhere.
589pub fn constant_refs_in_stmts(
590    source: &str,
591    stmts: &[Stmt<'_, '_>],
592    const_name: &str,
593    class_filter: Option<&str>,
594    out: &mut Vec<Span>,
595) {
596    // Pre-build the set of allowed class names: the owner itself plus any class
597    // in this file that directly `extends` the owner (they inherit the constant).
598    let allowed: Option<HashSet<String>> = class_filter.map(|owner| {
599        let mut set = HashSet::new();
600        set.insert(owner.to_string());
601        for stmt in stmts {
602            if let StmtKind::Class(c) = &stmt.kind
603                && let Some(extends) = &c.extends
604                && extends.to_string_repr() == owner
605                && let Some(name) = c.name
606            {
607                set.insert(name.to_string());
608            }
609        }
610        set
611    });
612    let mut v = ConstantRefsVisitor {
613        source,
614        const_name,
615        allowed: allowed.as_ref(),
616        current_class: None,
617        out: Vec::new(),
618    };
619    for stmt in stmts {
620        let _ = v.visit_stmt(stmt);
621    }
622    out.append(&mut v.out);
623}
624
625struct ConstantRefsVisitor<'a> {
626    source: &'a str,
627    const_name: &'a str,
628    /// When `Some`, only include access sites where the class expression matches
629    /// a name in this set or is `self`/`parent`/`static`.
630    allowed: Option<&'a HashSet<String>>,
631    /// The short name of the class currently being visited; used to filter
632    /// `ClassConst` declaration spans to the owning class only.
633    current_class: Option<String>,
634    out: Vec<Span>,
635}
636
637impl<'arena, 'src> Visitor<'arena, 'src> for ConstantRefsVisitor<'_> {
638    fn visit_stmt(&mut self, stmt: &Stmt<'arena, 'src>) -> ControlFlow<()> {
639        let class_name: Option<String> = match &stmt.kind {
640            StmtKind::Class(c) => c.name.map(|n| n.to_string()),
641            StmtKind::Interface(i) => Some(i.name.to_string()),
642            StmtKind::Trait(t) => Some(t.name.to_string()),
643            StmtKind::Enum(e) => Some(e.name.to_string()),
644            _ => {
645                return walk_stmt(self, stmt);
646            }
647        };
648        let prev = self.current_class.take();
649        self.current_class = class_name;
650        let r = walk_stmt(self, stmt);
651        self.current_class = prev;
652        r
653    }
654
655    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
656        if let ExprKind::ClassConstAccess(s) = &expr.kind
657            && let ExprKind::Identifier(name) = &s.member.kind
658            && name.as_str() == self.const_name
659        {
660            let include = self.allowed.is_none_or(|allowed| {
661                if let ExprKind::Identifier(class_id) = &s.class.kind {
662                    let cn = class_id.as_str();
663                    matches!(cn, "self" | "parent" | "static") || allowed.contains(cn)
664                } else {
665                    true
666                }
667            });
668            if include {
669                self.out.push(s.member.span);
670            }
671        }
672        walk_expr(self, expr)
673    }
674
675    fn visit_class_member(&mut self, member: &ClassMember<'arena, 'src>) -> ControlFlow<()> {
676        if let ClassMemberKind::ClassConst(c) = &member.kind
677            && c.name == self.const_name
678        {
679            let class_ok = self.allowed.is_none_or(|allowed| {
680                self.current_class
681                    .as_deref()
682                    .is_none_or(|cls| allowed.contains(cls))
683            });
684            if class_ok {
685                let name = c.name.to_string();
686                let start = str_offset_in_range(self.source, member.span, &name)
687                    .unwrap_or_else(|| str_offset(self.source, &name).unwrap_or(0));
688                self.out.push(Span {
689                    start,
690                    end: start + name.len() as u32,
691                });
692            }
693        }
694        walk_class_member(self, member)
695    }
696
697    fn visit_enum_member(&mut self, member: &EnumMember<'arena, 'src>) -> ControlFlow<()> {
698        if let EnumMemberKind::ClassConst(c) = &member.kind
699            && c.name == self.const_name
700        {
701            let class_ok = self.allowed.is_none_or(|allowed| {
702                self.current_class
703                    .as_deref()
704                    .is_none_or(|cls| allowed.contains(cls))
705            });
706            if class_ok {
707                let name = c.name.to_string();
708                let start = str_offset_in_range(self.source, member.span, &name)
709                    .unwrap_or_else(|| str_offset(self.source, &name).unwrap_or(0));
710                self.out.push(Span {
711                    start,
712                    end: start + name.len() as u32,
713                });
714            }
715        }
716        walk_enum_member(self, member)
717    }
718}
719
720// ── Global constant reference walker ─────────────────────────────────────────
721
722/// Collect spans where `const_name` is used as a global/namespace-level constant.
723/// Matches bare identifiers (`MAX_SIZE`) and, when `const_fqn` is supplied,
724/// also qualified names (`\Config\DB_HOST`, `Config\DB_HOST`). Emits the span
725/// of just the constant name portion (not any namespace qualifier prefix).
726///
727/// Only matches identifiers in value positions — function-call callees, class
728/// names in `new` / static access / `instanceof`, and class-const member names
729/// are excluded.
730///
731/// Also emits the declaration span when the name appears in a `StmtKind::Const`
732/// item (top-level or inside a braced namespace).
733pub fn global_constant_refs_in_stmts(
734    source: &str,
735    stmts: &[Stmt<'_, '_>],
736    const_name: &str,
737    const_fqn: Option<&str>,
738    out: &mut Vec<Span>,
739) {
740    let mut v = GlobalConstRefsVisitor {
741        source,
742        const_name,
743        const_fqn,
744        out: Vec::new(),
745    };
746    for stmt in stmts {
747        let _ = v.visit_stmt(stmt);
748    }
749    out.append(&mut v.out);
750}
751
752struct GlobalConstRefsVisitor<'a> {
753    source: &'a str,
754    const_name: &'a str,
755    /// FQN of the constant (e.g. `"Config\\DB_HOST"`). When set, also matches
756    /// `\Config\DB_HOST` and `Config\DB_HOST` in identifier position, emitting
757    /// a span pointing at just the `DB_HOST` portion.
758    const_fqn: Option<&'a str>,
759    out: Vec<Span>,
760}
761
762impl<'arena, 'src> Visitor<'arena, 'src> for GlobalConstRefsVisitor<'_> {
763    fn visit_stmt(&mut self, stmt: &Stmt<'arena, 'src>) -> ControlFlow<()> {
764        if let StmtKind::Const(items) = &stmt.kind {
765            for item in items.iter() {
766                if item.name == self.const_name {
767                    let name = item.name.to_string();
768                    if let Some(start) = str_offset_in_range(self.source, item.span, &name) {
769                        self.out.push(Span {
770                            start,
771                            end: start + name.len() as u32,
772                        });
773                    }
774                }
775                // Visit the value expression for any constant references inside it.
776                let _ = self.visit_expr(&item.value);
777            }
778            return ControlFlow::Continue(());
779        }
780        // Handle `define('NAME', value)` declaration at statement level.
781        if let StmtKind::Expression(expr) = &stmt.kind
782            && let ExprKind::FunctionCall(f) = &expr.kind
783            && let ExprKind::Identifier(id) = &f.name.kind
784            && id.as_str() == "define"
785            && let Some(first_arg) = f.args.first()
786            && let ExprKind::String(s) = &first_arg.value.kind
787            && *s == self.const_name
788        {
789            // Span covers the string content excluding the surrounding quotes.
790            let start = first_arg.value.span.start + 1;
791            self.out.push(Span {
792                start,
793                end: start + s.len() as u32,
794            });
795            // Don't recurse: the second arg (value) is not a constant reference.
796            return ControlFlow::Continue(());
797        }
798        walk_stmt(self, stmt)
799    }
800
801    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
802        match &expr.kind {
803            ExprKind::Identifier(name) => {
804                let s = name.as_str();
805                // Bare name in same namespace.
806                let name_offset = if s == self.const_name {
807                    Some(0usize)
808                } else if let Some(fqn) = self.const_fqn {
809                    // Qualified: `Config\DB_HOST` or `\Config\DB_HOST`.
810                    let bare_fqn = s.trim_start_matches('\\');
811                    if bare_fqn == fqn {
812                        // Offset of const_name within the identifier string.
813                        Some(s.len() - self.const_name.len())
814                    } else {
815                        None
816                    }
817                } else {
818                    None
819                };
820                if let Some(off) = name_offset {
821                    let start = expr.span.start + off as u32;
822                    self.out.push(Span {
823                        start,
824                        end: start + self.const_name.len() as u32,
825                    });
826                }
827                ControlFlow::Continue(())
828            }
829
830            // Function call: skip the callee name, only visit arguments.
831            ExprKind::FunctionCall(f) => {
832                for arg in f.args.iter() {
833                    let _ = self.visit_arg(arg);
834                }
835                ControlFlow::Continue(())
836            }
837
838            // Static method call: skip class and method names, only visit arguments.
839            ExprKind::StaticMethodCall(call) => {
840                for arg in call.args.iter() {
841                    let _ = self.visit_arg(arg);
842                }
843                ControlFlow::Continue(())
844            }
845            ExprKind::StaticDynMethodCall(call) => {
846                for arg in call.args.iter() {
847                    let _ = self.visit_arg(arg);
848                }
849                ControlFlow::Continue(())
850            }
851
852            // New expression: skip the class name, only visit constructor arguments.
853            ExprKind::New(new_expr) => {
854                for arg in new_expr.args.iter() {
855                    let _ = self.visit_arg(arg);
856                }
857                ControlFlow::Continue(())
858            }
859
860            // Static property / class-const access: skip entirely (class names and
861            // class-const member names are not global constant references).
862            ExprKind::StaticPropertyAccess(_)
863            | ExprKind::ClassConstAccess(_)
864            | ExprKind::ClassConstAccessDynamic { .. }
865            | ExprKind::StaticPropertyAccessDynamic { .. } => ControlFlow::Continue(()),
866
867            // instanceof right-hand side is a class name, not a constant.
868            ExprKind::Binary(b) if b.op == php_ast::BinaryOp::Instanceof => {
869                let _ = self.visit_expr(b.left);
870                // Skip b.right — it's a class name identifier, not a constant.
871                ControlFlow::Continue(())
872            }
873
874            _ => walk_expr(self, expr),
875        }
876    }
877}
878
879// ── Class-reference walker ────────────────────────────────────────────────────
880
881/// Collect spans for `new ClassName(...)` expressions only — excludes type hints,
882/// `instanceof`, `extends`, `implements`, and static calls.
883///
884/// `class_fqn` — when `Some`, FQN-qualified identifiers in the source (those
885/// containing `\`) are compared against the FQN rather than just the short name,
886/// preventing false positives when two classes share a short name across namespaces.
887pub fn new_refs_in_stmts(
888    stmts: &[Stmt<'_, '_>],
889    class_name: &str,
890    class_fqn: Option<&str>,
891    out: &mut Vec<Span>,
892) {
893    let mut v = NewRefsVisitor {
894        class_name,
895        class_fqn,
896        out: Vec::new(),
897    };
898    for stmt in stmts {
899        let _ = v.visit_stmt(stmt);
900    }
901    out.append(&mut v.out);
902}
903
904struct NewRefsVisitor<'a> {
905    class_name: &'a str,
906    class_fqn: Option<&'a str>,
907    out: Vec<Span>,
908}
909
910impl<'arena, 'src> Visitor<'arena, 'src> for NewRefsVisitor<'_> {
911    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
912        if let ExprKind::New(n) = &expr.kind
913            && let ExprKind::Identifier(id) = &n.class.kind
914        {
915            let matches = if id.contains('\\')
916                && let Some(fqn) = self.class_fqn
917            {
918                // Fully-qualified identifier: compare by FQN for exact namespace match.
919                id.trim_start_matches('\\') == fqn.trim_start_matches('\\')
920            } else {
921                fqn_short_name(id) == self.class_name
922            };
923            if matches {
924                self.out.push(n.class.span);
925            }
926        }
927        walk_expr(self, expr)
928    }
929}
930
931/// Collect every fully-qualified class name (i.e. starting with `\`) that
932/// appears as the class argument of a `new` expression in `stmts`.
933/// Returns de-duplicated FQCN strings with the leading `\` stripped, ready to
934/// pass to `session.lazy_load_class`.
935pub fn fqn_new_class_refs_in_stmts(stmts: &[Stmt<'_, '_>]) -> Vec<String> {
936    let mut v = FqnNewRefsVisitor { out: Vec::new() };
937    for stmt in stmts {
938        let _ = v.visit_stmt(stmt);
939    }
940    v.out.sort_unstable();
941    v.out.dedup();
942    v.out
943}
944
945struct FqnNewRefsVisitor {
946    out: Vec<String>,
947}
948
949impl<'arena, 'src> Visitor<'arena, 'src> for FqnNewRefsVisitor {
950    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
951        if let ExprKind::New(n) = &expr.kind
952            && let ExprKind::Identifier(id) = &n.class.kind
953            && id.starts_with('\\')
954        {
955            self.out.push(id.trim_start_matches('\\').to_string());
956        }
957        walk_expr(self, expr)
958    }
959}
960
961/// Collect every class-typed name reference (extends, implements, new,
962/// instanceof, type hints, static method/property/const access, catch types).
963/// Each entry is the source-spelling of the reference (`Foo`, `Sub\Foo`, or
964/// `\Foo`) — callers apply namespace/use resolution to obtain an FQN.
965///
966/// Returned vec is sorted + de-duplicated by exact spelling.
967pub fn all_class_ref_names_in_stmts(stmts: &[Stmt<'_, '_>]) -> Vec<String> {
968    let mut v = AllClassRefsVisitor { out: Vec::new() };
969    for stmt in stmts {
970        let _ = v.visit_stmt(stmt);
971    }
972    v.out.sort_unstable();
973    v.out.dedup();
974    v.out
975}
976
977struct AllClassRefsVisitor {
978    out: Vec<String>,
979}
980
981impl AllClassRefsVisitor {
982    fn push_name(&mut self, name: &Name<'_, '_>) {
983        self.out.push(name.to_string_repr().into_owned());
984    }
985
986    fn push_id(&mut self, id: &str) {
987        self.out.push(id.to_string());
988    }
989}
990
991impl<'arena, 'src> Visitor<'arena, 'src> for AllClassRefsVisitor {
992    fn visit_stmt(&mut self, stmt: &Stmt<'arena, 'src>) -> ControlFlow<()> {
993        match &stmt.kind {
994            StmtKind::Class(c) => {
995                if let Some(ext) = &c.extends {
996                    self.push_name(ext);
997                }
998                for iface in c.implements.iter() {
999                    self.push_name(iface);
1000                }
1001            }
1002            StmtKind::Interface(i) => {
1003                for parent in i.extends.iter() {
1004                    self.push_name(parent);
1005                }
1006            }
1007            _ => {}
1008        }
1009        walk_stmt(self, stmt)
1010    }
1011
1012    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
1013        match &expr.kind {
1014            ExprKind::New(n) => {
1015                if let ExprKind::Identifier(id) = &n.class.kind {
1016                    self.push_id(id);
1017                }
1018            }
1019            ExprKind::AnonymousClass(c) => {
1020                if let Some(ext) = &c.extends {
1021                    self.push_name(ext);
1022                }
1023                for iface in c.implements.iter() {
1024                    self.push_name(iface);
1025                }
1026            }
1027            ExprKind::Binary(b) => {
1028                // `$x instanceof Foo` — parser models this as a Binary expr
1029                // whose right-hand side is an Identifier.
1030                if let ExprKind::Identifier(id) = &b.right.kind {
1031                    self.push_id(id);
1032                }
1033            }
1034            ExprKind::StaticMethodCall(s) => {
1035                if let ExprKind::Identifier(id) = &s.class.kind {
1036                    self.push_id(id);
1037                }
1038            }
1039            ExprKind::StaticPropertyAccess(s) => {
1040                if let ExprKind::Identifier(id) = &s.class.kind {
1041                    self.push_id(id);
1042                }
1043            }
1044            ExprKind::ClassConstAccess(c) => {
1045                if let ExprKind::Identifier(id) = &c.class.kind {
1046                    self.push_id(id);
1047                }
1048            }
1049            _ => {}
1050        }
1051        walk_expr(self, expr)
1052    }
1053
1054    fn visit_attribute(&mut self, attribute: &Attribute<'arena, 'src>) -> ControlFlow<()> {
1055        self.push_name(&attribute.name);
1056        walk_attribute(self, attribute)
1057    }
1058
1059    fn visit_type_hint(&mut self, type_hint: &TypeHint<'arena, 'src>) -> ControlFlow<()> {
1060        match &type_hint.kind {
1061            TypeHintKind::Named(name) => {
1062                self.push_name(name);
1063                walk_type_hint(self, type_hint)
1064            }
1065            TypeHintKind::Nullable(_) => walk_type_hint(self, type_hint),
1066            TypeHintKind::Union(types) => {
1067                for inner in types.iter() {
1068                    let _ = self.visit_type_hint(inner);
1069                }
1070                ControlFlow::Continue(())
1071            }
1072            TypeHintKind::Intersection(types) => {
1073                for inner in types.iter() {
1074                    let _ = self.visit_type_hint(inner);
1075                }
1076                ControlFlow::Continue(())
1077            }
1078            TypeHintKind::Keyword(_, _) => ControlFlow::Continue(()),
1079        }
1080    }
1081
1082    fn visit_catch_clause(&mut self, catch: &CatchClause<'arena, 'src>) -> ControlFlow<()> {
1083        for ty in catch.types.iter() {
1084            self.push_name(ty);
1085        }
1086        walk_catch_clause(self, catch)
1087    }
1088
1089    fn visit_trait_use(&mut self, trait_use: &TraitUseDecl<'arena, 'src>) -> ControlFlow<()> {
1090        for name in trait_use.traits.iter() {
1091            self.push_name(name);
1092        }
1093        walk_trait_use(self, trait_use)
1094    }
1095}
1096
1097/// `new ClassName`, `extends ClassName`, `implements ClassName`, type hints,
1098/// and `$x instanceof ClassName`. Does NOT match free function calls or
1099/// method names with the same spelling.
1100pub fn class_refs_in_stmts(stmts: &[Stmt<'_, '_>], class_name: &str, out: &mut Vec<Span>) {
1101    let mut v = ClassRefsVisitor {
1102        class_name,
1103        out: Vec::new(),
1104    };
1105    for stmt in stmts {
1106        let _ = v.visit_stmt(stmt);
1107    }
1108    out.append(&mut v.out);
1109}
1110
1111struct ClassRefsVisitor<'a> {
1112    class_name: &'a str,
1113    out: Vec<Span>,
1114}
1115
1116impl ClassRefsVisitor<'_> {
1117    /// Push the span of the last segment of `name` if it matches `class_name`.
1118    fn collect_name<'a, 'b>(&mut self, name: &Name<'a, 'b>) {
1119        let repr = name.to_string_repr();
1120        let last = fqn_short_name(&repr);
1121        if last == self.class_name {
1122            let span = name.span();
1123            let offset = (repr.len() - last.len()) as u32;
1124            self.out.push(Span {
1125                start: span.start + offset,
1126                end: span.end,
1127            });
1128        }
1129    }
1130}
1131
1132impl<'arena, 'src> Visitor<'arena, 'src> for ClassRefsVisitor<'_> {
1133    fn visit_stmt(&mut self, stmt: &Stmt<'arena, 'src>) -> ControlFlow<()> {
1134        match &stmt.kind {
1135            StmtKind::Class(c) => {
1136                if let Some(ext) = &c.extends {
1137                    self.collect_name(ext);
1138                }
1139                for iface in c.implements.iter() {
1140                    self.collect_name(iface);
1141                }
1142            }
1143            StmtKind::Interface(i) => {
1144                for parent in i.extends.iter() {
1145                    self.collect_name(parent);
1146                }
1147            }
1148            _ => {}
1149        }
1150        walk_stmt(self, stmt)
1151    }
1152
1153    fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
1154        match &expr.kind {
1155            ExprKind::New(n) => {
1156                if let ExprKind::Identifier(id) = &n.class.kind
1157                    && fqn_short_name(id) == self.class_name
1158                {
1159                    self.out.push(n.class.span);
1160                }
1161            }
1162            ExprKind::AnonymousClass(c) => {
1163                if let Some(ext) = &c.extends {
1164                    self.collect_name(ext);
1165                }
1166                for iface in c.implements.iter() {
1167                    self.collect_name(iface);
1168                }
1169            }
1170            ExprKind::Binary(b) => {
1171                if let ExprKind::Identifier(id) = &b.right.kind
1172                    && fqn_short_name(id) == self.class_name
1173                {
1174                    self.out.push(b.right.span);
1175                }
1176            }
1177            ExprKind::StaticMethodCall(s) => {
1178                if let ExprKind::Identifier(id) = &s.class.kind
1179                    && fqn_short_name(id) == self.class_name
1180                {
1181                    self.out.push(s.class.span);
1182                }
1183            }
1184            ExprKind::StaticPropertyAccess(s) => {
1185                if let ExprKind::Identifier(id) = &s.class.kind
1186                    && fqn_short_name(id) == self.class_name
1187                {
1188                    self.out.push(s.class.span);
1189                }
1190            }
1191            ExprKind::ClassConstAccess(c) => {
1192                if let ExprKind::Identifier(id) = &c.class.kind
1193                    && fqn_short_name(id) == self.class_name
1194                {
1195                    self.out.push(c.class.span);
1196                }
1197            }
1198            _ => {}
1199        }
1200        walk_expr(self, expr)
1201    }
1202
1203    fn visit_attribute(&mut self, attribute: &Attribute<'arena, 'src>) -> ControlFlow<()> {
1204        self.collect_name(&attribute.name);
1205        walk_attribute(self, attribute)
1206    }
1207
1208    fn visit_type_hint(&mut self, type_hint: &TypeHint<'arena, 'src>) -> ControlFlow<()> {
1209        match &type_hint.kind {
1210            TypeHintKind::Named(name) => {
1211                self.collect_name(name);
1212                walk_type_hint(self, type_hint)
1213            }
1214            TypeHintKind::Nullable(_) => walk_type_hint(self, type_hint),
1215            TypeHintKind::Union(types) => {
1216                for inner in types.iter() {
1217                    let _ = self.visit_type_hint(inner);
1218                }
1219                ControlFlow::Continue(())
1220            }
1221            TypeHintKind::Intersection(types) => {
1222                for inner in types.iter() {
1223                    let _ = self.visit_type_hint(inner);
1224                }
1225                ControlFlow::Continue(())
1226            }
1227            TypeHintKind::Keyword(_, _) => ControlFlow::Continue(()),
1228        }
1229    }
1230
1231    fn visit_catch_clause(&mut self, catch: &CatchClause<'arena, 'src>) -> ControlFlow<()> {
1232        for ty in catch.types.iter() {
1233            self.collect_name(ty);
1234        }
1235        walk_catch_clause(self, catch)
1236    }
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241    use super::*;
1242    use crate::ast::ParsedDoc;
1243
1244    /// Return all substrings of `source` at the given spans.
1245    fn spans_to_strs<'a>(source: &'a str, spans: &[Span]) -> Vec<&'a str> {
1246        spans
1247            .iter()
1248            .map(|s| &source[s.start as usize..s.end as usize])
1249            .collect()
1250    }
1251
1252    fn parse(src: &str) -> ParsedDoc {
1253        ParsedDoc::parse(src.to_string())
1254    }
1255
1256    // ── refs_in_stmts ────────────────────────────────────────────────────────
1257
1258    #[test]
1259    fn refs_finds_function_declaration_and_call() {
1260        let src = "<?php\nfunction greet() {}\ngreet();";
1261        let doc = parse(src);
1262        let mut out = vec![];
1263        refs_in_stmts(src, &doc.program().stmts, "greet", &mut out);
1264        let texts = spans_to_strs(src, &out);
1265        assert!(texts.contains(&"greet"), "expected function decl name");
1266        assert_eq!(texts.iter().filter(|&&t| t == "greet").count(), 2);
1267    }
1268
1269    #[test]
1270    fn refs_finds_class_declaration_and_new() {
1271        let src = "<?php\nclass Foo {}\n$x = new Foo();";
1272        let doc = parse(src);
1273        let mut out = vec![];
1274        refs_in_stmts(src, &doc.program().stmts, "Foo", &mut out);
1275        let texts = spans_to_strs(src, &out);
1276        assert!(texts.iter().all(|&t| t == "Foo"));
1277        assert_eq!(texts.len(), 2);
1278    }
1279
1280    #[test]
1281    fn refs_finds_method_declaration_inside_class() {
1282        let src = "<?php\nclass Bar { function run() { $this->run(); } }";
1283        let doc = parse(src);
1284        let mut out = vec![];
1285        refs_in_stmts(src, &doc.program().stmts, "run", &mut out);
1286        let texts = spans_to_strs(src, &out);
1287        // method decl name + method call name both appear
1288        assert!(texts.iter().any(|&t| t == "run"));
1289    }
1290
1291    #[test]
1292    fn refs_returns_empty_for_unknown_name() {
1293        let src = "<?php\nfunction greet() {}";
1294        let doc = parse(src);
1295        let mut out = vec![];
1296        refs_in_stmts(src, &doc.program().stmts, "nope", &mut out);
1297        assert!(out.is_empty());
1298    }
1299
1300    // ── refs_in_stmts_with_use ───────────────────────────────────────────────
1301
1302    #[test]
1303    fn refs_with_use_includes_use_import() {
1304        let src = "<?php\nuse Vendor\\Lib\\Foo;\n$x = new Foo();";
1305        let doc = parse(src);
1306        let mut out = vec![];
1307        refs_in_stmts_with_use(src, &doc.program().stmts, "Foo", &mut out);
1308        let texts = spans_to_strs(src, &out);
1309        // Should see the `Foo` segment in the use statement + the new Foo()
1310        assert!(
1311            texts.iter().filter(|&&t| t == "Foo").count() >= 2,
1312            "got: {texts:?}"
1313        );
1314    }
1315
1316    #[test]
1317    fn refs_without_use_misses_use_import() {
1318        let src = "<?php\nuse Vendor\\Lib\\Foo;\n$x = new Foo();";
1319        let doc = parse(src);
1320        let mut out = vec![];
1321        refs_in_stmts(src, &doc.program().stmts, "Foo", &mut out);
1322        let texts = spans_to_strs(src, &out);
1323        // refs_in_stmts does NOT walk use statements
1324        assert!(
1325            texts.iter().filter(|&&t| t == "Foo").count() < 2,
1326            "refs_in_stmts should not include use import; got: {texts:?}"
1327        );
1328    }
1329
1330    // ── var_refs_in_stmts ────────────────────────────────────────────────────
1331
1332    #[test]
1333    fn var_refs_finds_variable_in_assignment_and_echo() {
1334        let src = "<?php\n$x = 1;\necho $x;";
1335        let doc = parse(src);
1336        let mut out = vec![];
1337        var_refs_in_stmts(&doc.program().stmts, "x", &mut out);
1338        assert_eq!(out.len(), 2, "expected $x in assignment and echo");
1339    }
1340
1341    #[test]
1342    fn var_refs_respects_function_scope_boundary() {
1343        // $x inside the nested function is a separate scope — must not be collected.
1344        let src = "<?php\n$x = 1;\nfunction inner() { $x = 2; }";
1345        let doc = parse(src);
1346        let mut out = vec![];
1347        var_refs_in_stmts(&doc.program().stmts, "x", &mut out);
1348        // Only the top-level $x = 1; should be found (function is a scope boundary).
1349        assert_eq!(out.len(), 1, "inner $x must not cross scope boundary");
1350    }
1351
1352    #[test]
1353    fn var_refs_traverses_if_while_for_foreach() {
1354        let src = "<?php\n$x = 0;\nif ($x) { $x++; }\nwhile ($x > 0) { $x--; }\nfor ($x = 0; $x < 3; $x++) {}\nforeach ([$x] as $v) {}";
1355        let doc = parse(src);
1356        let mut out = vec![];
1357        var_refs_in_stmts(&doc.program().stmts, "x", &mut out);
1358        assert!(
1359            out.len() >= 5,
1360            "expected multiple $x refs, got {}",
1361            out.len()
1362        );
1363    }
1364
1365    #[test]
1366    fn var_refs_does_not_cross_closure_boundary() {
1367        let src = "<?php\n$x = 1;\n$f = function() { $x = 2; };";
1368        let doc = parse(src);
1369        let mut out = vec![];
1370        var_refs_in_stmts(&doc.program().stmts, "x", &mut out);
1371        // Closure is a scope boundary — inner $x not collected.
1372        assert_eq!(
1373            out.len(),
1374            1,
1375            "closure $x must not be collected by outer scope walk"
1376        );
1377    }
1378
1379    // ── collect_var_refs_in_scope ────────────────────────────────────────────
1380
1381    #[test]
1382    fn collect_scope_finds_var_inside_function() {
1383        let src = "<?php\nfunction foo($x) { return $x + 1; }";
1384        let doc = parse(src);
1385        // byte_off somewhere inside the function body
1386        let byte_off = src.find("return").unwrap();
1387        let mut out = vec![];
1388        collect_var_refs_in_scope(&doc.program().stmts, "x", byte_off, &mut out);
1389        // Should find the param span and the $x in return
1390        assert!(
1391            out.len() >= 2,
1392            "expected param + body ref, got {}",
1393            out.len()
1394        );
1395    }
1396
1397    #[test]
1398    fn collect_scope_top_level_when_no_function() {
1399        let src = "<?php\n$x = 1;\necho $x;";
1400        let doc = parse(src);
1401        let byte_off = src.find("echo").unwrap();
1402        let mut out = vec![];
1403        collect_var_refs_in_scope(&doc.program().stmts, "x", byte_off, &mut out);
1404        assert_eq!(out.len(), 2);
1405    }
1406
1407    #[test]
1408    fn collect_scope_finds_var_inside_enum_method() {
1409        let src = "<?php\nenum Status {\n    public function label($arg) { return $arg; }\n}";
1410        let doc = parse(src);
1411        let byte_off = src.find("return").unwrap();
1412        let mut out = vec![];
1413        collect_var_refs_in_scope(&doc.program().stmts, "arg", byte_off, &mut out);
1414        assert!(
1415            out.len() >= 2,
1416            "expected param + body ref in enum method, got {}",
1417            out.len()
1418        );
1419    }
1420
1421    #[test]
1422    fn collect_scope_does_not_bleed_enum_method_into_outer_scope() {
1423        let src =
1424            "<?php\n$arg = 1;\nenum Status {\n    public function label($arg) { return $arg; }\n}";
1425        let doc = parse(src);
1426        // cursor is at the top-level $arg = 1, outside the enum
1427        let byte_off = src.find("$arg").unwrap();
1428        let mut out = vec![];
1429        collect_var_refs_in_scope(&doc.program().stmts, "arg", byte_off, &mut out);
1430        // only the top-level $arg should be found, not the enum method param
1431        assert_eq!(
1432            out.len(),
1433            1,
1434            "enum method $arg must not bleed into outer scope"
1435        );
1436    }
1437
1438    // ── property_refs_in_stmts ───────────────────────────────────────────────
1439
1440    #[test]
1441    fn property_refs_finds_declaration_and_access() {
1442        let src = "<?php\nclass Baz { public int $val = 0; function get() { return $this->val; } }";
1443        let doc = parse(src);
1444        let mut out = vec![];
1445        property_refs_in_stmts(src, &doc.program().stmts, "val", &mut out);
1446        // property declaration + $this->val access
1447        assert_eq!(out.len(), 2, "expected decl + access, got {}", out.len());
1448    }
1449
1450    #[test]
1451    fn property_refs_finds_nullsafe_access() {
1452        let src = "<?php\n$r = $obj?->name;";
1453        let doc = parse(src);
1454        let mut out = vec![];
1455        property_refs_in_stmts(src, &doc.program().stmts, "name", &mut out);
1456        assert_eq!(out.len(), 1);
1457    }
1458
1459    #[test]
1460    fn property_refs_finds_static_access() {
1461        let src = "<?php\nclass Reg { public static int $val = 0; }\nReg::$val;\nReg::$val = 1;";
1462        let doc = parse(src);
1463        let mut out = vec![];
1464        property_refs_in_stmts(src, &doc.program().stmts, "val", &mut out);
1465        // declaration + two static access sites
1466        assert_eq!(out.len(), 3, "expected decl + 2 accesses, got: {out:?}");
1467    }
1468
1469    // ── constant_refs_in_stmts ──────────────────────────────────────────────
1470
1471    #[test]
1472    fn constant_refs_finds_decl_and_class_access() {
1473        let src = "<?php\nclass S { const ACTIVE = 1; }\n$x = S::ACTIVE;\nif ($v === S::ACTIVE) {}";
1474        let doc = parse(src);
1475        let mut out = vec![];
1476        constant_refs_in_stmts(src, &doc.program().stmts, "ACTIVE", None, &mut out);
1477        // declaration + 2 access sites
1478        assert_eq!(out.len(), 3, "expected decl + 2 accesses, got: {out:?}");
1479    }
1480
1481    #[test]
1482    fn constant_refs_finds_self_and_parent_access() {
1483        let src = "<?php\nclass Base { const V = 1; }\nclass Child extends Base { public function f(): int { return parent::V; } }";
1484        let doc = parse(src);
1485        let mut out = vec![];
1486        constant_refs_in_stmts(src, &doc.program().stmts, "V", Some("Base"), &mut out);
1487        let texts = spans_to_strs(src, &out);
1488        // should find: declaration in Base + parent::V access in Child
1489        assert!(
1490            out.len() >= 2,
1491            "expected decl + parent::V access, got: {texts:?}"
1492        );
1493    }
1494
1495    #[test]
1496    fn constant_refs_parent_reference_full_source() {
1497        let src = "<?php\nclass Base {\n    const VERSION = '1.0';\n}\n\nclass Extended extends Base {\n    public function getVersion(): string {\n        return parent::VERSION;\n    }\n}\n\necho Extended::VERSION;";
1498        let doc = parse(src);
1499        let mut out = vec![];
1500        constant_refs_in_stmts(src, &doc.program().stmts, "VERSION", Some("Base"), &mut out);
1501        let texts = spans_to_strs(src, &out);
1502        assert!(
1503            out.len() >= 3,
1504            "expected decl + parent::VERSION + Extended::VERSION = 3, got {}: {texts:?}",
1505            out.len()
1506        );
1507    }
1508
1509    #[test]
1510    fn constant_refs_filters_same_name_different_class() {
1511        let src = "<?php\nclass A { const X = 1; }\nclass B { const X = 2; }\nA::X;\nB::X;";
1512        let doc = parse(src);
1513        let mut out = vec![];
1514        constant_refs_in_stmts(src, &doc.program().stmts, "X", Some("A"), &mut out);
1515        // should NOT include B's declaration or B::X
1516        let texts = spans_to_strs(src, &out);
1517        assert!(!texts.is_empty(), "should find A::X: {texts:?}");
1518    }
1519
1520    // ── function_refs_in_stmts ───────────────────────────────────────────────
1521
1522    #[test]
1523    fn function_refs_only_matches_free_calls_not_methods() {
1524        let src = "<?php\nfunction run() {}\nrun();\n$obj->run();";
1525        let doc = parse(src);
1526        let mut out = vec![];
1527        function_refs_in_stmts(&doc.program().stmts, "run", &mut out);
1528        // Only the free call `run()` should match; `$obj->run()` must not.
1529        assert_eq!(out.len(), 1, "got: {out:?}");
1530    }
1531
1532    // ── method_refs_in_stmts ─────────────────────────────────────────────────
1533
1534    #[test]
1535    fn method_refs_only_matches_method_calls_not_free_functions() {
1536        let src = "<?php\nfunction run() {}\nrun();\n$obj->run();";
1537        let doc = parse(src);
1538        let mut out = vec![];
1539        method_refs_in_stmts(&doc.program().stmts, "run", &mut out);
1540        // Only `$obj->run()` method name span should match.
1541        assert_eq!(out.len(), 1, "got: {out:?}");
1542    }
1543
1544    #[test]
1545    fn method_refs_finds_nullsafe_method_call() {
1546        let src = "<?php\n$obj?->process();";
1547        let doc = parse(src);
1548        let mut out = vec![];
1549        method_refs_in_stmts(&doc.program().stmts, "process", &mut out);
1550        assert_eq!(out.len(), 1);
1551    }
1552
1553    // ── class_refs_in_stmts ──────────────────────────────────────────────────
1554
1555    #[test]
1556    fn class_refs_finds_new_and_extends() {
1557        let src = "<?php\nclass Child extends Base {}\n$x = new Base();";
1558        let doc = parse(src);
1559        let mut out = vec![];
1560        class_refs_in_stmts(&doc.program().stmts, "Base", &mut out);
1561        assert!(out.len() >= 2, "expected extends + new, got {}", out.len());
1562    }
1563
1564    #[test]
1565    fn class_refs_does_not_match_free_function_with_same_name() {
1566        let src = "<?php\nfunction Foo() {}\nFoo();";
1567        let doc = parse(src);
1568        let mut out = vec![];
1569        class_refs_in_stmts(&doc.program().stmts, "Foo", &mut out);
1570        assert!(
1571            out.is_empty(),
1572            "free function call must not be a class ref; got: {out:?}"
1573        );
1574    }
1575
1576    #[test]
1577    fn class_refs_finds_type_hint_in_function_param() {
1578        let src = "<?php\nfunction take(MyClass $obj): MyClass { return $obj; }";
1579        let doc = parse(src);
1580        let mut out = vec![];
1581        class_refs_in_stmts(&doc.program().stmts, "MyClass", &mut out);
1582        // param type hint + return type hint
1583        assert_eq!(out.len(), 2, "got {out:?}");
1584    }
1585
1586    // ── all_class_ref_names_in_stmts ─────────────────────────────────────────
1587
1588    #[test]
1589    fn all_class_refs_collects_extends_and_implements() {
1590        let src = "<?php\nclass A extends B implements C, D {}";
1591        let doc = parse(src);
1592        let out = all_class_ref_names_in_stmts(&doc.program().stmts);
1593        assert_eq!(out, vec!["B", "C", "D"]);
1594    }
1595
1596    #[test]
1597    fn all_class_refs_collects_interface_extends() {
1598        let src = "<?php\ninterface I extends J, K {}";
1599        let doc = parse(src);
1600        let out = all_class_ref_names_in_stmts(&doc.program().stmts);
1601        assert_eq!(out, vec!["J", "K"]);
1602    }
1603
1604    #[test]
1605    fn all_class_refs_collects_new_bare_and_fqn() {
1606        let src = "<?php\n$a = new Local();\n$b = new \\Vendor\\Pkg\\Cls();";
1607        let doc = parse(src);
1608        let out = all_class_ref_names_in_stmts(&doc.program().stmts);
1609        assert!(out.contains(&"Local".to_string()));
1610        assert!(out.contains(&"\\Vendor\\Pkg\\Cls".to_string()));
1611    }
1612
1613    #[test]
1614    fn all_class_refs_collects_instanceof() {
1615        let src = "<?php\nif ($x instanceof MyClass) {}";
1616        let doc = parse(src);
1617        let out = all_class_ref_names_in_stmts(&doc.program().stmts);
1618        assert!(out.contains(&"MyClass".to_string()));
1619    }
1620
1621    #[test]
1622    fn all_class_refs_collects_static_call_property_const() {
1623        let src = "<?php\nA::method();\nB::$prop;\nC::CONST;\n$x = D::class;";
1624        let doc = parse(src);
1625        let out = all_class_ref_names_in_stmts(&doc.program().stmts);
1626        assert!(out.contains(&"A".to_string()), "A::method() — got {out:?}");
1627        assert!(out.contains(&"B".to_string()), "B::$prop — got {out:?}");
1628        assert!(out.contains(&"C".to_string()), "C::CONST — got {out:?}");
1629        assert!(out.contains(&"D".to_string()), "D::class — got {out:?}");
1630    }
1631
1632    #[test]
1633    fn all_class_refs_collects_type_hints_in_all_positions() {
1634        let src = "<?php\nclass C {\n    public P $prop;\n    public function f(Q $q): R { return $q; }\n}";
1635        let doc = parse(src);
1636        let out = all_class_ref_names_in_stmts(&doc.program().stmts);
1637        assert!(
1638            out.contains(&"P".to_string()),
1639            "property type — got {out:?}"
1640        );
1641        assert!(out.contains(&"Q".to_string()), "param type — got {out:?}");
1642        assert!(out.contains(&"R".to_string()), "return type — got {out:?}");
1643    }
1644
1645    #[test]
1646    fn all_class_refs_collects_catch_types() {
1647        let src = "<?php\ntry {} catch (FirstException | SecondException $e) {}";
1648        let doc = parse(src);
1649        let out = all_class_ref_names_in_stmts(&doc.program().stmts);
1650        assert!(out.contains(&"FirstException".to_string()));
1651        assert!(out.contains(&"SecondException".to_string()));
1652    }
1653
1654    #[test]
1655    fn all_class_refs_does_not_collect_free_function_calls_or_method_names() {
1656        let src = "<?php\nrun();\n$obj->run();";
1657        let doc = parse(src);
1658        let out = all_class_ref_names_in_stmts(&doc.program().stmts);
1659        assert!(
1660            !out.contains(&"run".to_string()),
1661            "function call / method must not be a class ref; got {out:?}"
1662        );
1663    }
1664
1665    #[test]
1666    fn all_class_refs_collects_trait_use_in_class() {
1667        let src = "<?php\nclass C {\n    use TraitOne, TraitTwo;\n}";
1668        let doc = parse(src);
1669        let out = all_class_ref_names_in_stmts(&doc.program().stmts);
1670        assert!(out.contains(&"TraitOne".to_string()), "got {out:?}");
1671        assert!(out.contains(&"TraitTwo".to_string()), "got {out:?}");
1672    }
1673
1674    #[test]
1675    fn all_class_refs_collects_trait_use_in_enum() {
1676        let src = "<?php\nenum E: int {\n    use TraitEnum;\n    case A = 1;\n}";
1677        let doc = parse(src);
1678        let out = all_class_ref_names_in_stmts(&doc.program().stmts);
1679        assert!(out.contains(&"TraitEnum".to_string()), "got {out:?}");
1680    }
1681
1682    #[test]
1683    fn all_class_refs_deduplicates() {
1684        let src = "<?php\n$a = new X();\n$b = new X();\n$c instanceof X;";
1685        let doc = parse(src);
1686        let out = all_class_ref_names_in_stmts(&doc.program().stmts);
1687        assert_eq!(out.iter().filter(|s| s == &"X").count(), 1);
1688    }
1689
1690    #[test]
1691    fn all_class_refs_collects_attribute_names() {
1692        let src = "<?php\n#[MyAttr]\nclass Foo {}\n#[ORM\\Entity]\nclass Bar {}";
1693        let doc = parse(src);
1694        let out = all_class_ref_names_in_stmts(&doc.program().stmts);
1695        assert!(
1696            out.contains(&"MyAttr".to_string()),
1697            "simple attribute — got {out:?}"
1698        );
1699        assert!(
1700            out.contains(&"ORM\\Entity".to_string()),
1701            "qualified attribute — got {out:?}"
1702        );
1703    }
1704
1705    #[test]
1706    fn all_class_refs_collects_anonymous_class_extends_and_implements() {
1707        let src = "<?php\n$x = new class extends Base implements Countable {};";
1708        let doc = parse(src);
1709        let out = all_class_ref_names_in_stmts(&doc.program().stmts);
1710        assert!(
1711            out.contains(&"Base".to_string()),
1712            "anon class extends — got {out:?}"
1713        );
1714        assert!(
1715            out.contains(&"Countable".to_string()),
1716            "anon class implements — got {out:?}"
1717        );
1718    }
1719}