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