mago-analyzer 1.21.1

A PHP static analyzer that can detect type errors in PHP code, and provide suggestions for fixing them.
Documentation
use std::rc::Rc;

use mago_codex::ttype::TType;
use mago_codex::ttype::add_optional_union_type;
use mago_codex::ttype::comparator::ComparisonResult;
use mago_codex::ttype::comparator::union_comparator;
use mago_codex::ttype::get_mixed;
use mago_codex::ttype::get_never;
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::ClassLikeMemberSelector;
use mago_syntax::ast::Expression;
use mago_syntax::ast::PropertyAccess;
use mago_syntax::ast::Variable;

use crate::artifacts::AnalysisArtifacts;
use crate::code::IssueCode;
use crate::context::Context;
use crate::context::block::BlockContext;
use crate::error::AnalysisError;
use crate::resolver::property::resolve_instance_properties;
use crate::utils::expression::get_property_access_expression_id;
use crate::utils::get_type_diff;

#[inline]
pub fn analyze<'ctx, 'arena>(
    context: &mut Context<'ctx, 'arena>,
    block_context: &mut BlockContext<'ctx>,
    artifacts: &mut AnalysisArtifacts,
    property_access: &PropertyAccess<'arena>,
    assigned_value_type: &TUnion,
    assigned_value_span: Option<Span>,
) -> Result<(), AnalysisError> {
    let property_access_id = get_property_access_expression_id(
        property_access.object,
        &property_access.property,
        false,
        block_context.scope.get_class_like_name(),
        context.resolved_names,
        Some(context.codebase),
    );

    let was_inside_assignment = block_context.flags.inside_assignment();
    block_context.flags.set_inside_assignment(true);
    let resolution_result = resolve_instance_properties(
        context,
        block_context,
        artifacts,
        property_access.object,
        &property_access.property,
        property_access.arrow.span(),
        false, // `null_safe`
        true,  // `for_assignment`
    )?;
    block_context.flags.set_inside_assignment(was_inside_assignment);

    let mut resolved_property_type = None;
    let mut matched_all_properties = true;
    for resolved_property in resolution_result.properties {
        let mut union_comparison_result = ComparisonResult::new();

        let type_match_found = union_comparator::is_contained_by(
            context.codebase,
            assigned_value_type,
            &resolved_property.property_type,
            true,
            assigned_value_type.ignore_falsable_issues(),
            false,
            &mut union_comparison_result,
        );

        if !type_match_found {
            let property_name = resolved_property.property_name;
            let property_type_str = resolved_property.property_type.get_id();
            let assigned_type_str = assigned_value_type.get_id();

            let mut issue;

            if let Some(true) = union_comparison_result.type_coerced {
                let issue_kind;

                if union_comparison_result.type_coerced_from_nested_mixed.unwrap_or(false) {
                    issue_kind = IssueCode::MixedPropertyTypeCoercion;
                    issue = Issue::error(format!(
                        "A value with a less specific type `{assigned_type_str}` is being assigned to property `${property_name}` ({property_type_str})."
                    ))
                    .with_note("The assigned value contains a nested `mixed` type, which can hide potential bugs.");
                } else {
                    issue_kind = IssueCode::PropertyTypeCoercion;
                    issue = Issue::error(format!(
                        "A value of a less specific type `{assigned_type_str}` is being assigned to property `${property_name}` ({property_type_str})."
                    ))
                    .with_note(format!("While `{assigned_type_str}` can be assigned to `{property_type_str}`, it is a wider type which may accept values that are invalid for this property."));
                }

                if let Some(value_span) = assigned_value_span {
                    issue = issue.with_annotation(
                        Annotation::primary(value_span)
                            .with_message(format!("This value has the less specific type `{assigned_type_str}`")),
                    );
                } else {
                    issue = issue.with_annotation(
                        Annotation::primary(property_access.span())
                            .with_message("The value assigned to this property is of a less specific type"),
                    );
                }

                if let Some(property_span) = resolved_property.property_span {
                    issue = issue.with_annotation(Annotation::secondary(property_span).with_message(format!(
                        "This property `{property_name}` is declared with type `{property_type_str}`"
                    )));
                }

                if let Some(type_diff) = get_type_diff(context, &resolved_property.property_type, assigned_value_type) {
                    issue = issue.with_note(type_diff);
                }

                context.collector.report_with_code(
                    issue_kind,
                    issue.with_help(
                        "Consider adding a type assertion to narrow the type of the value before the assignment.",
                    ),
                );
            } else {
                if let Some(value_span) = assigned_value_span {
                    issue = Issue::error(format!(
                        "Invalid type for property `{property_name}`: expected `{property_type_str}`, but got `{assigned_type_str}`."
                    ))
                    .with_annotation(
                        Annotation::primary(value_span)
                            .with_message(format!("This expression has type `{assigned_type_str}`")),
                    );
                } else {
                    issue = Issue::error(format!(
                        "Invalid assignment to property `{property_name}`: cannot assign value of type `{assigned_type_str}` to expected type `{property_type_str}`."
                    ))
                    .with_annotation(
                        Annotation::primary(property_access.span())
                            .with_message("The value assigned to this property is of an incompatible type"),
                    );
                }

                if let Some(property_span) = resolved_property.property_span {
                    issue = issue.with_annotation(Annotation::secondary(property_span).with_message(format!(
                        "This property `{property_name}` is declared with type `{property_type_str}`"
                    )));
                }

                if let Some(type_diff) = get_type_diff(context, &resolved_property.property_type, assigned_value_type) {
                    issue = issue.with_note(type_diff);
                }

                context.collector.report_with_code(
                    IssueCode::InvalidPropertyAssignmentValue,
                    issue
                         .with_note(format!("The type `{assigned_type_str}` is not compatible with and cannot be assigned to `{property_type_str}`."))
                         .with_help("Change the assigned value to match the property's type, or update the property's type declaration."),
                );
            }
        }

        resolved_property_type = Some(add_optional_union_type(
            resolved_property.property_type,
            resolved_property_type.as_ref(),
            context.codebase,
        ));

        matched_all_properties &= type_match_found;
    }

    let mut resulting_type = if matched_all_properties && context.settings.memoize_properties {
        Some(assigned_value_type.clone())
    } else {
        resolved_property_type
    };

    if resolution_result.has_ambiguous_path
        || resolution_result.encountered_mixed
        || resolution_result.has_possibly_defined_property
    {
        resulting_type = Some(add_optional_union_type(get_mixed(), resulting_type.as_ref(), context.codebase));
    }

    if resolution_result.has_error_path || resolution_result.has_invalid_path || resolution_result.encountered_null {
        resulting_type = Some(add_optional_union_type(get_never(), resulting_type.as_ref(), context.codebase));
    }

    let resulting_type = Rc::new(resulting_type.unwrap_or_else(get_never));

    if context.settings.memoize_properties
        && let Some(property_access_id) = property_access_id
    {
        block_context.locals.insert(property_access_id, resulting_type.clone());
    }

    artifacts.set_rc_expression_type(property_access, resulting_type);

    if block_context.flags.collect_initializations()
        && let Expression::Variable(Variable::Direct(var)) = property_access.object
        && var.name == "$this"
        && let ClassLikeMemberSelector::Identifier(ident) = &property_access.property
    {
        let property_name = mago_atom::Atom::from(format!("${}", ident.value).as_str());
        block_context.definitely_initialized_properties.insert(property_name);
        block_context.possibly_initialized_properties.insert(property_name);
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use indoc::indoc;

    use crate::test_analysis;

    test_analysis! {
        name = memoized_property_assignment,
        code = indoc! {r"
            <?php

            class A {
                /** @var int<0, max> */
                private int $a = 0;

                public function work(): void {
                    $this->a++;
                    $this->a--;
                    $this->a += 5;
                    $this->a -= 2;
                    $this->a *= 2;
                    $this->a %= 2;
                    $this->a = 1;
                    $this->a = 0;
                    ++$this->a;
                    --$this->a;
                }
            }
        "}
    }
}