mago-analyzer 1.21.0

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::atomic::TAtomic;
use mago_codex::ttype::atomic::object::TObject;
use mago_codex::ttype::combine_union_types;
use mago_codex::ttype::get_mixed;
use mago_codex::ttype::get_never;
use mago_reporting::Annotation;
use mago_reporting::Issue;
use mago_span::HasSpan;
use mago_syntax::ast::Clone;

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;

impl<'ast, 'arena> Analyzable<'ast, 'arena> for Clone<'arena> {
    fn analyze<'ctx>(
        &'ast self,
        context: &mut Context<'ctx, 'arena>,
        block_context: &mut BlockContext<'ctx>,
        artifacts: &mut AnalysisArtifacts,
    ) -> Result<(), AnalysisError> {
        self.object.analyze(context, block_context, artifacts)?;

        let Some(object_type) = artifacts.get_rc_expression_type(&self.object).cloned() else {
            return Ok(());
        };

        let mut invalid_clone_atomics = vec![];
        let mut has_mixed_type = false;
        let mut has_cloneable_object = false;

        let mut atomic_types = object_type.types.iter().collect::<Vec<_>>();
        while let Some(atomic_type) = atomic_types.pop() {
            match atomic_type {
                TAtomic::Object(object) => match object {
                    TObject::Any | TObject::WithProperties(_) | TObject::HasMethod(_) | TObject::HasProperty(_) => {
                        has_cloneable_object = true;
                    }
                    TObject::Enum(_) => {
                        invalid_clone_atomics.push(atomic_type);
                    }
                    TObject::Named(named_object) => {
                        if !context.codebase.trait_exists(&named_object.name)
                            && !context.codebase.class_or_interface_exists(&named_object.name)
                        {
                            invalid_clone_atomics.push(atomic_type);
                        } else {
                            has_cloneable_object = true;
                        }
                    }
                },
                TAtomic::GenericParameter(parameter) => {
                    atomic_types.extend(parameter.constraint.types.iter());
                }
                TAtomic::Mixed(_) => {
                    has_mixed_type = true;
                }
                TAtomic::Scalar(scalar) if scalar.is_false() && object_type.ignore_falsable_issues() => {}
                TAtomic::Null | TAtomic::Void if object_type.ignore_nullable_issues() => {}
                TAtomic::Callable(callable)
                    if callable
                        .get_signature()
                        .is_none_or(mago_codex::ttype::atomic::callable::TCallableSignature::is_closure) =>
                {
                    has_cloneable_object = true;
                }
                _ => {
                    invalid_clone_atomics.push(atomic_type);
                }
            }
        }

        if has_mixed_type {
            context.collector.report_with_code(
                IssueCode::MixedClone,
                Issue::warning("Cannot statically verify `clone` on a `mixed` type.")
                    .with_annotation(Annotation::primary(self.object.span()).with_message(format!(
                        "This expression has type `{}`, which could be a non-object at runtime.",
                        object_type.get_id()
                    )))
                    .with_note("Cloning requires the value to be an object. Using `clone` on a non-object will cause a fatal error.")
                    .with_help("Use type hints or `is_object()` checks to ensure the value is an object before cloning."),
            );
        }

        if !invalid_clone_atomics.is_empty() {
            let invalid_types_str =
                invalid_clone_atomics.iter().map(|t| t.get_id().as_str()).collect::<Vec<_>>().join("|");

            if has_cloneable_object || has_mixed_type {
                context.collector.report_with_code(
                    IssueCode::PossiblyInvalidClone,
                    Issue::warning(format!(
                        "Expression of type `{}` might not be a cloneable object.",
                        object_type.get_id()
                    ))
                    .with_annotation(Annotation::primary(self.object.span()).with_message(format!(
                        "This could be of type `{invalid_types_str}`, which cannot be cloned"
                    )))
                    .with_note("Attempting to `clone` a non-object or an enum will result in a fatal error.")
                    .with_help("Ensure the value is a cloneable object before this operation, for example by using an `instanceof` check."),
                );
            } else {
                let (primary_message, note) = if invalid_clone_atomics.iter().any(|t| t.is_enum()) {
                    (
                        "This expression is an enum, which is not cloneable",
                        "PHP enums are singleton-like objects and cannot be cloned. This will cause a fatal `Error`.",
                    )
                } else {
                    (
                        "This expression is not an object",
                        "The `clone` operator can only be used on objects. This will cause a fatal `Error`.",
                    )
                };

                context.collector.report_with_code(
                    IssueCode::InvalidClone,
                    Issue::error(format!(
                        "Invalid `clone` operation on non-cloneable type `{invalid_types_str}`."
                    ))
                    .with_annotation(Annotation::primary(self.object.span()).with_message(primary_message))
                    .with_note(note)
                    .with_help("Remove the `clone` operator or ensure the expression evaluates to a cloneable object instance."),
                );
            }
        }

        let resulting_type = if invalid_clone_atomics.is_empty() {
            object_type
        } else {
            Rc::new(if has_mixed_type {
                get_mixed()
            } else if has_cloneable_object {
                combine_union_types(&object_type, &get_never(), context.codebase, context.settings.combiner_options())
            } else {
                get_never()
            })
        };

        artifacts.set_rc_expression_type(self, resulting_type);

        Ok(())
    }
}

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

    use crate::code::IssueCode;
    use crate::test_analysis;

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

            class Example {}

            function get_clone(Example $example): Example {
                return clone $example;
            }
        "}
    }

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

            class Example {}

            function get_clone(Example|string $example): Example {
                return clone $example;
            }
        "},
        issues = [
            IssueCode::PossiblyInvalidClone,
            IssueCode::InvalidReturnStatement,
        ]
    }

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

            enum Color { case Red; }

            function get_clone(Color $color): Color {
                return clone $color;
            }
        "},
        issues = [
            IssueCode::InvalidClone,
            IssueCode::NeverReturn,
        ]
    }
}