use std::rc::Rc;
use mago_atom::atom;
use mago_codex::ttype::get_mixed;
use mago_codex::ttype::get_null;
use mago_codex::ttype::union::TUnion;
use mago_reporting::Annotation;
use mago_reporting::Issue;
use mago_span::HasSpan;
use mago_span::Span;
use mago_syntax::ast::DirectVariable;
use mago_syntax::ast::IndirectVariable;
use mago_syntax::ast::NestedVariable;
use mago_syntax::ast::Variable;
use crate::analyzable::Analyzable;
use crate::artifacts::AnalysisArtifacts;
use crate::code::IssueCode;
use crate::context::Context;
use crate::context::block::BlockContext;
use crate::error::AnalysisError;
use crate::expression::assignment;
impl<'ast, 'arena> Analyzable<'ast, 'arena> for Variable<'arena> {
fn analyze<'ctx>(
&'ast self,
context: &mut Context<'ctx, 'arena>,
block_context: &mut BlockContext<'ctx>,
artifacts: &mut AnalysisArtifacts,
) -> Result<(), AnalysisError> {
match self {
Variable::Direct(var) => var.analyze(context, block_context, artifacts),
Variable::Indirect(var) => var.analyze(context, block_context, artifacts),
Variable::Nested(var) => var.analyze(context, block_context, artifacts),
}
}
}
impl<'ast, 'arena> Analyzable<'ast, 'arena> for DirectVariable<'arena> {
fn analyze<'ctx>(
&'ast self,
context: &mut Context<'ctx, 'arena>,
block_context: &mut BlockContext<'ctx>,
artifacts: &mut AnalysisArtifacts,
) -> Result<(), AnalysisError> {
let resulting_type = read_variable(context, block_context, artifacts, self.name, self.span());
artifacts.set_rc_expression_type(self, resulting_type);
Ok(())
}
}
impl<'ast, 'arena> Analyzable<'ast, 'arena> for IndirectVariable<'arena> {
fn analyze<'ctx>(
&'ast self,
context: &mut Context<'ctx, 'arena>,
block_context: &mut BlockContext<'ctx>,
artifacts: &mut AnalysisArtifacts,
) -> Result<(), AnalysisError> {
self.expression.analyze(context, block_context, artifacts)?;
let resulting_type = match artifacts.get_expression_type(&self.expression) {
Some(expression_type) if expression_type.is_single() => {
match expression_type.get_single_literal_string_value() {
Some(value) => {
let variable_name = format!("${value}");
read_variable(context, block_context, artifacts, &variable_name, self.span())
}
_ => Rc::new(get_mixed()),
}
}
_ => Rc::new(get_mixed()),
};
artifacts.set_rc_expression_type(self, resulting_type);
Ok(())
}
}
impl<'ast, 'arena> Analyzable<'ast, 'arena> for NestedVariable<'arena> {
fn analyze<'ctx>(
&'ast self,
context: &mut Context<'ctx, 'arena>,
block_context: &mut BlockContext<'ctx>,
artifacts: &mut AnalysisArtifacts,
) -> Result<(), AnalysisError> {
self.variable.analyze(context, block_context, artifacts)?;
let resulting_type = match artifacts.get_expression_type(&self.variable) {
Some(expression_type) if expression_type.is_single() => {
match expression_type.get_single_literal_string_value() {
Some(value) => {
let variable_name = format!("${value}");
read_variable(context, block_context, artifacts, &variable_name, self.span())
}
_ => Rc::new(get_mixed()),
}
}
_ => Rc::new(get_mixed()),
};
artifacts.set_rc_expression_type(self, resulting_type);
Ok(())
}
}
fn read_variable<'ctx>(
context: &mut Context<'ctx, '_>,
block_context: &mut BlockContext<'ctx>,
artifacts: &mut AnalysisArtifacts,
variable_name: &str,
variable_span: Span,
) -> Rc<TUnion> {
let variable_atom = atom(variable_name);
block_context.add_conditionally_referenced_variable_atom(variable_name, variable_atom);
let variable_type = match block_context.locals.get(&variable_atom) {
Some(variable_type) => variable_type.clone(),
None => {
if block_context.variables_possibly_in_scope.contains(&variable_atom) {
if !block_context.flags.inside_isset() {
context.collector.report_with_code(
IssueCode::PossiblyUndefinedVariable,
Issue::warning(format!(
"Variable `{variable_name}` might not have been defined on all execution paths leading to this point.",
))
.with_annotation(
Annotation::primary(variable_span)
.with_message(format!("`{variable_name}` might be undefined here")),
)
.with_note("This can happen if the variable is assigned within a conditional block and there's an execution path to this usage where that block is skipped.")
.with_note("Accessing an undefined variable will result in an `E_WARNING` (PHP 8+) or `E_NOTICE` (PHP 7) and it will be treated as `null`.")
.with_help(format!("Initialize `{variable_name}` before conditional paths, or use `isset()` to check its existence."))
);
}
Rc::new(get_mixed())
} else if block_context.flags.inside_variable_reference() {
context.collector.report_with_code(
IssueCode::ReferenceToUndefinedVariable,
Issue::help(format!("Reference created from a previously undefined variable `{variable_name}`.",))
.with_annotation(
Annotation::primary(variable_span)
.with_message(format!("`{variable_name}` is created here and initialized to `null` because it's used as a reference")),
)
.with_note(
"When a reference is taken from an undefined variable, PHP creates it with a `null` value."
)
.with_note(
"This is often used for output parameters but can hide typos if you intended to use an existing variable."
)
.with_help(
format!("If this is intentional, consider initializing `{variable_name}` to `null` first for code clarity. Otherwise, check for typos.")
),
);
assignment::analyze_assignment_to_variable(
context,
block_context,
artifacts,
variable_span,
None,
Rc::new(get_null()),
variable_atom,
false,
);
Rc::new(get_mixed())
} else if block_context.flags.inside_unset() {
Rc::new(get_null())
} else if block_context.flags.inside_isset() {
Rc::new(get_mixed())
} else {
let mut issue = Issue::error(format!("Undefined variable: `{variable_name}`.")).with_annotation(
Annotation::primary(variable_span)
.with_message(format!("Variable `{variable_name}` used here but not defined")),
);
let mut has_confusable_characters = false;
if let Some(confusable_note) = generate_confusable_character_note(variable_name) {
has_confusable_characters = true;
issue = issue.with_note(confusable_note);
}
let similar_suggestions = find_similar_variable_names(block_context, variable_name);
let mut help_message =
format!("Ensure `{variable_name}` is assigned a value before this use, or check its scope.");
if !similar_suggestions.is_empty() {
let suggestions_str = similar_suggestions.join("`, `");
issue = issue.with_note(format!(
"Did you perhaps mean one of these defined variables: `{suggestions_str}`?"
));
help_message = format!(
"Check for typos (like those suggested above), ensure `{variable_name}` is assigned, or verify its scope."
);
} else if !has_confusable_characters {
help_message = format!(
"Ensure `{variable_name}` is assigned before use, or check for typos and variable scope."
);
}
context.collector.report_with_code(IssueCode::UndefinedVariable, issue.with_help(help_message));
Rc::new(get_mixed())
}
}
};
if variable_type.possibly_undefined_from_try() && !block_context.flags.inside_isset() {
context.collector.report_with_code(
IssueCode::PossiblyUndefinedVariable,
Issue::warning(format!(
"Variable `{variable_name}` might be undefined here because its assignment occurs within a `try` block.",
))
.with_annotation(
Annotation::primary(variable_span)
.with_message(format!("`{variable_name}` might be undefined due to an exception in the preceding `try` block")),
)
.with_note(
"This variable is assigned inside a `try` block. If an exception was thrown before this assignment was reached, the variable would not be defined in this context."
)
.with_note(
"Accessing an undefined variable will result in an `E_WARNING` (PHP 8+) or `E_NOTICE` (PHP 7) and it will be treated as `null`."
)
.with_help(format!(
"Initialize `{variable_name}` before the `try` block if it should always exist, or use `isset()` to check its existence.",
)),
);
}
variable_type
}
fn find_similar_variable_names(context: &BlockContext<'_>, target: &str) -> Vec<String> {
fn levenshtein_distance(s1: &str, s2: &str) -> usize {
const MAX_LEN: usize = 128;
if s1 == s2 {
return 0;
}
if s1.is_empty() {
return s2.chars().count();
}
if s2.is_empty() {
return s1.chars().count();
}
let mut s2_buf = ['\0'; MAX_LEN];
let mut s2_len = 0;
for c in s2.chars() {
if s2_len >= MAX_LEN {
return usize::MAX;
}
s2_buf[s2_len] = c;
s2_len += 1;
}
let mut row = [0usize; MAX_LEN + 1];
for (i, c) in row.iter_mut().enumerate().take(s2_len + 1) {
*c = i;
}
for (i, c1) in s1.chars().enumerate() {
let mut prev_sub = row[0];
row[0] = i + 1;
let mut row_min = row[0];
for j in 0..s2_len {
let c2 = s2_buf[j];
let prev_val = row[j + 1];
let substitution = prev_sub + if c1 == c2 { 0 } else { 1 };
let deletion = prev_val + 1;
let insertion = row[j] + 1;
let dist = substitution.min(deletion).min(insertion);
prev_sub = prev_val;
row[j + 1] = dist;
if dist < row_min {
row_min = dist;
}
}
if row_min > 3 {
return usize::MAX;
}
}
row[s2_len]
}
let mut suggestions: Vec<(usize, &str)> = Vec::new();
for local in context.locals.keys() {
let local_str = local.as_str();
if local_str.is_empty() {
continue;
}
let distance = levenshtein_distance(target, local_str);
if distance > 0 && distance <= 3 {
suggestions.push((distance, local_str));
}
}
suggestions.sort_by_key(|k| k.0);
suggestions.into_iter().map(|(_, name)| name.to_owned()).collect()
}
fn generate_confusable_character_note(variable_name: &str) -> Option<String> {
let mut has_non_std_ascii_alphanumeric = false;
let mut confusable_examples = Vec::new();
for c in variable_name.chars().skip(1) {
if !c.is_ascii_alphanumeric() && c != '_' {
if c.is_alphabetic() {
has_non_std_ascii_alphanumeric = true;
if c == '\u{0430}' {
confusable_examples.push("'а' (Cyrillic 'a')");
} else if c == '\u{03BF}' {
confusable_examples.push("'ο' (Greek 'o')");
}
} else if c > '\x7F' {
has_non_std_ascii_alphanumeric = true;
}
}
}
if has_non_std_ascii_alphanumeric {
let mut note = format!("Variable name `{variable_name}` contains non-standard ASCII alphanumeric characters.");
if !confusable_examples.is_empty() {
note.push_str(&format!(" For example, it might contain {}.", confusable_examples.join(" or ")));
}
note.push_str(" Please verify all characters are intended.");
Some(note)
} else {
None
}
}
#[cfg(test)]
mod tests {
use indoc::indoc;
use crate::code::IssueCode;
use crate::test_analysis;
test_analysis! {
name = possibly_undefined_variable_from_foreach,
code = indoc! {r#"
<?php
/**
* @param array<string, string> $arr
*/
function iter(array $arr)
{
$value = 1;
unset($value);
foreach ($arr as $key => $value) {
$y = 1;
echo 'Key: ' . $key . ', Value: ' . $value . "\n";
echo 'Y: ' . $y . "\n";
}
echo (string) $key;
echo (string) $value;
echo (string) $y;
}
"#},
issues = [
IssueCode::PossiblyUndefinedVariable, IssueCode::PossiblyUndefinedVariable, IssueCode::PossiblyUndefinedVariable, ]
}
test_analysis! {
name = defined_variable_from_foreach,
code = indoc! {r#"
<?php
/**
* @param non-empty-array<string, string> $arr
*/
function iter(array $arr)
{
$value = 1;
unset($value);
foreach ($arr as $key => $value) {
$y = 1;
echo 'Key: ' . $key . ', Value: ' . $value . "\n";
echo 'Y: ' . $y . "\n";
}
echo (string) $key;
echo (string) $value;
echo (string) $y;
}
"#},
issues = [
IssueCode::RedundantCast, IssueCode::RedundantCast, ]
}
}