1
0
mirror of https://github.com/danog/psalm.git synced 2024-12-02 17:52:45 +01:00
psalm/src/Psalm/Internal/Analyzer/AttributesAnalyzer.php

397 lines
14 KiB
PHP

<?php
namespace Psalm\Internal\Analyzer;
use Generator;
use PhpParser\Node\Arg;
use PhpParser\Node\Attribute;
use PhpParser\Node\AttributeGroup;
use PhpParser\Node\Expr\New_;
use PhpParser\Node\Name\FullyQualified;
use PhpParser\Node\Stmt\Expression;
use Psalm\CodeLocation;
use Psalm\Context;
use Psalm\Internal\Analyzer\StatementsAnalyzer;
use Psalm\Internal\Codebase\ConstantTypeResolver;
use Psalm\Internal\Provider\NodeDataProvider;
use Psalm\Internal\Scanner\UnresolvedConstantComponent;
use Psalm\Issue\InvalidAttribute;
use Psalm\Issue\UndefinedClass;
use Psalm\IssueBuffer;
use Psalm\Storage\ClassLikeStorage;
use Psalm\Storage\HasAttributesInterface;
use Psalm\Type\Atomic\TLiteralString;
use Psalm\Type\Union;
use function array_shift;
use function array_values;
use function assert;
use function count;
use function reset;
use function strtolower;
/**
* @internal
*/
final class AttributesAnalyzer
{
private const TARGET_DESCRIPTIONS = [
1 => 'class',
2 => 'function',
4 => 'method',
8 => 'property',
16 => 'class constant',
32 => 'function/method parameter',
40 => 'promoted property',
];
// Copied from Attribute class since that class might not exist at runtime
public const TARGET_CLASS = 1;
public const TARGET_FUNCTION = 2;
public const TARGET_METHOD = 4;
public const TARGET_PROPERTY = 8;
public const TARGET_CLASS_CONSTANT = 16;
public const TARGET_PARAMETER = 32;
public const TARGET_ALL = 63;
public const IS_REPEATABLE = 64;
/**
* @param array<array-key, AttributeGroup> $attribute_groups
* @param key-of<self::TARGET_DESCRIPTIONS> $target
* @param array<array-key, string> $suppressed_issues
*/
public static function analyze(
SourceAnalyzer $source,
Context $context,
HasAttributesInterface $storage,
array $attribute_groups,
int $target,
array $suppressed_issues
): void {
$codebase = $source->getCodebase();
$appearing_non_repeatable_attributes = [];
foreach (self::iterateAttributeNodes($attribute_groups) as $attribute) {
if ($attribute->name instanceof FullyQualified) {
$fq_attribute_name = (string) $attribute->name;
} else {
$fq_attribute_name = ClassLikeAnalyzer::getFQCLNFromNameObject($attribute->name, $source->getAliases());
}
$attribute_name = (string) $attribute->name;
$attribute_name_location = new CodeLocation($source, $attribute->name);
$attribute_class_storage = $codebase->classlikes->classExists($fq_attribute_name)
? $codebase->classlike_storage_provider->get($fq_attribute_name)
: null;
$attribute_class_flags = self::getAttributeClassFlags(
$source,
$attribute_name,
$fq_attribute_name,
$attribute_name_location,
$attribute_class_storage,
$suppressed_issues,
);
self::analyzeAttributeConstruction(
$source,
$context,
$fq_attribute_name,
$attribute,
$suppressed_issues,
$storage instanceof ClassLikeStorage ? $storage : null,
);
if (($attribute_class_flags & self::IS_REPEATABLE) === 0) {
// Not IS_REPEATABLE
if (isset($appearing_non_repeatable_attributes[$fq_attribute_name])) {
IssueBuffer::maybeAdd(
new InvalidAttribute(
"Attribute {$attribute_name} is not repeatable",
$attribute_name_location,
),
$suppressed_issues,
);
}
$appearing_non_repeatable_attributes[$fq_attribute_name] = true;
}
if (($attribute_class_flags & $target) === 0) {
IssueBuffer::maybeAdd(
new InvalidAttribute(
"Attribute {$attribute_name} cannot be used on a "
. self::TARGET_DESCRIPTIONS[$target],
$attribute_name_location,
),
$suppressed_issues,
);
}
}
}
/**
* @param array<array-key, string> $suppressed_issues
*/
private static function analyzeAttributeConstruction(
SourceAnalyzer $source,
Context $context,
string $fq_attribute_name,
Attribute $attribute,
array $suppressed_issues,
?ClassLikeStorage $classlike_storage = null
): void {
$attribute_name_location = new CodeLocation($source, $attribute->name);
if (ClassLikeAnalyzer::checkFullyQualifiedClassLikeName(
$source,
$fq_attribute_name,
$attribute_name_location,
null,
null,
$suppressed_issues,
new ClassLikeNameOptions(
false,
false,
false,
false,
false,
true,
),
) === false) {
return;
}
if (strtolower($fq_attribute_name) === 'attribute' && $classlike_storage) {
if ($classlike_storage->is_trait) {
IssueBuffer::maybeAdd(
new InvalidAttribute(
'Traits cannot act as attribute classes',
$attribute_name_location,
),
$suppressed_issues,
);
} elseif ($classlike_storage->is_interface) {
IssueBuffer::maybeAdd(
new InvalidAttribute(
'Interfaces cannot act as attribute classes',
$attribute_name_location,
),
$suppressed_issues,
);
} elseif ($classlike_storage->abstract) {
IssueBuffer::maybeAdd(
new InvalidAttribute(
'Abstract classes cannot act as attribute classes',
$attribute_name_location,
),
$suppressed_issues,
);
} elseif (isset($classlike_storage->methods['__construct'])
&& $classlike_storage->methods['__construct']->visibility !== ClassLikeAnalyzer::VISIBILITY_PUBLIC
) {
IssueBuffer::maybeAdd(
new InvalidAttribute(
'Classes with protected/private constructors cannot act as attribute classes',
$attribute_name_location,
),
$suppressed_issues,
);
} elseif ($classlike_storage->is_enum) {
IssueBuffer::maybeAdd(
new InvalidAttribute(
'Enums cannot act as attribute classes',
$attribute_name_location,
),
$suppressed_issues,
);
}
}
$statements_analyzer = new StatementsAnalyzer(
$source,
new NodeDataProvider(),
);
$statements_analyzer->addSuppressedIssues(array_values($suppressed_issues));
$had_returned = $context->has_returned;
$context->has_returned = false;
IssueBuffer::startRecording();
$statements_analyzer->analyze(
[new Expression(new New_($attribute->name, $attribute->args, $attribute->getAttributes()))],
// Use a new Context for the Attribute attribute so that it can't access `self`
strtolower($fq_attribute_name) === "attribute" ? new Context() : $context,
);
$context->has_returned = $had_returned;
$issues = IssueBuffer::clearRecordingLevel();
IssueBuffer::stopRecording();
foreach ($issues as $issue) {
if ($issue instanceof UndefinedClass && $issue->fq_classlike_name === $fq_attribute_name) {
// Remove UndefinedClass for the attribute, since we already added UndefinedAttribute
continue;
}
IssueBuffer::bubbleUp($issue);
}
}
/**
* @param array<array-key, string> $suppressed_issues
*/
private static function getAttributeClassFlags(
SourceAnalyzer $source,
string $attribute_name,
string $fq_attribute_name,
CodeLocation $attribute_name_location,
?ClassLikeStorage $attribute_class_storage,
array $suppressed_issues
): int {
if (strtolower($fq_attribute_name) === "attribute") {
// We override this here because we still want to analyze attributes
// for PHP 7.4 when the Attribute class doesn't yet exist.
return self::TARGET_CLASS;
}
if ($attribute_class_storage === null) {
return self::TARGET_ALL; // Defaults to TARGET_ALL
}
foreach ($attribute_class_storage->attributes as $attribute_attribute) {
if ($attribute_attribute->fq_class_name === 'Attribute') {
if (!$attribute_attribute->args) {
return self::TARGET_ALL; // Defaults to TARGET_ALL
}
$first_arg = reset($attribute_attribute->args);
$first_arg_type = $first_arg->type;
if ($first_arg_type instanceof UnresolvedConstantComponent) {
$first_arg_type = new Union([
ConstantTypeResolver::resolve(
$source->getCodebase()->classlikes,
$first_arg_type,
$source instanceof StatementsAnalyzer ? $source : null,
),
]);
}
if (!$first_arg_type->isSingleIntLiteral()) {
return self::TARGET_ALL; // Fall back to default if it's invalid
}
return $first_arg_type->getSingleIntLiteral()->value;
}
}
IssueBuffer::maybeAdd(
new InvalidAttribute(
"The class {$attribute_name} doesn't have the Attribute attribute",
$attribute_name_location,
),
$suppressed_issues,
);
return self::TARGET_ALL; // Fall back to default if it's invalid
}
/**
* @param iterable<AttributeGroup> $attribute_groups
* @return Generator<int, Attribute>
*/
private static function iterateAttributeNodes(iterable $attribute_groups): Generator
{
foreach ($attribute_groups as $attribute_group) {
foreach ($attribute_group->attrs as $attribute) {
yield $attribute;
}
}
}
/**
* Analyze Reflection getAttributes method calls.
* @param list<Arg> $args
*/
public static function analyzeGetAttributes(
StatementsAnalyzer $statements_analyzer,
string $method_id,
array $args
): void {
if (count($args) !== 1) {
// We skip this analysis if $flags is specified on getAttributes, since the only option
// is ReflectionAttribute::IS_INSTANCEOF, which causes getAttributes to return children.
// When returning children we don't want to limit this since a child could add a target.
return;
}
switch ($method_id) {
case "ReflectionClass::getattributes":
$target = self::TARGET_CLASS;
break;
case "ReflectionFunction::getattributes":
$target = self::TARGET_FUNCTION;
break;
case "ReflectionMethod::getattributes":
$target = self::TARGET_METHOD;
break;
case "ReflectionProperty::getattributes":
$target = self::TARGET_PROPERTY;
break;
case "ReflectionClassConstant::getattributes":
$target = self::TARGET_CLASS_CONSTANT;
break;
case "ReflectionParameter::getattributes":
$target = self::TARGET_PARAMETER;
break;
default:
return;
}
$arg = $args[0];
if ($arg->name !== null) {
for (; !empty($args) && ($arg->name->name ?? null) !== "name"; $arg = array_shift($args));
if ($arg->name->name ?? null !== "name") {
// No named argument for "name" parameter
return;
}
}
$arg_type = $statements_analyzer->getNodeTypeProvider()->getType($arg->value);
if ($arg_type === null || !$arg_type->isSingle() || !$arg_type->hasLiteralString()) {
return;
}
$class_string = $arg_type->getSingleAtomic();
assert($class_string instanceof TLiteralString);
$codebase = $statements_analyzer->getCodebase();
if (!$codebase->classExists($class_string->value)) {
return;
}
$class_storage = $codebase->classlike_storage_provider->get($class_string->value);
$arg_location = new CodeLocation($statements_analyzer, $arg);
$class_attribute_target = self::getAttributeClassFlags(
$statements_analyzer,
$class_string->value,
$class_string->value,
$arg_location,
$class_storage,
$statements_analyzer->getSuppressedIssues(),
);
if (($class_attribute_target & $target) === 0) {
IssueBuffer::maybeAdd(
new InvalidAttribute(
"Attribute {$class_string->value} cannot be used on a "
. self::TARGET_DESCRIPTIONS[$target],
$arg_location,
),
$statements_analyzer->getSuppressedIssues(),
);
}
}
}