diff --git a/config.xsd b/config.xsd index 917968bd0f2..cee3627fef1 100644 --- a/config.xsd +++ b/config.xsd @@ -255,6 +255,7 @@ + @@ -603,6 +604,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/docs/running_psalm/issues/InvalidConstantAssignmentValue.md b/docs/running_psalm/issues/InvalidConstantAssignmentValue.md new file mode 100644 index 00000000000..b0bf046ba39 --- /dev/null +++ b/docs/running_psalm/issues/InvalidConstantAssignmentValue.md @@ -0,0 +1,12 @@ +# InvalidConstantAssignmentValue + +Emitted when attempting to assign a value to a class constant that cannot contain that type. + +```php +getReportingLevelForFunction($issue_type, $e->function_id); } elseif ($e instanceof PropertyIssue) { $reporting_level = $this->getReportingLevelForProperty($issue_type, $e->property_id); + } elseif ($e instanceof ClassConstantIssue) { + $reporting_level = $this->getReportingLevelForClassConstant($issue_type, $e->const_id); } elseif ($e instanceof ArgumentIssue && $e->function_id) { $reporting_level = $this->getReportingLevelForArgument($issue_type, $e->function_id); } elseif ($e instanceof VariableIssue) { @@ -1794,6 +1797,15 @@ public function getReportingLevelForProperty(string $issue_type, string $propert return null; } + public function getReportingLevelForClassConstant(string $issue_type, string $constant_id): ?string + { + if (isset($this->issue_handlers[$issue_type])) { + return $this->issue_handlers[$issue_type]->getReportingLevelForClassConstant($constant_id); + } + + return null; + } + public function getReportingLevelForVariable(string $issue_type, string $var_name): ?string { if (isset($this->issue_handlers[$issue_type])) { diff --git a/src/Psalm/Config/FileFilter.php b/src/Psalm/Config/FileFilter.php index 2db9bdaaee8..23948b7708e 100644 --- a/src/Psalm/Config/FileFilter.php +++ b/src/Psalm/Config/FileFilter.php @@ -66,6 +66,11 @@ class FileFilter */ protected $property_ids = []; + /** + * @var array + */ + protected $class_constant_ids = []; + /** * @var array */ @@ -326,6 +331,13 @@ public static function loadFromArray( } } + if (isset($config['referencedConstant']) && is_iterable($config['referencedConstant'])) { + /** @var array $referenced_constant */ + foreach ($config['referencedConstant'] as $referenced_constant) { + $filter->class_constant_ids[] = strtolower((string) ($referenced_constant['name'] ?? '')); + } + } + if (isset($config['referencedVariable']) && is_iterable($config['referencedVariable'])) { /** @var array $referenced_variable */ foreach ($config['referencedVariable'] as $referenced_variable) { @@ -400,6 +412,14 @@ public static function loadFromXMLElement( } } + if ($e->referencedConstant) { + $config['referencedConstant'] = []; + /** @var SimpleXMLElement $referenced_constant */ + foreach ($e->referencedConstant as $referenced_constant) { + $config['referencedConstant'][]['name'] = strtolower((string)$referenced_constant['name']); + } + } + if ($e->referencedVariable) { $config['referencedVariable'] = []; @@ -533,6 +553,11 @@ public function allowsProperty(string $property_id): bool return in_array(strtolower($property_id), $this->property_ids, true); } + public function allowsClassConstant(string $constant_id): bool + { + return in_array(strtolower($constant_id), $this->class_constant_ids, true); + } + public function allowsVariable(string $var_name): bool { return in_array(strtolower($var_name), $this->var_names, true); diff --git a/src/Psalm/Config/IssueHandler.php b/src/Psalm/Config/IssueHandler.php index d3b6a3cdf0a..db68b8af824 100644 --- a/src/Psalm/Config/IssueHandler.php +++ b/src/Psalm/Config/IssueHandler.php @@ -131,6 +131,17 @@ public function getReportingLevelForProperty(string $property_id): ?string return null; } + public function getReportingLevelForClassConstant(string $constant_id): ?string + { + foreach ($this->custom_levels as $custom_level) { + if ($custom_level->allowsClassConstant($constant_id)) { + return $custom_level->getErrorLevel(); + } + } + + return null; + } + public function getReportingLevelForVariable(string $var_name): ?string { foreach ($this->custom_levels as $custom_level) { @@ -155,6 +166,7 @@ public static function getAllIssueTypes(): array fn(string $issue_name): bool => $issue_name !== '' && $issue_name !== 'MethodIssue' && $issue_name !== 'PropertyIssue' + && $issue_name !== 'ClassConstantIssue' && $issue_name !== 'FunctionIssue' && $issue_name !== 'ArgumentIssue' && $issue_name !== 'VariableIssue' diff --git a/src/Psalm/Internal/Analyzer/ClassLikeAnalyzer.php b/src/Psalm/Internal/Analyzer/ClassLikeAnalyzer.php index 254c87fd4f7..66a3dd7ea21 100644 --- a/src/Psalm/Internal/Analyzer/ClassLikeAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ClassLikeAnalyzer.php @@ -22,6 +22,7 @@ use Psalm\StatementsSource; use Psalm\Storage\ClassLikeStorage; use Psalm\Type; +use Psalm\Type\Atomic\TKeyedArray; use Psalm\Type\Union; use UnexpectedValueException; @@ -30,6 +31,7 @@ use function gettype; use function implode; use function in_array; +use function is_array; use function is_float; use function is_int; use function is_string; @@ -519,10 +521,26 @@ public static function getTypeFromValue($value): Union * Gets the Psalm literal type from a particular value * * @param array|scalar|null $value + * @throws InvalidArgumentException * */ - public static function getLiteralTypeFromValue($value): Type\Union + public static function getLiteralTypeFromValue($value, bool $sealed_array = true): Type\Union { + if (is_array($value)) { + if (empty($value)) { + return Type::getEmptyArray(); + } + + $types = []; + /** @var array|scalar|null $val */ + foreach ($value as $key => $val) { + $types[$key] = self::getLiteralTypeFromValue($val, $sealed_array); + } + $type = new TKeyedArray($types); + $type->sealed = $sealed_array; + return new Type\Union([$type]); + } + if (is_string($value)) { return Type::getString($value); } @@ -543,7 +561,11 @@ public static function getLiteralTypeFromValue($value): Type\Union return Type::getTrue(); } - return Type::getNull(); + if ($value === null) { + return Type::getNull(); + } + + throw new InvalidArgumentException(); } /** diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/ArrayAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/ArrayAnalyzer.php index af55952a1ea..9f5e1ff305b 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/ArrayAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/ArrayAnalyzer.php @@ -117,6 +117,12 @@ public static function analyze( $item_value_type = null; } + if ($item_key_type === null && $item_value_type === null) { + $statements_analyzer->node_data->setType($stmt, Type::getEmptyArray()); + + return true; + } + // if this array looks like an object-like array, let's return that instead if ($item_value_type && $item_key_type diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ClassConstFetchAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ClassConstFetchAnalyzer.php index 5ed42002c5a..10ef6434885 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ClassConstFetchAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ClassConstFetchAnalyzer.php @@ -15,11 +15,13 @@ use Psalm\Internal\Analyzer\StatementsAnalyzer; use Psalm\Internal\Analyzer\TraitAnalyzer; use Psalm\Internal\FileManipulation\FileManipulationBuffer; +use Psalm\Internal\Type\Comparator\UnionTypeComparator; use Psalm\Issue\CircularReference; use Psalm\Issue\DeprecatedClass; use Psalm\Issue\DeprecatedConstant; use Psalm\Issue\InaccessibleClassConstant; use Psalm\Issue\InternalClass; +use Psalm\Issue\InvalidConstantAssignmentValue; use Psalm\Issue\NonStaticSelfCall; use Psalm\Issue\ParentNotFound; use Psalm\Issue\UndefinedConstant; @@ -35,6 +37,7 @@ use Psalm\Type\Union; use ReflectionProperty; +use function assert; use function explode; use function in_array; use function strtolower; @@ -676,6 +679,32 @@ public static function analyzeClassConstAssignment( ): void { foreach ($stmt->consts as $const) { ExpressionAnalyzer::analyze($statements_analyzer, $const->value, $context); + + assert($context->self !== null); + $class_storage = $statements_analyzer->getCodebase()->classlike_storage_provider->get($context->self); + $const_storage = $class_storage->constants[$const->name->name]; + if ($assigned_type = $statements_analyzer->node_data->getType($const->value)) { + if ($const_storage->type !== null + && $const_storage->stmt_location !== null + && $assigned_type !== $const_storage->type + && !UnionTypeComparator::isContainedBy( + $statements_analyzer->getCodebase(), + $assigned_type, + $const_storage->type + ) + ) { + IssueBuffer::maybeAdd( + new InvalidConstantAssignmentValue( + "{$context->self}::{$const->name->name} with declared type {$const_storage->type->getId()} " + . "cannot be assigned type {$assigned_type->getId()}", + $const_storage->stmt_location, + "{$context->self}::{$const->name->name}" + ), + $const_storage->suppressed_issues, + true + ); + } + } } } } diff --git a/src/Psalm/Internal/Codebase/ConstantTypeResolver.php b/src/Psalm/Internal/Codebase/ConstantTypeResolver.php index dfc1a7f0d4e..20ebf069953 100644 --- a/src/Psalm/Internal/Codebase/ConstantTypeResolver.php +++ b/src/Psalm/Internal/Codebase/ConstantTypeResolver.php @@ -137,7 +137,9 @@ public static function resolve( } if ($left instanceof TKeyedArray && $right instanceof TKeyedArray) { - return new TKeyedArray($left->properties + $right->properties); + $type = new TKeyedArray($left->properties + $right->properties); + $type->sealed = true; + return $type; } return new TMixed; diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php index 35ae6b1fd9c..31104071ed6 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php @@ -1270,8 +1270,10 @@ private function visitClassConstDeclaration( ); $type_location = null; + $suppressed_issues = []; if ($var_comment !== null && $var_comment->type !== null) { $const_type = $var_comment->type; + $suppressed_issues = $var_comment->suppressed_issues; if ($var_comment->type_start !== null && $var_comment->type_end !== null @@ -1301,6 +1303,7 @@ private function visitClassConstDeclaration( $const->name ) ); + $constant_storage->suppressed_issues = $suppressed_issues; $constant_storage->type_location = $type_location; diff --git a/src/Psalm/Issue/ClassConstantIssue.php b/src/Psalm/Issue/ClassConstantIssue.php new file mode 100644 index 00000000000..e747e78ac78 --- /dev/null +++ b/src/Psalm/Issue/ClassConstantIssue.php @@ -0,0 +1,21 @@ +const_id = $const_id; + } +} diff --git a/src/Psalm/Issue/InvalidConstantAssignmentValue.php b/src/Psalm/Issue/InvalidConstantAssignmentValue.php new file mode 100644 index 00000000000..32922b5e37f --- /dev/null +++ b/src/Psalm/Issue/InvalidConstantAssignmentValue.php @@ -0,0 +1,8 @@ + + */ + public $suppressed_issues = []; + /** * @var ?string */ diff --git a/tests/ArrayAssignmentTest.php b/tests/ArrayAssignmentTest.php index ec0c6831b93..03f4f21f49d 100644 --- a/tests/ArrayAssignmentTest.php +++ b/tests/ArrayAssignmentTest.php @@ -1514,9 +1514,8 @@ function unpackIterable(Traversable $data): array $y = []; $x = [...$x, ...$y]; - - $x ? 1 : 0; ', + 'assertions' => ['$x' => 'array'] ], 'unpackEmptyKeepsCorrectKeys' => [ 'code' => ' + + + + + ' ) @@ -589,6 +594,14 @@ public function testIssueHandlerWithCustomErrorLevels(): void 'b' ) ); + + $this->assertSame( + 'suppress', + $config->getReportingLevelForClassConstant( + 'InvalidConstantAssignmentValue', + 'Psalm\Bodger::FOO' + ) + ); } public function testIssueHandlerSetDynamically(): void diff --git a/tests/ConstantTest.php b/tests/ConstantTest.php index 54fb1ff41ff..35e00879dda 100644 --- a/tests/ConstantTest.php +++ b/tests/ConstantTest.php @@ -619,6 +619,8 @@ class B extends A { class Clazz { /** + * @var 0|1 + * * @psalm-suppress RedundantCondition */ const cons2 = (cons1) ? 1 : 0; @@ -1293,6 +1295,30 @@ public function bar(): string } ', ], + 'classConstSuppress' => [ + ' [ + ' $arg */ + function foo(array $arg): void {} + foo([...A::ARR]); + ', + ], ]; } @@ -1642,6 +1668,28 @@ public function bar(int $key): string ', 'error_message' => 'UnresolvableConstant', ], + 'invalidConstantAssignmentType' => [ + ' "InvalidConstantAssignmentValue", + ], + 'invalidConstantAssignmentTypeResolvedLate' => [ + ' "InvalidConstantAssignmentValue", + ], ]; } }