use std::collections::HashSet;
use mago_span::HasSpan;
use mago_syntax::ast::*;
use tower_lsp::lsp_types::*;
use crate::Backend;
use crate::parser::with_parsed_program;
use crate::scope_collector::{
AccessKind, ByRefCallKind, ByRefResolver, FrameKind, ScopeMap,
collect_function_scope_with_kind_and_resolver, collect_function_scope_with_resolver,
};
use super::helpers::make_diagnostic;
use super::offset_range_to_lsp_range;
pub(crate) const UNDEFINED_VARIABLE_CODE: &str = "undefined_variable";
const SUPERGLOBALS: &[&str] = &[
"$_GET",
"$_POST",
"$_SERVER",
"$_REQUEST",
"$_SESSION",
"$_COOKIE",
"$_FILES",
"$_ENV",
"$GLOBALS",
"$argc",
"$argv",
"$http_response_header",
"$php_errormsg",
];
impl Backend {
pub fn collect_undefined_variable_diagnostics(
&self,
_uri: &str,
content: &str,
out: &mut Vec<Diagnostic>,
) {
let resolver: ByRefResolver<'_> =
&|call_kind: &ByRefCallKind<'_>| self.resolve_by_ref_positions(call_kind);
with_parsed_program(content, "undefined_variable", |program, content| {
let mut ctx = DiagnosticCtx {
content,
diagnostics: Vec::new(),
};
for stmt in program.statements.iter() {
collect_from_statement(stmt, &mut ctx, Some(&resolver));
}
out.extend(ctx.diagnostics);
});
}
fn resolve_by_ref_positions(&self, call_kind: &ByRefCallKind<'_>) -> Option<Vec<usize>> {
match call_kind {
ByRefCallKind::Function(name) => {
let candidates: Vec<&str> = vec![*name];
let func_info = self.find_or_load_function(&candidates)?;
let positions: Vec<usize> = func_info
.parameters
.iter()
.enumerate()
.filter(|(_, p)| p.is_reference)
.map(|(i, _)| i)
.collect();
Some(positions)
}
ByRefCallKind::StaticMethod(class_name, method_name) => {
let cls = self.find_or_load_class(class_name)?;
let method = cls.methods.iter().find(|m| m.name == *method_name)?;
let positions: Vec<usize> = method
.parameters
.iter()
.enumerate()
.filter(|(_, p)| p.is_reference)
.map(|(i, _)| i)
.collect();
Some(positions)
}
ByRefCallKind::Constructor(class_name) => {
let cls = self.find_or_load_class(class_name)?;
let ctor = cls.methods.iter().find(|m| m.name == "__construct")?;
let positions: Vec<usize> = ctor
.parameters
.iter()
.enumerate()
.filter(|(_, p)| p.is_reference)
.map(|(i, _)| i)
.collect();
Some(positions)
}
}
}
}
struct DiagnosticCtx<'a> {
content: &'a str,
diagnostics: Vec<Diagnostic>,
}
fn collect_from_statement(
stmt: &Statement<'_>,
ctx: &mut DiagnosticCtx<'_>,
resolver: Option<ByRefResolver<'_>>,
) {
match stmt {
Statement::Function(func) => {
let body_start = func.body.left_brace.start.offset;
let body_end = func.body.right_brace.end.offset;
let scope = collect_function_scope_with_resolver(
&func.parameter_list,
func.body.statements.as_slice(),
body_start,
body_end,
resolver,
);
check_scope(
&scope,
func.body.statements.as_slice(),
ctx,
false, );
}
Statement::Class(class) => {
collect_from_class_members(class.members.as_slice(), ctx, resolver);
}
Statement::Trait(tr) => {
collect_from_class_members(tr.members.as_slice(), ctx, resolver);
}
Statement::Enum(en) => {
collect_from_class_members(en.members.as_slice(), ctx, resolver);
}
Statement::Interface(_) => {
}
Statement::Namespace(ns) => {
for inner in ns.statements().iter() {
collect_from_statement(inner, ctx, resolver);
}
}
_ => {
}
}
}
fn collect_from_class_members(
members: &[ClassLikeMember<'_>],
ctx: &mut DiagnosticCtx<'_>,
resolver: Option<ByRefResolver<'_>>,
) {
for member in members.iter() {
if let ClassLikeMember::Method(method) = member
&& let MethodBody::Concrete(block) = &method.body
{
let body_start = block.left_brace.start.offset;
let body_end = block.right_brace.end.offset;
let is_static = method
.modifiers
.iter()
.any(|m| matches!(m, Modifier::Static(_)));
let scope = collect_function_scope_with_kind_and_resolver(
&method.parameter_list,
block.statements.as_slice(),
body_start,
body_end,
FrameKind::Method,
resolver,
);
check_scope(&scope, block.statements.as_slice(), ctx, !is_static);
}
}
}
fn check_scope(
scope: &ScopeMap,
statements: &[Statement<'_>],
ctx: &mut DiagnosticCtx<'_>,
this_is_defined: bool,
) {
if has_dynamic_variables(statements) || has_extract_call(statements) {
return;
}
let compact_vars = collect_compact_vars(statements);
let var_annotated = collect_var_annotations(ctx.content);
let error_suppressed_offsets = collect_error_suppressed_offsets(statements);
let guarded_offsets = collect_guarded_offsets(statements);
if scope.frames.is_empty() {
return;
}
let mut always_defined: HashSet<&str> = HashSet::new();
for sg in SUPERGLOBALS {
always_defined.insert(sg);
}
if this_is_defined {
always_defined.insert("$this");
}
for cv in &compact_vars {
always_defined.insert(cv.as_str());
}
for av in &var_annotated {
always_defined.insert(av.as_str());
}
let frame_own_writes: Vec<Vec<(&str, u32)>> = scope
.frames
.iter()
.map(|frame| {
let mut writes: Vec<(&str, u32)> = Vec::new();
for param in &frame.parameters {
writes.push((param.as_str(), 0));
}
for access in &scope.accesses {
if !matches!(access.kind, AccessKind::Write | AccessKind::ReadWrite) {
continue;
}
if access.offset >= frame.start
&& access.offset <= frame.end
&& !is_in_nested_frame(access.offset, frame, &scope.frames)
{
writes.push((access.name.as_str(), access.offset));
}
}
writes
})
.collect();
for (frame_idx, frame) in scope.frames.iter().enumerate() {
let visible_writes = build_visible_writes(frame_idx, &scope.frames, &frame_own_writes);
let frame_writes = &visible_writes;
for access in &scope.accesses {
if access.offset < frame.start || access.offset > frame.end {
continue;
}
if is_in_nested_frame(access.offset, frame, &scope.frames) {
continue;
}
if !matches!(access.kind, AccessKind::Read) {
continue;
}
if access.name == "self" || access.name == "static" || access.name == "parent" {
continue;
}
if access.name == "$this" {
continue;
}
if guarded_offsets.contains(&access.offset) {
continue;
}
if error_suppressed_offsets.contains(&access.offset) {
continue;
}
if always_defined.contains(access.name.as_str()) {
continue;
}
let has_prior_write = frame_writes
.iter()
.any(|(name, off)| *name == access.name && *off < access.offset);
if has_prior_write {
continue;
}
let var_len = access.name.len();
let range = match offset_range_to_lsp_range(
ctx.content,
access.offset as usize,
access.offset as usize + var_len,
) {
Some(r) => r,
None => continue,
};
let message = format!("Undefined variable '{}'", access.name);
ctx.diagnostics.push(make_diagnostic(
range,
DiagnosticSeverity::ERROR,
UNDEFINED_VARIABLE_CODE,
message,
));
}
}
}
fn is_in_nested_frame(
offset: u32,
frame: &crate::scope_collector::Frame,
frames: &[crate::scope_collector::Frame],
) -> bool {
frames.iter().any(|f| {
f.start > frame.start
&& f.end < frame.end
&& offset >= f.start
&& offset <= f.end
&& f.kind != FrameKind::Catch
})
}
fn find_parent_frame_idx(
frame_idx: usize,
frames: &[crate::scope_collector::Frame],
) -> Option<usize> {
let frame = &frames[frame_idx];
let mut best: Option<usize> = None;
for (i, candidate) in frames.iter().enumerate() {
if i == frame_idx {
continue;
}
if candidate.start <= frame.start
&& candidate.end >= frame.end
&& !(candidate.start == frame.start && candidate.end == frame.end)
{
match best {
None => best = Some(i),
Some(prev) => {
let prev_frame = &frames[prev];
if (candidate.end - candidate.start) < (prev_frame.end - prev_frame.start) {
best = Some(i);
}
}
}
}
}
best
}
fn build_visible_writes<'a>(
frame_idx: usize,
frames: &[crate::scope_collector::Frame],
frame_own_writes: &[Vec<(&'a str, u32)>],
) -> Vec<(&'a str, u32)> {
let frame = &frames[frame_idx];
let own = &frame_own_writes[frame_idx];
match frame.kind {
FrameKind::ArrowFunction | FrameKind::Catch => {
let parent_writes = match find_parent_frame_idx(frame_idx, frames) {
Some(parent_idx) => build_visible_writes(parent_idx, frames, frame_own_writes),
None => Vec::new(),
};
let mut combined = parent_writes;
combined.extend_from_slice(own);
combined
}
_ => {
own.clone()
}
}
}
fn has_dynamic_variables(statements: &[Statement<'_>]) -> bool {
for stmt in statements {
if stmt_has_dynamic_var(stmt) {
return true;
}
}
false
}
fn stmt_has_dynamic_var(stmt: &Statement<'_>) -> bool {
match stmt {
Statement::Expression(es) => expr_has_dynamic_var(es.expression),
Statement::Return(ret) => ret.value.is_some_and(|v| expr_has_dynamic_var(v)),
Statement::Echo(echo) => echo.values.iter().any(|v| expr_has_dynamic_var(v)),
Statement::If(if_stmt) => {
if expr_has_dynamic_var(if_stmt.condition) {
return true;
}
match &if_stmt.body {
IfBody::Statement(body) => {
if stmt_has_dynamic_var(body.statement) {
return true;
}
for clause in body.else_if_clauses.iter() {
if expr_has_dynamic_var(clause.condition)
|| stmt_has_dynamic_var(clause.statement)
{
return true;
}
}
if let Some(ref el) = body.else_clause
&& stmt_has_dynamic_var(el.statement)
{
return true;
}
}
IfBody::ColonDelimited(body) => {
for s in body.statements.iter() {
if stmt_has_dynamic_var(s) {
return true;
}
}
for clause in body.else_if_clauses.iter() {
if expr_has_dynamic_var(clause.condition) {
return true;
}
for s in clause.statements.iter() {
if stmt_has_dynamic_var(s) {
return true;
}
}
}
if let Some(ref el) = body.else_clause {
for s in el.statements.iter() {
if stmt_has_dynamic_var(s) {
return true;
}
}
}
}
}
false
}
Statement::Foreach(foreach) => {
expr_has_dynamic_var(foreach.expression)
|| match &foreach.body {
ForeachBody::Statement(s) => stmt_has_dynamic_var(s),
ForeachBody::ColonDelimited(b) => {
b.statements.iter().any(|s| stmt_has_dynamic_var(s))
}
}
}
Statement::While(w) => {
expr_has_dynamic_var(w.condition)
|| match &w.body {
WhileBody::Statement(s) => stmt_has_dynamic_var(s),
WhileBody::ColonDelimited(b) => {
b.statements.iter().any(|s| stmt_has_dynamic_var(s))
}
}
}
Statement::DoWhile(dw) => {
stmt_has_dynamic_var(dw.statement) || expr_has_dynamic_var(dw.condition)
}
Statement::For(for_stmt) => {
for_stmt
.initializations
.iter()
.any(|e| expr_has_dynamic_var(e))
|| for_stmt.conditions.iter().any(|e| expr_has_dynamic_var(e))
|| for_stmt.increments.iter().any(|e| expr_has_dynamic_var(e))
|| match &for_stmt.body {
ForBody::Statement(s) => stmt_has_dynamic_var(s),
ForBody::ColonDelimited(b) => {
b.statements.iter().any(|s| stmt_has_dynamic_var(s))
}
}
}
Statement::Switch(sw) => {
expr_has_dynamic_var(sw.expression)
|| sw.body.cases().iter().any(|c| match c {
SwitchCase::Expression(sc) => {
expr_has_dynamic_var(sc.expression)
|| sc.statements.iter().any(|s| stmt_has_dynamic_var(s))
}
SwitchCase::Default(dc) => {
dc.statements.iter().any(|s| stmt_has_dynamic_var(s))
}
})
}
Statement::Try(try_stmt) => {
try_stmt
.block
.statements
.iter()
.any(|s| stmt_has_dynamic_var(s))
|| try_stmt
.catch_clauses
.iter()
.any(|c| c.block.statements.iter().any(|s| stmt_has_dynamic_var(s)))
|| try_stmt
.finally_clause
.as_ref()
.is_some_and(|f| f.block.statements.iter().any(|s| stmt_has_dynamic_var(s)))
}
Statement::Block(block) => block.statements.iter().any(|s| stmt_has_dynamic_var(s)),
Statement::Unset(_) => false,
Statement::Global(_) => false,
Statement::Static(_) => false,
_ => false,
}
}
fn expr_has_dynamic_var(expr: &Expression<'_>) -> bool {
match expr {
Expression::Variable(Variable::Indirect(_)) => true,
Expression::Variable(Variable::Nested(_)) => true,
Expression::Variable(_) => false,
Expression::Assignment(a) => expr_has_dynamic_var(a.lhs) || expr_has_dynamic_var(a.rhs),
Expression::Binary(b) => expr_has_dynamic_var(b.lhs) || expr_has_dynamic_var(b.rhs),
Expression::UnaryPrefix(u) => expr_has_dynamic_var(u.operand),
Expression::UnaryPostfix(u) => expr_has_dynamic_var(u.operand),
Expression::Parenthesized(p) => expr_has_dynamic_var(p.expression),
Expression::Call(call) => match call {
Call::Function(fc) => {
expr_has_dynamic_var(fc.function)
|| fc
.argument_list
.arguments
.iter()
.any(|a| expr_has_dynamic_var(a.value()))
}
Call::Method(mc) => {
expr_has_dynamic_var(mc.object)
|| mc
.argument_list
.arguments
.iter()
.any(|a| expr_has_dynamic_var(a.value()))
}
Call::NullSafeMethod(mc) => {
expr_has_dynamic_var(mc.object)
|| mc
.argument_list
.arguments
.iter()
.any(|a| expr_has_dynamic_var(a.value()))
}
Call::StaticMethod(sc) => {
expr_has_dynamic_var(sc.class)
|| sc
.argument_list
.arguments
.iter()
.any(|a| expr_has_dynamic_var(a.value()))
}
},
Expression::Access(access) => match access {
Access::Property(pa) => expr_has_dynamic_var(pa.object),
Access::NullSafeProperty(pa) => expr_has_dynamic_var(pa.object),
Access::StaticProperty(spa) => expr_has_dynamic_var(spa.class),
Access::ClassConstant(cca) => expr_has_dynamic_var(cca.class),
},
Expression::ArrayAccess(aa) => {
expr_has_dynamic_var(aa.array) || expr_has_dynamic_var(aa.index)
}
Expression::Conditional(c) => {
expr_has_dynamic_var(c.condition)
|| c.then.is_some_and(|t| expr_has_dynamic_var(t))
|| expr_has_dynamic_var(c.r#else)
}
Expression::Instantiation(inst) => {
expr_has_dynamic_var(inst.class)
|| inst
.argument_list
.as_ref()
.is_some_and(|al| al.arguments.iter().any(|a| expr_has_dynamic_var(a.value())))
}
Expression::Array(arr) => arr.elements.iter().any(|e| array_elem_has_dynamic_var(e)),
Expression::LegacyArray(arr) => arr.elements.iter().any(|e| array_elem_has_dynamic_var(e)),
Expression::Throw(t) => expr_has_dynamic_var(t.exception),
Expression::Clone(c) => expr_has_dynamic_var(c.object),
Expression::Match(m) => {
expr_has_dynamic_var(m.expression)
|| m.arms.iter().any(|arm| match arm {
MatchArm::Expression(ea) => {
ea.conditions.iter().any(|c| expr_has_dynamic_var(c))
|| expr_has_dynamic_var(ea.expression)
}
MatchArm::Default(da) => expr_has_dynamic_var(da.expression),
})
}
Expression::Closure(_) | Expression::ArrowFunction(_) => false,
_ => false,
}
}
fn array_elem_has_dynamic_var(elem: &ArrayElement<'_>) -> bool {
match elem {
ArrayElement::KeyValue(kv) => {
expr_has_dynamic_var(kv.key) || expr_has_dynamic_var(kv.value)
}
ArrayElement::Value(v) => expr_has_dynamic_var(v.value),
ArrayElement::Variadic(s) => expr_has_dynamic_var(s.value),
ArrayElement::Missing(_) => false,
}
}
fn has_extract_call(statements: &[Statement<'_>]) -> bool {
for stmt in statements {
if stmt_has_extract(stmt) {
return true;
}
}
false
}
fn stmt_has_extract(stmt: &Statement<'_>) -> bool {
match stmt {
Statement::Expression(es) => expr_has_extract(es.expression),
Statement::Return(ret) => ret.value.is_some_and(|v| expr_has_extract(v)),
Statement::Echo(echo) => echo.values.iter().any(|v| expr_has_extract(v)),
Statement::If(if_stmt) => {
if expr_has_extract(if_stmt.condition) {
return true;
}
match &if_stmt.body {
IfBody::Statement(body) => {
if stmt_has_extract(body.statement) {
return true;
}
for clause in body.else_if_clauses.iter() {
if expr_has_extract(clause.condition) || stmt_has_extract(clause.statement)
{
return true;
}
}
if let Some(ref el) = body.else_clause
&& stmt_has_extract(el.statement)
{
return true;
}
}
IfBody::ColonDelimited(body) => {
for s in body.statements.iter() {
if stmt_has_extract(s) {
return true;
}
}
for clause in body.else_if_clauses.iter() {
if expr_has_extract(clause.condition) {
return true;
}
for s in clause.statements.iter() {
if stmt_has_extract(s) {
return true;
}
}
}
if let Some(ref el) = body.else_clause {
for s in el.statements.iter() {
if stmt_has_extract(s) {
return true;
}
}
}
}
}
false
}
Statement::Foreach(foreach) => {
expr_has_extract(foreach.expression)
|| match &foreach.body {
ForeachBody::Statement(s) => stmt_has_extract(s),
ForeachBody::ColonDelimited(b) => {
b.statements.iter().any(|s| stmt_has_extract(s))
}
}
}
Statement::While(w) => {
expr_has_extract(w.condition)
|| match &w.body {
WhileBody::Statement(s) => stmt_has_extract(s),
WhileBody::ColonDelimited(b) => {
b.statements.iter().any(|s| stmt_has_extract(s))
}
}
}
Statement::DoWhile(dw) => stmt_has_extract(dw.statement) || expr_has_extract(dw.condition),
Statement::For(for_stmt) => {
for_stmt.initializations.iter().any(|e| expr_has_extract(e))
|| for_stmt.conditions.iter().any(|e| expr_has_extract(e))
|| for_stmt.increments.iter().any(|e| expr_has_extract(e))
|| match &for_stmt.body {
ForBody::Statement(s) => stmt_has_extract(s),
ForBody::ColonDelimited(b) => b.statements.iter().any(|s| stmt_has_extract(s)),
}
}
Statement::Switch(sw) => {
expr_has_extract(sw.expression)
|| sw.body.cases().iter().any(|c| match c {
SwitchCase::Expression(sc) => {
expr_has_extract(sc.expression)
|| sc.statements.iter().any(|s| stmt_has_extract(s))
}
SwitchCase::Default(dc) => dc.statements.iter().any(|s| stmt_has_extract(s)),
})
}
Statement::Try(try_stmt) => {
try_stmt
.block
.statements
.iter()
.any(|s| stmt_has_extract(s))
|| try_stmt
.catch_clauses
.iter()
.any(|c| c.block.statements.iter().any(|s| stmt_has_extract(s)))
|| try_stmt
.finally_clause
.as_ref()
.is_some_and(|f| f.block.statements.iter().any(|s| stmt_has_extract(s)))
}
Statement::Block(block) => block.statements.iter().any(|s| stmt_has_extract(s)),
_ => false,
}
}
fn expr_has_extract(expr: &Expression<'_>) -> bool {
match expr {
Expression::Call(Call::Function(fc)) => {
if let Expression::Identifier(ident) = fc.function
&& ident.value().eq_ignore_ascii_case("extract")
{
return true;
}
fc.argument_list
.arguments
.iter()
.any(|a| expr_has_extract(a.value()))
}
Expression::Assignment(a) => expr_has_extract(a.lhs) || expr_has_extract(a.rhs),
Expression::Binary(b) => expr_has_extract(b.lhs) || expr_has_extract(b.rhs),
Expression::UnaryPrefix(u) => expr_has_extract(u.operand),
Expression::UnaryPostfix(u) => expr_has_extract(u.operand),
Expression::Parenthesized(p) => expr_has_extract(p.expression),
Expression::Conditional(c) => {
expr_has_extract(c.condition)
|| c.then.is_some_and(|t| expr_has_extract(t))
|| expr_has_extract(c.r#else)
}
Expression::Call(Call::Method(mc)) => {
expr_has_extract(mc.object)
|| mc
.argument_list
.arguments
.iter()
.any(|a| expr_has_extract(a.value()))
}
Expression::Call(Call::NullSafeMethod(mc)) => {
expr_has_extract(mc.object)
|| mc
.argument_list
.arguments
.iter()
.any(|a| expr_has_extract(a.value()))
}
Expression::Call(Call::StaticMethod(sc)) => {
expr_has_extract(sc.class)
|| sc
.argument_list
.arguments
.iter()
.any(|a| expr_has_extract(a.value()))
}
Expression::Closure(_) | Expression::ArrowFunction(_) => false,
_ => false,
}
}
fn collect_compact_vars(statements: &[Statement<'_>]) -> HashSet<String> {
let mut vars = HashSet::new();
for stmt in statements {
collect_compact_from_stmt(stmt, &mut vars);
}
vars
}
fn collect_compact_from_stmt(stmt: &Statement<'_>, vars: &mut HashSet<String>) {
match stmt {
Statement::Expression(es) => collect_compact_from_expr(es.expression, vars),
Statement::Return(ret) => {
if let Some(v) = ret.value {
collect_compact_from_expr(v, vars);
}
}
Statement::Echo(echo) => {
for v in echo.values.iter() {
collect_compact_from_expr(v, vars);
}
}
Statement::If(if_stmt) => {
collect_compact_from_expr(if_stmt.condition, vars);
match &if_stmt.body {
IfBody::Statement(body) => {
collect_compact_from_stmt(body.statement, vars);
for clause in body.else_if_clauses.iter() {
collect_compact_from_expr(clause.condition, vars);
collect_compact_from_stmt(clause.statement, vars);
}
if let Some(ref el) = body.else_clause {
collect_compact_from_stmt(el.statement, vars);
}
}
IfBody::ColonDelimited(body) => {
for s in body.statements.iter() {
collect_compact_from_stmt(s, vars);
}
for clause in body.else_if_clauses.iter() {
collect_compact_from_expr(clause.condition, vars);
for s in clause.statements.iter() {
collect_compact_from_stmt(s, vars);
}
}
if let Some(ref el) = body.else_clause {
for s in el.statements.iter() {
collect_compact_from_stmt(s, vars);
}
}
}
}
}
Statement::Foreach(foreach) => {
collect_compact_from_expr(foreach.expression, vars);
match &foreach.body {
ForeachBody::Statement(s) => collect_compact_from_stmt(s, vars),
ForeachBody::ColonDelimited(b) => {
for s in b.statements.iter() {
collect_compact_from_stmt(s, vars);
}
}
}
}
Statement::While(w) => {
collect_compact_from_expr(w.condition, vars);
match &w.body {
WhileBody::Statement(s) => collect_compact_from_stmt(s, vars),
WhileBody::ColonDelimited(b) => {
for s in b.statements.iter() {
collect_compact_from_stmt(s, vars);
}
}
}
}
Statement::DoWhile(dw) => {
collect_compact_from_stmt(dw.statement, vars);
collect_compact_from_expr(dw.condition, vars);
}
Statement::For(for_stmt) => {
for e in for_stmt.initializations.iter() {
collect_compact_from_expr(e, vars);
}
for e in for_stmt.conditions.iter() {
collect_compact_from_expr(e, vars);
}
for e in for_stmt.increments.iter() {
collect_compact_from_expr(e, vars);
}
match &for_stmt.body {
ForBody::Statement(s) => collect_compact_from_stmt(s, vars),
ForBody::ColonDelimited(b) => {
for s in b.statements.iter() {
collect_compact_from_stmt(s, vars);
}
}
}
}
Statement::Switch(sw) => {
collect_compact_from_expr(sw.expression, vars);
for case in sw.body.cases().iter() {
match case {
SwitchCase::Expression(sc) => {
collect_compact_from_expr(sc.expression, vars);
for s in sc.statements.iter() {
collect_compact_from_stmt(s, vars);
}
}
SwitchCase::Default(dc) => {
for s in dc.statements.iter() {
collect_compact_from_stmt(s, vars);
}
}
}
}
}
Statement::Try(try_stmt) => {
for s in try_stmt.block.statements.iter() {
collect_compact_from_stmt(s, vars);
}
for catch in try_stmt.catch_clauses.iter() {
for s in catch.block.statements.iter() {
collect_compact_from_stmt(s, vars);
}
}
if let Some(ref finally) = try_stmt.finally_clause {
for s in finally.block.statements.iter() {
collect_compact_from_stmt(s, vars);
}
}
}
Statement::Block(block) => {
for s in block.statements.iter() {
collect_compact_from_stmt(s, vars);
}
}
_ => {}
}
}
fn collect_compact_from_expr(expr: &Expression<'_>, vars: &mut HashSet<String>) {
match expr {
Expression::Call(Call::Function(fc)) => {
if let Expression::Identifier(ident) = fc.function
&& ident.value().eq_ignore_ascii_case("compact")
{
for arg in fc.argument_list.arguments.iter() {
if let Expression::Literal(Literal::String(s)) = arg.value() {
let name: &str = if let Some(v) = s.value {
v
} else {
let raw = s.raw;
raw.strip_prefix('\'')
.or_else(|| raw.strip_prefix('"'))
.and_then(|inner| {
inner.strip_suffix('\'').or_else(|| inner.strip_suffix('"'))
})
.unwrap_or(raw)
};
if !name.is_empty() {
vars.insert(format!("${}", name));
}
}
}
}
for arg in fc.argument_list.arguments.iter() {
collect_compact_from_expr(arg.value(), vars);
}
}
Expression::Assignment(a) => {
collect_compact_from_expr(a.lhs, vars);
collect_compact_from_expr(a.rhs, vars);
}
Expression::Binary(b) => {
collect_compact_from_expr(b.lhs, vars);
collect_compact_from_expr(b.rhs, vars);
}
Expression::Parenthesized(p) => collect_compact_from_expr(p.expression, vars),
Expression::Conditional(c) => {
collect_compact_from_expr(c.condition, vars);
if let Some(t) = c.then {
collect_compact_from_expr(t, vars);
}
collect_compact_from_expr(c.r#else, vars);
}
Expression::Call(Call::Method(mc)) => {
collect_compact_from_expr(mc.object, vars);
for arg in mc.argument_list.arguments.iter() {
collect_compact_from_expr(arg.value(), vars);
}
}
Expression::Call(Call::NullSafeMethod(mc)) => {
collect_compact_from_expr(mc.object, vars);
for arg in mc.argument_list.arguments.iter() {
collect_compact_from_expr(arg.value(), vars);
}
}
Expression::Call(Call::StaticMethod(sc)) => {
collect_compact_from_expr(sc.class, vars);
for arg in sc.argument_list.arguments.iter() {
collect_compact_from_expr(arg.value(), vars);
}
}
_ => {}
}
}
fn collect_var_annotations(content: &str) -> HashSet<String> {
let mut vars = HashSet::new();
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.contains("@var") {
continue;
}
if let Some(var_pos) = trimmed.find("@var") {
let after_var = &trimmed[var_pos + 4..];
let after_var = after_var.trim_start();
if let Some(dollar_pos) = after_var.find('$') {
let var_part = &after_var[dollar_pos..];
let name_end = var_part
.char_indices()
.skip(1) .find(|(_, c)| !c.is_alphanumeric() && *c != '_')
.map(|(i, _)| i)
.unwrap_or(var_part.len());
let var_name = &var_part[..name_end];
let var_name = var_name.trim_end_matches("*/").trim();
if var_name.len() > 1 {
vars.insert(var_name.to_string());
}
}
}
}
vars
}
fn collect_error_suppressed_offsets(statements: &[Statement<'_>]) -> HashSet<u32> {
let mut offsets = HashSet::new();
for stmt in statements {
collect_suppressed_from_stmt(stmt, &mut offsets);
}
offsets
}
fn collect_suppressed_from_stmt(stmt: &Statement<'_>, offsets: &mut HashSet<u32>) {
match stmt {
Statement::Expression(es) => collect_suppressed_from_expr(es.expression, false, offsets),
Statement::Return(ret) => {
if let Some(v) = ret.value {
collect_suppressed_from_expr(v, false, offsets);
}
}
Statement::Echo(echo) => {
for v in echo.values.iter() {
collect_suppressed_from_expr(v, false, offsets);
}
}
Statement::If(if_stmt) => {
collect_suppressed_from_expr(if_stmt.condition, false, offsets);
match &if_stmt.body {
IfBody::Statement(body) => {
collect_suppressed_from_stmt(body.statement, offsets);
for clause in body.else_if_clauses.iter() {
collect_suppressed_from_expr(clause.condition, false, offsets);
collect_suppressed_from_stmt(clause.statement, offsets);
}
if let Some(ref el) = body.else_clause {
collect_suppressed_from_stmt(el.statement, offsets);
}
}
IfBody::ColonDelimited(body) => {
for s in body.statements.iter() {
collect_suppressed_from_stmt(s, offsets);
}
for clause in body.else_if_clauses.iter() {
collect_suppressed_from_expr(clause.condition, false, offsets);
for s in clause.statements.iter() {
collect_suppressed_from_stmt(s, offsets);
}
}
if let Some(ref el) = body.else_clause {
for s in el.statements.iter() {
collect_suppressed_from_stmt(s, offsets);
}
}
}
}
}
Statement::Foreach(foreach) => {
collect_suppressed_from_expr(foreach.expression, false, offsets);
match &foreach.body {
ForeachBody::Statement(s) => collect_suppressed_from_stmt(s, offsets),
ForeachBody::ColonDelimited(b) => {
for s in b.statements.iter() {
collect_suppressed_from_stmt(s, offsets);
}
}
}
}
Statement::While(w) => {
collect_suppressed_from_expr(w.condition, false, offsets);
match &w.body {
WhileBody::Statement(s) => collect_suppressed_from_stmt(s, offsets),
WhileBody::ColonDelimited(b) => {
for s in b.statements.iter() {
collect_suppressed_from_stmt(s, offsets);
}
}
}
}
Statement::DoWhile(dw) => {
collect_suppressed_from_stmt(dw.statement, offsets);
collect_suppressed_from_expr(dw.condition, false, offsets);
}
Statement::For(for_stmt) => {
for e in for_stmt.initializations.iter() {
collect_suppressed_from_expr(e, false, offsets);
}
for e in for_stmt.conditions.iter() {
collect_suppressed_from_expr(e, false, offsets);
}
for e in for_stmt.increments.iter() {
collect_suppressed_from_expr(e, false, offsets);
}
match &for_stmt.body {
ForBody::Statement(s) => collect_suppressed_from_stmt(s, offsets),
ForBody::ColonDelimited(b) => {
for s in b.statements.iter() {
collect_suppressed_from_stmt(s, offsets);
}
}
}
}
Statement::Switch(sw) => {
collect_suppressed_from_expr(sw.expression, false, offsets);
for case in sw.body.cases().iter() {
match case {
SwitchCase::Expression(sc) => {
collect_suppressed_from_expr(sc.expression, false, offsets);
for s in sc.statements.iter() {
collect_suppressed_from_stmt(s, offsets);
}
}
SwitchCase::Default(dc) => {
for s in dc.statements.iter() {
collect_suppressed_from_stmt(s, offsets);
}
}
}
}
}
Statement::Try(try_stmt) => {
for s in try_stmt.block.statements.iter() {
collect_suppressed_from_stmt(s, offsets);
}
for catch in try_stmt.catch_clauses.iter() {
for s in catch.block.statements.iter() {
collect_suppressed_from_stmt(s, offsets);
}
}
if let Some(ref finally) = try_stmt.finally_clause {
for s in finally.block.statements.iter() {
collect_suppressed_from_stmt(s, offsets);
}
}
}
Statement::Block(block) => {
for s in block.statements.iter() {
collect_suppressed_from_stmt(s, offsets);
}
}
_ => {}
}
}
fn collect_suppressed_from_expr(
expr: &Expression<'_>,
under_error_control: bool,
offsets: &mut HashSet<u32>,
) {
match expr {
Expression::UnaryPrefix(unary) if unary.operator.is_error_control() => {
collect_suppressed_from_expr(unary.operand, true, offsets);
}
Expression::Variable(Variable::Direct(dv)) if under_error_control => {
offsets.insert(dv.span().start.offset);
}
Expression::UnaryPrefix(unary) => {
collect_suppressed_from_expr(unary.operand, under_error_control, offsets);
}
Expression::UnaryPostfix(unary) => {
collect_suppressed_from_expr(unary.operand, under_error_control, offsets);
}
Expression::Assignment(a) => {
collect_suppressed_from_expr(a.lhs, under_error_control, offsets);
collect_suppressed_from_expr(a.rhs, under_error_control, offsets);
}
Expression::Binary(b) => {
collect_suppressed_from_expr(b.lhs, under_error_control, offsets);
collect_suppressed_from_expr(b.rhs, under_error_control, offsets);
}
Expression::Parenthesized(p) => {
collect_suppressed_from_expr(p.expression, under_error_control, offsets);
}
Expression::Call(Call::Function(fc)) => {
collect_suppressed_from_expr(fc.function, under_error_control, offsets);
for arg in fc.argument_list.arguments.iter() {
collect_suppressed_from_expr(arg.value(), under_error_control, offsets);
}
}
Expression::Call(Call::Method(mc)) => {
collect_suppressed_from_expr(mc.object, under_error_control, offsets);
for arg in mc.argument_list.arguments.iter() {
collect_suppressed_from_expr(arg.value(), under_error_control, offsets);
}
}
Expression::Access(Access::Property(pa)) => {
collect_suppressed_from_expr(pa.object, under_error_control, offsets);
}
Expression::Access(Access::NullSafeProperty(pa)) => {
collect_suppressed_from_expr(pa.object, under_error_control, offsets);
}
Expression::ArrayAccess(aa) => {
collect_suppressed_from_expr(aa.array, under_error_control, offsets);
collect_suppressed_from_expr(aa.index, under_error_control, offsets);
}
Expression::Conditional(c) => {
collect_suppressed_from_expr(c.condition, under_error_control, offsets);
if let Some(t) = c.then {
collect_suppressed_from_expr(t, under_error_control, offsets);
}
collect_suppressed_from_expr(c.r#else, under_error_control, offsets);
}
_ => {}
}
}
fn collect_guarded_offsets(statements: &[Statement<'_>]) -> HashSet<u32> {
let mut offsets = HashSet::new();
for stmt in statements {
collect_guarded_from_stmt(stmt, &mut offsets);
}
offsets
}
fn collect_guarded_from_stmt(stmt: &Statement<'_>, offsets: &mut HashSet<u32>) {
match stmt {
Statement::Expression(es) => collect_guarded_from_expr(es.expression, false, offsets),
Statement::Return(ret) => {
if let Some(v) = ret.value {
collect_guarded_from_expr(v, false, offsets);
}
}
Statement::Echo(echo) => {
for v in echo.values.iter() {
collect_guarded_from_expr(v, false, offsets);
}
}
Statement::If(if_stmt) => {
collect_guarded_from_expr(if_stmt.condition, false, offsets);
match &if_stmt.body {
IfBody::Statement(body) => {
collect_guarded_from_stmt(body.statement, offsets);
for clause in body.else_if_clauses.iter() {
collect_guarded_from_expr(clause.condition, false, offsets);
collect_guarded_from_stmt(clause.statement, offsets);
}
if let Some(ref el) = body.else_clause {
collect_guarded_from_stmt(el.statement, offsets);
}
}
IfBody::ColonDelimited(body) => {
for s in body.statements.iter() {
collect_guarded_from_stmt(s, offsets);
}
for clause in body.else_if_clauses.iter() {
collect_guarded_from_expr(clause.condition, false, offsets);
for s in clause.statements.iter() {
collect_guarded_from_stmt(s, offsets);
}
}
if let Some(ref el) = body.else_clause {
for s in el.statements.iter() {
collect_guarded_from_stmt(s, offsets);
}
}
}
}
}
Statement::Foreach(foreach) => {
collect_guarded_from_expr(foreach.expression, false, offsets);
match &foreach.body {
ForeachBody::Statement(s) => collect_guarded_from_stmt(s, offsets),
ForeachBody::ColonDelimited(b) => {
for s in b.statements.iter() {
collect_guarded_from_stmt(s, offsets);
}
}
}
}
Statement::While(w) => {
collect_guarded_from_expr(w.condition, false, offsets);
match &w.body {
WhileBody::Statement(s) => collect_guarded_from_stmt(s, offsets),
WhileBody::ColonDelimited(b) => {
for s in b.statements.iter() {
collect_guarded_from_stmt(s, offsets);
}
}
}
}
Statement::DoWhile(dw) => {
collect_guarded_from_stmt(dw.statement, offsets);
collect_guarded_from_expr(dw.condition, false, offsets);
}
Statement::For(for_stmt) => {
for e in for_stmt.initializations.iter() {
collect_guarded_from_expr(e, false, offsets);
}
for e in for_stmt.conditions.iter() {
collect_guarded_from_expr(e, false, offsets);
}
for e in for_stmt.increments.iter() {
collect_guarded_from_expr(e, false, offsets);
}
match &for_stmt.body {
ForBody::Statement(s) => collect_guarded_from_stmt(s, offsets),
ForBody::ColonDelimited(b) => {
for s in b.statements.iter() {
collect_guarded_from_stmt(s, offsets);
}
}
}
}
Statement::Switch(sw) => {
collect_guarded_from_expr(sw.expression, false, offsets);
for case in sw.body.cases().iter() {
match case {
SwitchCase::Expression(sc) => {
collect_guarded_from_expr(sc.expression, false, offsets);
for s in sc.statements.iter() {
collect_guarded_from_stmt(s, offsets);
}
}
SwitchCase::Default(dc) => {
for s in dc.statements.iter() {
collect_guarded_from_stmt(s, offsets);
}
}
}
}
}
Statement::Try(try_stmt) => {
for s in try_stmt.block.statements.iter() {
collect_guarded_from_stmt(s, offsets);
}
for catch in try_stmt.catch_clauses.iter() {
for s in catch.block.statements.iter() {
collect_guarded_from_stmt(s, offsets);
}
}
if let Some(ref finally) = try_stmt.finally_clause {
for s in finally.block.statements.iter() {
collect_guarded_from_stmt(s, offsets);
}
}
}
Statement::Block(block) => {
for s in block.statements.iter() {
collect_guarded_from_stmt(s, offsets);
}
}
_ => {}
}
}
fn collect_guarded_from_expr(
expr: &Expression<'_>,
inside_guard: bool,
offsets: &mut HashSet<u32>,
) {
match expr {
Expression::Construct(Construct::Isset(isset)) => {
for val in isset.values.iter() {
collect_guard_targets(val, offsets);
}
}
Expression::Construct(Construct::Empty(empty)) => {
collect_guard_targets(empty.value, offsets);
}
Expression::UnaryPrefix(unary) => {
collect_guarded_from_expr(unary.operand, inside_guard, offsets);
}
Expression::UnaryPostfix(unary) => {
collect_guarded_from_expr(unary.operand, inside_guard, offsets);
}
Expression::Assignment(a) => {
collect_guarded_from_expr(a.lhs, inside_guard, offsets);
collect_guarded_from_expr(a.rhs, inside_guard, offsets);
}
Expression::Binary(b) => {
collect_guarded_from_expr(b.lhs, inside_guard, offsets);
collect_guarded_from_expr(b.rhs, inside_guard, offsets);
}
Expression::Parenthesized(p) => {
collect_guarded_from_expr(p.expression, inside_guard, offsets);
}
Expression::Conditional(c) => {
collect_guarded_from_expr(c.condition, inside_guard, offsets);
if let Some(t) = c.then {
collect_guarded_from_expr(t, inside_guard, offsets);
}
collect_guarded_from_expr(c.r#else, inside_guard, offsets);
}
Expression::Call(Call::Function(fc)) => {
collect_guarded_from_expr(fc.function, inside_guard, offsets);
for arg in fc.argument_list.arguments.iter() {
collect_guarded_from_expr(arg.value(), inside_guard, offsets);
}
}
Expression::Call(Call::Method(mc)) => {
collect_guarded_from_expr(mc.object, inside_guard, offsets);
for arg in mc.argument_list.arguments.iter() {
collect_guarded_from_expr(arg.value(), inside_guard, offsets);
}
}
Expression::Call(Call::NullSafeMethod(mc)) => {
collect_guarded_from_expr(mc.object, inside_guard, offsets);
for arg in mc.argument_list.arguments.iter() {
collect_guarded_from_expr(arg.value(), inside_guard, offsets);
}
}
Expression::Call(Call::StaticMethod(sc)) => {
collect_guarded_from_expr(sc.class, inside_guard, offsets);
for arg in sc.argument_list.arguments.iter() {
collect_guarded_from_expr(arg.value(), inside_guard, offsets);
}
}
Expression::Access(Access::Property(pa)) => {
collect_guarded_from_expr(pa.object, inside_guard, offsets);
}
Expression::Access(Access::NullSafeProperty(pa)) => {
collect_guarded_from_expr(pa.object, inside_guard, offsets);
}
Expression::ArrayAccess(aa) => {
collect_guarded_from_expr(aa.array, inside_guard, offsets);
collect_guarded_from_expr(aa.index, inside_guard, offsets);
}
Expression::Array(arr) => {
for e in arr.elements.iter() {
collect_guarded_from_array_elem(e, inside_guard, offsets);
}
}
Expression::LegacyArray(arr) => {
for e in arr.elements.iter() {
collect_guarded_from_array_elem(e, inside_guard, offsets);
}
}
Expression::Instantiation(inst) => {
collect_guarded_from_expr(inst.class, inside_guard, offsets);
if let Some(ref al) = inst.argument_list {
for arg in al.arguments.iter() {
collect_guarded_from_expr(arg.value(), inside_guard, offsets);
}
}
}
_ => {}
}
}
fn collect_guarded_from_array_elem(
elem: &ArrayElement<'_>,
inside_guard: bool,
offsets: &mut HashSet<u32>,
) {
match elem {
ArrayElement::KeyValue(kv) => {
collect_guarded_from_expr(kv.key, inside_guard, offsets);
collect_guarded_from_expr(kv.value, inside_guard, offsets);
}
ArrayElement::Value(v) => {
collect_guarded_from_expr(v.value, inside_guard, offsets);
}
ArrayElement::Variadic(s) => {
collect_guarded_from_expr(s.value, inside_guard, offsets);
}
ArrayElement::Missing(_) => {}
}
}
fn collect_guard_targets(expr: &Expression<'_>, offsets: &mut HashSet<u32>) {
match expr {
Expression::Variable(Variable::Direct(dv)) => {
offsets.insert(dv.span().start.offset);
}
Expression::ArrayAccess(aa) => {
collect_guard_targets(aa.array, offsets);
}
Expression::Access(Access::Property(pa)) => {
collect_guard_targets(pa.object, offsets);
}
Expression::Access(Access::NullSafeProperty(pa)) => {
collect_guard_targets(pa.object, offsets);
}
Expression::Access(Access::StaticProperty(spa)) => {
collect_guard_targets(spa.class, offsets);
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn collect(php: &str) -> Vec<Diagnostic> {
let backend = Backend::new_test();
let uri = "file:///test.php";
backend.update_ast(uri, php);
let mut out = Vec::new();
backend.collect_undefined_variable_diagnostics(uri, php, &mut out);
out
}
#[test]
fn flags_undefined_variable_in_echo() {
let diags = collect(
r#"<?php
function test(): void {
echo $nmae;
}
"#,
);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("$nmae"));
assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn flags_undefined_variable_in_expression() {
let diags = collect(
r#"<?php
function test(): void {
$x = $y + 1;
}
"#,
);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("$y"));
}
#[test]
fn flags_multiple_undefined_variables() {
let diags = collect(
r#"<?php
function test(): void {
echo $a;
echo $b;
echo $c;
}
"#,
);
assert_eq!(diags.len(), 3);
}
#[test]
fn diagnostic_has_correct_code_and_source() {
let diags = collect(
r#"<?php
function test(): void {
echo $x;
}
"#,
);
assert_eq!(diags.len(), 1);
assert_eq!(
diags[0].code,
Some(NumberOrString::String("undefined_variable".to_string())),
);
assert_eq!(diags[0].source, Some("phpantom".to_string()));
}
#[test]
fn no_diagnostic_for_assigned_variable() {
let diags = collect(
r#"<?php
function test(): void {
$name = "Alice";
echo $name;
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_parameter() {
let diags = collect(
r#"<?php
function test(string $name): void {
echo $name;
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_foreach_binding() {
let diags = collect(
r#"<?php
function test(array $items): void {
foreach ($items as $key => $value) {
echo $key;
echo $value;
}
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_function_param_used_in_catch() {
let diags = collect(
r#"<?php
function capture(string $payment, float $amount): void {
try {
doSomething($amount);
} catch (\Exception $e) {
echo $payment;
echo $amount;
echo $e->getMessage();
}
}
"#,
);
assert!(
diags.is_empty(),
"Function parameters should be visible inside catch blocks. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_outer_variable_used_in_catch() {
let diags = collect(
r#"<?php
function test(): void {
$client = getClient();
$token = 'abc';
try {
$client->send($token);
} catch (\RuntimeException $e) {
log($client, $token, $e->getMessage());
}
}
"#,
);
assert!(
diags.is_empty(),
"Variables assigned before try should be visible inside catch blocks. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_try_assigned_variable_used_in_catch() {
let diags = collect(
r#"<?php
function test(): void {
try {
$response = fetchData();
} catch (\Exception $e) {
if (isset($response)) {
echo $response;
}
}
}
"#,
);
assert!(
diags.is_empty(),
"Variables assigned in try block should be visible in catch (guarded by isset). Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_catch_variable() {
let diags = collect(
r#"<?php
function test(): void {
try {
doSomething();
} catch (\Exception $e) {
echo $e->getMessage();
}
}
"#,
);
assert!(
!diags.iter().any(|d| d.message.contains("$e")),
"Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_global_statement() {
let diags = collect(
r#"<?php
function test(): void {
global $config;
echo $config;
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_static_variable() {
let diags = collect(
r#"<?php
function test(): void {
static $count = 0;
echo $count;
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_list_destructuring() {
let diags = collect(
r#"<?php
function test(array $pair): void {
[$a, $b] = $pair;
echo $a;
echo $b;
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_compound_assignment() {
let diags = collect(
r#"<?php
function test(): void {
$x = 0;
$x += 1;
echo $x;
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_branch_assignment() {
let diags = collect(
r#"<?php
function test(bool $flag): void {
if ($flag) {
$result = "yes";
}
echo $result;
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_superglobals() {
let diags = collect(
r#"<?php
function test(): void {
echo $_GET['key'];
echo $_POST['key'];
echo $_SERVER['REQUEST_URI'];
echo $_SESSION['user'];
echo $_COOKIE['token'];
echo $_FILES['upload'];
echo $_ENV['APP_ENV'];
echo $_REQUEST['data'];
echo $GLOBALS['x'];
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_argc_argv() {
let diags = collect(
r#"<?php
function test(): void {
echo $argc;
echo $argv[0];
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_this_in_method() {
let diags = collect(
r#"<?php
class Foo {
private string $name;
public function bar(): string {
return $this->name;
}
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_this_in_static_method() {
let diags = collect(
r#"<?php
class Foo {
public static function bar(): void {
echo $this;
}
}
"#,
);
assert!(
!diags.iter().any(|d| d.message.contains("$this")),
"Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_isset_guard() {
let diags = collect(
r#"<?php
function test(): void {
if (isset($x)) {
echo $x;
}
}
"#,
);
assert_eq!(diags.len(), 1, "Got: {:?}", diags);
assert!(diags[0].message.contains("$x"));
}
#[test]
fn no_diagnostic_for_empty_guard() {
let diags = collect(
r#"<?php
function test(): void {
if (empty($y)) {
return;
}
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_compact_referenced_var() {
let diags = collect(
r#"<?php
function test(): void {
$name = "Alice";
$age = 30;
return compact('name', 'age');
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_when_extract_is_used() {
let diags = collect(
r#"<?php
function test(array $data): void {
extract($data);
echo $name;
echo $age;
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_when_variable_variables_are_used() {
let diags = collect(
r#"<?php
function test(): void {
$varName = 'hello';
$$varName = 'world';
echo $unknown;
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_error_suppressed_variable() {
let diags = collect(
r#"<?php
function test(): void {
echo @$undefined;
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_var_annotated_variable() {
let diags = collect(
r#"<?php
function test(): void {
/** @var string $name */
echo $name;
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_closure_use_captured_variable() {
let diags = collect(
r#"<?php
function test(): void {
$x = 42;
$fn = function() use ($x) {
echo $x;
};
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn flags_undefined_in_closure_without_capture() {
let diags = collect(
r#"<?php
function test(): void {
$x = 42;
$fn = function() {
echo $x;
};
}
"#,
);
assert!(
diags.iter().any(|d| d.message.contains("$x")),
"Expected undefined $x in closure, got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_closure_parameter() {
let diags = collect(
r#"<?php
function test(): void {
$fn = function(string $name) {
echo $name;
};
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_arrow_function_implicit_capture() {
let diags = collect(
r#"<?php
function test(): void {
$x = 42;
$fn = fn() => $x * 2;
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_arrow_function_parameter() {
let diags = collect(
r#"<?php
function test(): void {
$fn = fn(int $n) => $n * 2;
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn flags_undefined_in_method() {
let diags = collect(
r#"<?php
class Foo {
public function bar(): void {
echo $undefined;
}
}
"#,
);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("$undefined"));
}
#[test]
fn no_diagnostic_for_method_parameter() {
let diags = collect(
r#"<?php
class Foo {
public function bar(string $name): void {
echo $name;
}
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_top_level_code() {
let diags = collect(
r#"<?php
echo $undefined;
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_for_loop_variable() {
let diags = collect(
r#"<?php
function test(): void {
for ($i = 0; $i < 10; $i++) {
echo $i;
}
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_unset_target() {
let diags = collect(
r#"<?php
function test(): void {
$x = 1;
unset($x);
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn flags_undefined_in_namespaced_function() {
let diags = collect(
r#"<?php
namespace App;
function test(): void {
echo $undefined;
}
"#,
);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("$undefined"));
}
#[test]
fn flags_undefined_in_namespaced_class_method() {
let diags = collect(
r#"<?php
namespace App;
class Foo {
public function bar(): void {
echo $undefined;
}
}
"#,
);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("$undefined"));
}
#[test]
fn flags_undefined_in_trait_method() {
let diags = collect(
r#"<?php
trait MyTrait {
public function foo(): void {
echo $undefined;
}
}
"#,
);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("$undefined"));
}
#[test]
fn flags_undefined_in_enum_method() {
let diags = collect(
r#"<?php
enum Status {
case Active;
case Inactive;
public function label(): string {
return $undefined;
}
}
"#,
);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("$undefined"));
}
#[test]
fn no_diagnostic_for_reference_parameter() {
let diags = collect(
r#"<?php
function test(array &$items): void {
$items[] = 'new';
echo count($items);
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_variable_used_in_match() {
let diags = collect(
r#"<?php
function test(int $status): string {
return match($status) {
1 => 'active',
2 => 'inactive',
default => 'unknown',
};
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_isset_with_array_access() {
let diags = collect(
r#"<?php
function test(): void {
if (isset($data['key'])) {
echo "found";
}
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_postfix_increment_of_defined_var() {
let diags = collect(
r#"<?php
function test(): void {
$x = 0;
$x++;
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn flags_undefined_in_yield() {
let diags = collect(
r#"<?php
function test(): \Generator {
yield $undefined;
}
"#,
);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("$undefined"));
}
#[test]
fn no_diagnostic_for_defined_in_yield() {
let diags = collect(
r#"<?php
function test(): \Generator {
$x = 42;
yield $x;
}
"#,
);
assert!(diags.is_empty(), "Got: {:?}", diags);
}
#[test]
fn no_diagnostic_for_self_static_property_access() {
let diags = collect(
r#"<?php
class Config {
private static ?string $instance = null;
public static function get(): ?string {
if (self::$instance === null) {
self::$instance = 'default';
}
return self::$instance;
}
}
"#,
);
assert!(
diags.is_empty(),
"self::$prop should not be flagged as undefined variable. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_static_keyword_property_access() {
let diags = collect(
r#"<?php
class Base {
protected static int $count = 0;
public function increment(): void {
static::$count++;
}
}
"#,
);
assert!(
diags.is_empty(),
"static::$prop should not be flagged as undefined variable. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_preg_match_out_param() {
let diags = collect(
r#"<?php
function test(string $input): ?string {
if (preg_match('/(\d+)/', $input, $match) === 1) {
return $match[1];
}
return null;
}
"#,
);
assert!(
diags.is_empty(),
"preg_match out-param $match should be treated as defined. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_parse_str_out_param() {
let diags = collect(
r#"<?php
function test(string $query): string {
parse_str($query, $data);
return $data['key'] ?? '';
}
"#,
);
assert!(
diags.is_empty(),
"parse_str out-param $data should be treated as defined. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_preg_match_all_out_param() {
let diags = collect(
r#"<?php
function test(string $text): array {
preg_match_all('/\w+/', $text, $matches);
return $matches[0];
}
"#,
);
assert!(
diags.is_empty(),
"preg_match_all out-param $matches should be treated as defined. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_fqn_preg_match() {
let diags = collect(
r#"<?php
function test(string $input): ?string {
if (\preg_match('/(\d+)/', $input, $match) === 1) {
return $match[1];
}
return null;
}
"#,
);
assert!(
diags.is_empty(),
"FQN \\preg_match out-param should be treated as defined. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_curl_multi_exec_out_param() {
let diags = collect(
r#"<?php
function test($mh): int {
curl_multi_exec($mh, $running);
return $running;
}
"#,
);
assert!(
diags.is_empty(),
"curl_multi_exec out-param $running should be treated as defined. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_fsockopen_out_params() {
let diags = collect(
r#"<?php
function test(): void {
$fp = fsockopen('example.com', 80, $errno, $errstr);
echo $errno . $errstr;
}
"#,
);
assert!(
diags.is_empty(),
"fsockopen out-params $errno/$errstr should be treated as defined. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_openssl_sign_out_param() {
let diags = collect(
r#"<?php
function test(string $data, $key): string {
openssl_sign($data, $signature, $key);
return $signature;
}
"#,
);
assert!(
diags.is_empty(),
"openssl_sign out-param $signature should be treated as defined. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_getimagesize_out_param() {
let diags = collect(
r#"<?php
function test(string $file): array {
$info = getimagesize($file, $imageinfo);
return $imageinfo;
}
"#,
);
assert!(
diags.is_empty(),
"getimagesize out-param $imageinfo should be treated as defined. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_headers_sent_out_params() {
let diags = collect(
r#"<?php
function test(): void {
headers_sent($file, $line);
echo $file . ':' . $line;
}
"#,
);
assert!(
diags.is_empty(),
"headers_sent out-params $file/$line should be treated as defined. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_pcntl_wait_out_param() {
let diags = collect(
r#"<?php
function test(): void {
pcntl_wait($status);
echo $status;
}
"#,
);
assert!(
diags.is_empty(),
"pcntl_wait out-param $status should be treated as defined. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_dns_get_mx_out_params() {
let diags = collect(
r#"<?php
function test(string $host): void {
dns_get_mx($host, $mxhosts, $weights);
var_dump($mxhosts, $weights);
}
"#,
);
assert!(
diags.is_empty(),
"dns_get_mx out-params should be treated as defined. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_flock_out_param() {
let diags = collect(
r#"<?php
function test($fp): void {
flock($fp, LOCK_EX, $wouldblock);
echo $wouldblock;
}
"#,
);
assert!(
diags.is_empty(),
"flock out-param $wouldblock should be treated as defined. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_mb_parse_str_out_param() {
let diags = collect(
r#"<?php
function test(string $input): array {
mb_parse_str($input, $result);
return $result;
}
"#,
);
assert!(
diags.is_empty(),
"mb_parse_str out-param $result should be treated as defined. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_user_defined_function_byref_param() {
let php = r#"<?php
function myFunc(string $input, array &$output): void {
$output = [$input];
}
function test(string $val): void {
myFunc($val, $result);
echo $result[0];
}
"#;
let diags = collect(php);
assert!(
diags.is_empty(),
"User-defined function by-ref $result should be treated as defined. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_static_method_byref_param() {
let php = r#"<?php
class Validator {
public static function validate(string $input, array &$errors): bool {
$errors = [];
return true;
}
}
function test(string $data): void {
Validator::validate($data, $errors);
var_dump($errors);
}
"#;
let diags = collect(php);
assert!(
diags.is_empty(),
"Static method by-ref $errors should be treated as defined. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_constructor_byref_param() {
let php = r#"<?php
class Parser {
public function __construct(string $input, array &$warnings) {
$warnings = [];
}
}
function test(string $src): void {
$p = new Parser($src, $warnings);
var_dump($warnings);
}
"#;
let diags = collect(php);
assert!(
diags.is_empty(),
"Constructor by-ref $warnings should be treated as defined. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_fqn_user_defined_function_byref_param() {
let php = r#"<?php
namespace App;
function transform(string $in, array &$out): void {
$out = [$in];
}
function test(): void {
\App\transform('hello', $result);
echo $result[0];
}
"#;
let diags = collect(php);
assert!(
diags.is_empty(),
"FQN user-defined function by-ref $result should be treated as defined. Got: {:?}",
diags,
);
}
#[test]
fn diagnostic_still_fires_for_truly_undefined_after_non_byref_call() {
let php = r#"<?php
function noRefs(string $a): void {}
function test(): void {
noRefs('hello');
echo $undefined;
}
"#;
let diags = collect(php);
assert_eq!(
diags.len(),
1,
"Should flag $undefined even when resolver is active. Got: {:?}",
diags,
);
assert!(
diags[0].message.contains("$undefined"),
"Diagnostic should be for $undefined",
);
}
#[test]
fn no_diagnostic_for_arrow_fn_capturing_closure_variable() {
let diags = collect(
r#"<?php
function test(): void {
$callback = function (array $ids) {
$sortMap = array_flip($ids);
return array_map(fn($item) => $sortMap[$item], $ids);
};
}
"#,
);
assert!(
diags.is_empty(),
"Arrow fn should see variables from enclosing closure. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_nested_closure_use_captures() {
let diags = collect(
r#"<?php
function test(): void {
$outer = function () {
$brandIds = [1, 2, 3];
$typeIds = [4, 5, 6];
$inner = function () use ($brandIds, $typeIds) {
return [$brandIds[0], $typeIds[0]];
};
return $inner();
};
}
"#,
);
assert!(
diags.is_empty(),
"Nested closure use() captures should be visible. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_catch_inside_closure() {
let diags = collect(
r#"<?php
function test(): void {
$handler = function (string $payment) {
$client = getClient();
try {
$response = $client->send($payment);
} catch (\Exception $e) {
log($payment, $client, $e->getMessage());
}
};
}
"#,
);
assert!(
diags.is_empty(),
"Catch inside closure should see closure variables. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_variable_assigned_in_try_used_in_catch_inside_closure() {
let diags = collect(
r#"<?php
function test(): void {
$handler = function () {
$fullFilePath = '/tmp/test.jpg';
try {
process($fullFilePath);
} catch (\Throwable $e) {
fallback($fullFilePath);
}
};
}
"#,
);
assert!(
diags.is_empty(),
"Variable assigned before try should be visible in catch inside closure. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_arrow_fn_in_closure_in_method() {
let diags = collect(
r#"<?php
class Foo {
public function run(): void {
$this->process(function (array $products, array $ids) {
$sortMap = array_flip($ids);
return $products->sortBy(fn($product) => $sortMap[$product->id]);
});
}
}
"#,
);
assert!(
diags.is_empty(),
"Arrow fn in closure in method should see closure variables. Got: {:?}",
diags,
);
}
#[test]
fn no_diagnostic_for_deeply_nested_arrow_functions() {
let diags = collect(
r#"<?php
function test(): void {
$a = 1;
$f = function () use ($a) {
$b = 2;
$g = fn() => fn() => $a + $b;
return $g;
};
}
"#,
);
assert!(
diags.is_empty(),
"Deeply nested arrow fns should see all ancestor variables. Got: {:?}",
diags,
);
}
#[test]
fn flags_undefined_in_closure_without_capture_nested() {
let diags = collect(
r#"<?php
function test(): void {
$outer = function () {
$local = 42;
$inner = function () {
echo $local;
};
};
}
"#,
);
assert_eq!(
diags.len(),
1,
"Closure without use() should not see parent closure variables. Got: {:?}",
diags,
);
assert!(diags[0].message.contains("$local"));
}
}