mago-analyzer 1.18.1

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

use mago_codex::flags::attribute::AttributeFlags;

use mago_codex::metadata::class_like::ClassLikeMetadata;
use mago_reporting::Annotation;
use mago_reporting::Issue;
use mago_span::HasSpan;
use mago_syntax::ast::Attribute;
use mago_syntax::ast::AttributeList;

use crate::artifacts::AnalysisArtifacts;
use crate::code::IssueCode;
use crate::context::Context;
use crate::context::block::BlockContext;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum AttributeTarget {
    ClassLike,
    Method,
    Property,
    Parameter,
    PromotedProperty,
    ClassLikeConstant,
    Function,
    Constant,
}

impl AttributeTarget {
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::ClassLike => "a class, interface, enum, or trait",
            Self::Method => "a method",
            Self::Property => "a property",
            Self::Parameter => "a parameter",
            Self::PromotedProperty => "a promoted property",
            Self::ClassLikeConstant => "a class constant",
            Self::Function => "a function",
            Self::Constant => "a constant",
        }
    }
}

pub fn analyze_attributes<'ctx, 'arena>(
    context: &mut Context<'ctx, 'arena>,
    _block_context: &mut BlockContext<'ctx>,
    _artifacts: &mut AnalysisArtifacts,
    attribute_lists: &[AttributeList<'arena>],
    target: AttributeTarget,
) {
    let attributes = attribute_lists.iter().flat_map(|list| list.attributes.iter()).collect::<Vec<_>>();

    let mut used_attributes = HashMap::default();
    for attribute in attributes {
        let attribute_name = context.resolved_names.get(&attribute.name);

        let Some(metadata) = context.codebase.get_class_like(attribute_name) else {
            context.collector.report_with_code(
                IssueCode::NonExistentAttributeClass,
                Issue::error(format!("Attribute class `{attribute_name}` not found or could not be autoloaded."))
                .with_annotation(
                    Annotation::primary(attribute.name.span()).with_message(format!("Unknown attribute class `{attribute_name}`")),
                )
                .with_note("Attributes must be classes that are defined, correctly namespaced, and autoloadable. Ensure the class exists and is accessible.")
                .with_help("Verify the attribute class name, its namespace, and your autoloader configuration. Make sure the class is defined."),
            );

            continue;
        };

        let class_like_kind_str = metadata.kind.as_str();

        if !metadata.kind.is_class() {
            context.collector.report_with_code(
                IssueCode::NonClassUsedAsAttribute,
                Issue::error(format!(
                    "The {class_like_kind_str} `{attribute_name}` cannot be used as an attribute.",
                ))
                .with_annotation(
                    Annotation::primary(attribute.name.span())
                        .with_message(format!(
                            "`{attribute_name}` is a{} {class_like_kind_str} and not a class",
                            if metadata.kind.is_interface() || metadata.kind.is_enum() { "n" } else { "" }
                        )),
                )
                .with_annotation(
                    Annotation::secondary(metadata.name_span.unwrap_or(metadata.span))
                        .with_message(format!(
                            "`{attribute_name}` defined as a{} {class_like_kind_str} here",
                            if metadata.kind.is_interface() || metadata.kind.is_enum() { "n" } else { "" }
                        )),
                )
                .with_note("Only classes can be declared as attributes.")
                .with_note("Interfaces, enums, and traits are not valid attribute types.")
                .with_help(format!("Ensure you are using a class intended to be an attribute. Replace `{attribute_name}` with a valid attribute class.")),
            );

            continue;
        }

        if metadata.flags.is_abstract() {
            context.collector.report_with_code(
                IssueCode::AbstractClassUsedAsAttribute,
                Issue::error(format!("The abstract class `{attribute_name}` cannot be used as an attribute.",))
                    .with_annotation(Annotation::primary(attribute.name.span()).with_message(format!(
                        "`{attribute_name}` is an abstract class and cannot be instantiated as an attribute"
                    )))
                    .with_annotation(
                        Annotation::secondary(metadata.name_span.unwrap_or(metadata.span))
                            .with_message(format!("`{attribute_name}` defined here as an abstract class")),
                    )
                    .with_note("Attributes must be concrete classes that can be instantiated.")
                    .with_help(format!("Use a concrete class instead of `{attribute_name}` for attributes.")),
            );

            continue;
        }

        let Some(attribute_flags) = &metadata.attribute_flags else {
            context.collector.report_with_code(
                IssueCode::ClassNotMarkedAsAttribute,
                Issue::error(format!(
                    "Class `{attribute_name}` is used as an attribute but is not declared with `#[Attribute]`.",
                ))
                .with_annotation(
                    Annotation::primary(attribute.name.span()).with_message(format!("`{attribute_name}` used as an attribute here")),
                )
                .with_annotation(
                    Annotation::secondary(metadata.name_span.unwrap_or(metadata.span))
                        .with_message(format!("Class `{attribute_name}` defined here needs an `#[Attribute]` declaration")),
                )
                .with_note("To be used as a PHP attribute, a class must itself be decorated with the `#[\\Attribute]` system attribute.")
                .with_help(format!("Add `#[\\Attribute]` to the definition of class `{attribute_name}` to declare it as an attribute, or use a different class that is a valid attribute.")),
            );

            continue;
        };

        if let Some(first_usage_span) = used_attributes.get(&attribute_name)
            && !attribute_flags.is_repeatable()
        {
            context.collector.report_with_code(
                IssueCode::AttributeNotRepeatable,
                Issue::error(format!("Attribute `{attribute_name}` is not declared as repeatable and has already been used."))
                .with_annotation(
                    Annotation::primary(attribute.name.span())
                        .with_message(format!("Duplicate use of non-repeatable attribute `{attribute_name}`")),
                )
                .with_annotation(
                    Annotation::secondary(*first_usage_span)
                        .with_message(format!("Attribute `{attribute_name}` was first used here")),
                )
                .with_note(format!(
                    "The attribute `{attribute_name}` is not declared with `Attribute::IS_REPEATABLE` in its `#[Attribute]` flags. Non-repeatable attributes can only be applied once to a given target (e.g., a class, method, property).",
                ))
                .with_help(format!(
                    "Remove this duplicate `{attribute_name}` attribute, or if multiple instances are intended and valid, modify the attribute class `{attribute_name}` to include `Attribute::IS_REPEATABLE` in its `#[Attribute]` declaration (e.g., `#[Attribute(Attribute::TARGET_ALL | Attribute::IS_REPEATABLE)]`).",
                )),
            );

            continue;
        }

        used_attributes.insert(attribute_name, attribute.name.span());

        if let Some(flags) = metadata.attribute_flags {
            let is_valid_target = match target {
                AttributeTarget::ClassLike => flags.targets_class(),
                AttributeTarget::Method => flags.targets_method(),
                AttributeTarget::Property => flags.targets_property(),
                AttributeTarget::Parameter => flags.targets_parameter(),
                AttributeTarget::PromotedProperty => flags.targets_property() || flags.targets_parameter(),
                AttributeTarget::ClassLikeConstant => flags.targets_class_constant(),
                AttributeTarget::Function => flags.targets_function(),
                AttributeTarget::Constant => flags.targets_constant(),
            };

            if !is_valid_target {
                report_invalid_target(context, metadata, attribute, target, flags);
            }
        }
    }
}

fn report_invalid_target<'ctx, 'arena>(
    context: &mut Context<'ctx, 'arena>,
    metadata: &'ctx ClassLikeMetadata,
    attribute: &Attribute<'arena>,
    target: AttributeTarget,
    flags: AttributeFlags,
) {
    let attribute_name = metadata.original_name;
    let short_attribute_name = attribute_name.split('\\').next_back().unwrap_or(attribute_name.as_str());
    let allowed_targets = flags.get_target_names().join(", ");

    context.collector.report_with_code(
        IssueCode::InvalidAttributeTarget,
        Issue::error(format!("Attribute `{attribute_name}` cannot be used on {}.", target.as_str()))
            .with_annotation(Annotation::primary(attribute.name.span()).with_message("This attribute is not allowed here"))
            .with_annotation(
                Annotation::secondary(metadata.name_span.unwrap_or(metadata.span))
                    .with_message(format!("`{attribute_name}` defined here")),
            )
            .with_note(format!(
                "The definition of `{attribute_name}` restricts its use to the following targets: {allowed_targets}."
            ))
            .with_help(format!(
                "Remove the `#[{short_attribute_name}]` attribute from this location, or update the `#[Attribute]` declaration on the `{attribute_name}` class to include `{}` as a valid target.",
                target.as_str()
            ))
    );
}