diff --git a/config.xsd b/config.xsd index ad48f587e59..cee3627fef1 100644 --- a/config.xsd +++ b/config.xsd @@ -255,6 +255,7 @@ + @@ -458,6 +459,7 @@ + @@ -602,6 +604,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/docs/running_psalm/issues.md b/docs/running_psalm/issues.md index 7c3da484357..dca69c9dda2 100644 --- a/docs/running_psalm/issues.md +++ b/docs/running_psalm/issues.md @@ -61,6 +61,7 @@ - [InvalidCatch](issues/InvalidCatch.md) - [InvalidClass](issues/InvalidClass.md) - [InvalidClone](issues/InvalidClone.md) + - [InvalidConstantAssignmentValue](issues/InvalidConstantAssignmentValue.md) - [InvalidDocblock](issues/InvalidDocblock.md) - [InvalidDocblockParamName](issues/InvalidDocblockParamName.md) - [InvalidEnumBackingType](issues/InvalidEnumBackingType.md) @@ -266,6 +267,7 @@ - [UnnecessaryVarAnnotation](issues/UnnecessaryVarAnnotation.md) - [UnrecognizedExpression](issues/UnrecognizedExpression.md) - [UnrecognizedStatement](issues/UnrecognizedStatement.md) + - [UnresolvableConstant](issues/UnresolvableConstant.md) - [UnresolvableInclude](issues/UnresolvableInclude.md) - [UnsafeGenericInstantiation](issues/UnsafeGenericInstantiation.md) - [UnsafeInstantiation](issues/UnsafeInstantiation.md) 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 + + */ + public function bar(): string + { + return self::BAR[0]; + } +} +``` diff --git a/src/Psalm/Config.php b/src/Psalm/Config.php index c80ca64de65..375431dfbf9 100644 --- a/src/Psalm/Config.php +++ b/src/Psalm/Config.php @@ -27,6 +27,7 @@ use Psalm\Internal\Provider\AddRemoveTaints\HtmlFunctionTainter; use Psalm\Internal\Scanner\FileScanner; use Psalm\Issue\ArgumentIssue; +use Psalm\Issue\ClassConstantIssue; use Psalm\Issue\ClassIssue; use Psalm\Issue\CodeIssue; use Psalm\Issue\ConfigIssue; @@ -1583,6 +1584,8 @@ public function getReportingLevelForIssue(CodeIssue $e): string $reporting_level = $this->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/Exception/UnresolvableConstantException.php b/src/Psalm/Exception/UnresolvableConstantException.php new file mode 100644 index 00000000000..10032182587 --- /dev/null +++ b/src/Psalm/Exception/UnresolvableConstantException.php @@ -0,0 +1,24 @@ +class_name = $class_name; + $this->const_name = $const_name; + } +} diff --git a/src/Psalm/Internal/Analyzer/FunctionLike/ReturnTypeAnalyzer.php b/src/Psalm/Internal/Analyzer/FunctionLike/ReturnTypeAnalyzer.php index 5382c4e02a1..6f68fc02250 100644 --- a/src/Psalm/Internal/Analyzer/FunctionLike/ReturnTypeAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/FunctionLike/ReturnTypeAnalyzer.php @@ -11,6 +11,7 @@ use Psalm\CodeLocation; use Psalm\Config; use Psalm\Context; +use Psalm\Exception\UnresolvableConstantException; use Psalm\Internal\Analyzer\ClassAnalyzer; use Psalm\Internal\Analyzer\FunctionLikeAnalyzer; use Psalm\Internal\Analyzer\InterfaceAnalyzer; @@ -40,6 +41,7 @@ use Psalm\Issue\MixedInferredReturnType; use Psalm\Issue\MixedReturnTypeCoercion; use Psalm\Issue\MoreSpecificReturnType; +use Psalm\Issue\UnresolvableConstant; use Psalm\IssueBuffer; use Psalm\StatementsSource; use Psalm\Storage\FunctionLikeStorage; @@ -821,15 +823,27 @@ public static function checkReturnType( return null; } - $fleshed_out_return_type = TypeExpander::expandUnion( - $codebase, - $storage->return_type, - $classlike_storage->name ?? null, - $classlike_storage->name ?? null, - $parent_class, - true, - true - ); + try { + $fleshed_out_return_type = TypeExpander::expandUnion( + $codebase, + $storage->return_type, + $classlike_storage->name ?? null, + $classlike_storage->name ?? null, + $parent_class, + true, + true + ); + } catch (UnresolvableConstantException $e) { + IssueBuffer::maybeAdd( + new UnresolvableConstant( + "Could not resolve constant {$e->class_name}::{$e->const_name}", + $storage->return_type_location + ), + $storage->suppressed_issues, + true + ); + $fleshed_out_return_type = $storage->return_type; + } if ($fleshed_out_return_type->check( $function_like_analyzer, diff --git a/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php b/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php index 459db1133bd..551e7e8b73c 100644 --- a/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php @@ -10,6 +10,7 @@ use Psalm\CodeLocation; use Psalm\Codebase; use Psalm\Context; +use Psalm\Exception\UnresolvableConstantException; use Psalm\FileManipulation; use Psalm\Internal\Analyzer\FunctionLike\ReturnTypeAnalyzer; use Psalm\Internal\Analyzer\FunctionLike\ReturnTypeCollector; @@ -38,6 +39,7 @@ use Psalm\Issue\MissingThrowsDocblock; use Psalm\Issue\ReferenceConstraintViolation; use Psalm\Issue\ReservedWord; +use Psalm\Issue\UnresolvableConstant; use Psalm\Issue\UnusedClosureParam; use Psalm\Issue\UnusedParam; use Psalm\IssueBuffer; @@ -1013,17 +1015,30 @@ private function processParams( if ($function_param->type) { $param_type = clone $function_param->type; - $param_type = TypeExpander::expandUnion( - $codebase, - $param_type, - $context->self, - $context->self, - $this->getParentFQCLN(), - true, - false, - false, - true - ); + try { + $param_type = TypeExpander::expandUnion( + $codebase, + $param_type, + $context->self, + $context->self, + $this->getParentFQCLN(), + true, + false, + false, + true + ); + } catch (UnresolvableConstantException $e) { + if ($function_param->type_location !== null) { + IssueBuffer::maybeAdd( + new UnresolvableConstant( + "Could not resolve constant {$e->class_name}::{$e->const_name}", + $function_param->type_location + ), + $storage->suppressed_issues, + true + ); + } + } if ($function_param->type_location) { if ($param_type->check( 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 dc6fa069753..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; @@ -252,7 +255,9 @@ public static function analyze( $fq_class_name, $stmt->name->name, $class_visibility, - $statements_analyzer + $statements_analyzer, + [], + $stmt->class->parts[0] === "static" ); } catch (InvalidArgumentException $_) { return true; @@ -378,7 +383,7 @@ public static function analyze( ); } - if ($first_part_lc !== 'static' || $const_class_storage->final) { + if ($first_part_lc !== 'static' || $const_class_storage->final || $class_constant_type->from_docblock) { $stmt_type = clone $class_constant_type; $statements_analyzer->node_data->setType($stmt, $stmt_type); @@ -674,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/Analyzer/Statements/Expression/TernaryAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/TernaryAnalyzer.php index 99ed3eb6c45..39905239d37 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/TernaryAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/TernaryAnalyzer.php @@ -274,12 +274,12 @@ function (Clause $c) use ($mixed_var_ids, $cond_id): Clause { ); $lhs_type = null; - + $stmt_cond_type = $statements_analyzer->node_data->getType($stmt->cond); if ($stmt->if) { if ($stmt_if_type = $statements_analyzer->node_data->getType($stmt->if)) { $lhs_type = $stmt_if_type; } - } elseif ($stmt_cond_type = $statements_analyzer->node_data->getType($stmt->cond)) { + } elseif ($stmt_cond_type) { $if_return_type_reconciled = AssertionReconciler::reconcile( new Truthy(), clone $stmt_cond_type, @@ -295,7 +295,13 @@ function (Clause $c) use ($mixed_var_ids, $cond_id): Clause { } if ($lhs_type && ($stmt_else_type = $statements_analyzer->node_data->getType($stmt->else))) { - $statements_analyzer->node_data->setType($stmt, Type::combineUnionTypes($lhs_type, $stmt_else_type)); + if ($stmt_cond_type !== null && $stmt_cond_type->isAlwaysFalsy()) { + $statements_analyzer->node_data->setType($stmt, $stmt_else_type); + } elseif ($stmt_cond_type !== null && $stmt_cond_type->isAlwaysTruthy()) { + $statements_analyzer->node_data->setType($stmt, $lhs_type); + } else { + $statements_analyzer->node_data->setType($stmt, Type::combineUnionTypes($lhs_type, $stmt_else_type)); + } } else { $statements_analyzer->node_data->setType($stmt, Type::getMixed()); } diff --git a/src/Psalm/Internal/Codebase/ClassLikes.php b/src/Psalm/Internal/Codebase/ClassLikes.php index aad785051c0..1d394e2fa37 100644 --- a/src/Psalm/Internal/Codebase/ClassLikes.php +++ b/src/Psalm/Internal/Codebase/ClassLikes.php @@ -1632,7 +1632,8 @@ public function getClassConstantType( string $constant_name, int $visibility, ?StatementsAnalyzer $statements_analyzer = null, - array $visited_constant_ids = [] + array $visited_constant_ids = [], + bool $late_static_binding = false ): ?Union { $class_name = strtolower($class_name); @@ -1659,15 +1660,18 @@ public function getClassConstantType( } if ($constant_storage->unresolved_node) { - $constant_storage->type = new Union([ConstantTypeResolver::resolve( + $constant_storage->inferred_type = new Union([ConstantTypeResolver::resolve( $this, $constant_storage->unresolved_node, $statements_analyzer, $visited_constant_ids )]); + if ($constant_storage->type === null || !$constant_storage->type->from_docblock) { + $constant_storage->type = $constant_storage->inferred_type; + } } - return $constant_storage->type; + return $late_static_binding ? $constant_storage->type : ($constant_storage->inferred_type ?? null); } elseif (isset($storage->enum_cases[$constant_name])) { return new Union([new TEnumCase($storage->name, $constant_name)]); } diff --git a/src/Psalm/Internal/Codebase/ConstantTypeResolver.php b/src/Psalm/Internal/Codebase/ConstantTypeResolver.php index dfc1a7f0d4e..88c9e03b189 100644 --- a/src/Psalm/Internal/Codebase/ConstantTypeResolver.php +++ b/src/Psalm/Internal/Codebase/ConstantTypeResolver.php @@ -2,6 +2,7 @@ namespace Psalm\Internal\Codebase; +use InvalidArgumentException; use Psalm\Exception\CircularReferenceException; use Psalm\Internal\Analyzer\Statements\Expression\Fetch\ConstFetchAnalyzer; use Psalm\Internal\Analyzer\StatementsAnalyzer; @@ -40,6 +41,7 @@ use ReflectionProperty; use function ctype_digit; +use function is_array; use function is_float; use function is_int; use function is_string; @@ -137,7 +139,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; @@ -332,10 +336,27 @@ public static function resolve( } /** - * @param string|int|float|bool|null $value + * Note: This takes an array, but any array should only contain other arrays and scalars. + * + * @param array|string|int|float|bool|null $value */ - private static function getLiteralTypeFromScalarValue($value): Atomic + public static function getLiteralTypeFromScalarValue($value, bool $sealed_array = true): Atomic { + if (is_array($value)) { + if (empty($value)) { + return Type::getEmptyArray()->getSingleAtomic(); + } + + $types = []; + /** @var array|scalar|null $val */ + foreach ($value as $key => $val) { + $types[$key] = new Union([self::getLiteralTypeFromScalarValue($val, $sealed_array)]); + } + $type = new TKeyedArray($types); + $type->sealed = $sealed_array; + return $type; + } + if (is_string($value)) { return new TLiteralString($value); } @@ -349,13 +370,17 @@ private static function getLiteralTypeFromScalarValue($value): Atomic } if ($value === false) { - return new TFalse; + return new TFalse(); } if ($value === true) { - return new TTrue; + return new TTrue(); + } + + if ($value === null) { + return new TNull(); } - return new TNull; + throw new InvalidArgumentException('$value must be a scalar.'); } } diff --git a/src/Psalm/Internal/Codebase/Reflection.php b/src/Psalm/Internal/Codebase/Reflection.php index 424c2a1cdeb..45a12766cec 100644 --- a/src/Psalm/Internal/Codebase/Reflection.php +++ b/src/Psalm/Internal/Codebase/Reflection.php @@ -171,6 +171,7 @@ public function registerClass(ReflectionClass $reflected_class): void foreach ($class_constants as $name => $value) { $storage->constants[$name] = new ClassConstantStorage( ClassLikeAnalyzer::getTypeFromValue($value), + new Union([ConstantTypeResolver::getLiteralTypeFromScalarValue($value)]), ClassLikeAnalyzer::VISIBILITY_PUBLIC, null ); diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php index 977d7888e96..31104071ed6 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php @@ -1208,6 +1208,7 @@ private function visitClassConstDeclaration( $existing_constants = $storage->constants; $comment = $stmt->getDocComment(); + $var_comment = null; $deprecated = false; $description = null; $config = $this->config; @@ -1220,19 +1221,31 @@ private function visitClassConstDeclaration( } $description = $comments->description; + + try { + $var_comments = CommentAnalyzer::getTypeFromComment( + $comment, + $this->file_scanner, + $this->aliases, + [], + $this->type_aliases + ); + + $var_comment = array_pop($var_comments); + } catch (IncorrectDocblockException $e) { + $storage->docblock_issues[] = new MissingDocblockType( + $e->getMessage(), + new CodeLocation($this->file_scanner, $stmt, null, true) + ); + } catch (DocblockParseException $e) { + $storage->docblock_issues[] = new InvalidDocblock( + $e->getMessage(), + new CodeLocation($this->file_scanner, $stmt, null, true) + ); + } } foreach ($stmt->consts as $const) { - $const_type = SimpleTypeInferer::infer( - $this->codebase, - new NodeDataProvider(), - $const->value, - $this->aliases, - null, - $existing_constants, - $fq_classlike_name - ); - if (isset($storage->constants[$const->name->name]) || isset($storage->enum_cases[$const->name->name]) ) { @@ -1246,8 +1259,40 @@ private function visitClassConstDeclaration( continue; } + $inferred_type = SimpleTypeInferer::infer( + $this->codebase, + new NodeDataProvider(), + $const->value, + $this->aliases, + null, + $existing_constants, + $fq_classlike_name + ); + + $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 + && $var_comment->line_number !== null + ) { + $type_location = new DocblockTypeLocation( + $this->file_scanner, + $var_comment->type_start, + $var_comment->type_end, + $var_comment->line_number + ); + } + } else { + $const_type = $inferred_type; + } + $storage->constants[$const->name->name] = $constant_storage = new ClassConstantStorage( $const_type, + $inferred_type, $stmt->isProtected() ? ClassLikeAnalyzer::VISIBILITY_PROTECTED : ($stmt->isPrivate() @@ -1258,31 +1303,23 @@ private function visitClassConstDeclaration( $const->name ) ); + $constant_storage->suppressed_issues = $suppressed_issues; + + $constant_storage->type_location = $type_location; $constant_storage->stmt_location = new CodeLocation( $this->file_scanner, $const ); - if ($const_type - && $const->value instanceof Concat - && $const_type->isSingle() - && get_class($const_type->getSingleAtomic()) === TString::class + if ($inferred_type + && !( + $const->value instanceof Concat + && $inferred_type->isSingle() + && get_class($inferred_type->getSingleAtomic()) === TString::class + ) ) { - // Prefer unresolved type over inferred string from concat, so that it can later be resolved to literal. - $const_type = null; - } - - if ($const_type) { - $existing_constants[$const->name->name] = new ClassConstantStorage( - $const_type, - $stmt->isProtected() - ? ClassLikeAnalyzer::VISIBILITY_PROTECTED - : ($stmt->isPrivate() - ? ClassLikeAnalyzer::VISIBILITY_PRIVATE - : ClassLikeAnalyzer::VISIBILITY_PUBLIC), - null - ); + $existing_constants[$const->name->name] = $constant_storage; } else { $unresolved_const_expr = ExpressionResolver::getUnresolvedClassConstExpr( $const->value, diff --git a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php index aaf7ce5e3c2..7a9223c548a 100644 --- a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php +++ b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php @@ -1652,6 +1652,11 @@ private static function reconcileInArray( ): Union { $new_var_type = clone $assertion->type; + if ($new_var_type->isSingle() && $new_var_type->getSingleAtomic() instanceof TClassConstant) { + // Can't do assertion on const with non-literal type + return $existing_var_type; + } + $intersection = Type::intersectUnionTypes($new_var_type, $existing_var_type, $codebase); if ($intersection === null) { diff --git a/src/Psalm/Internal/Type/TypeExpander.php b/src/Psalm/Internal/Type/TypeExpander.php index 41fbb5e9f03..230dd5d5592 100644 --- a/src/Psalm/Internal/Type/TypeExpander.php +++ b/src/Psalm/Internal/Type/TypeExpander.php @@ -4,6 +4,7 @@ use Psalm\Codebase; use Psalm\Exception\CircularReferenceException; +use Psalm\Exception\UnresolvableConstantException; use Psalm\Storage\Assertion\IsType; use Psalm\Type\Atomic; use Psalm\Type\Atomic\TArray; @@ -347,7 +348,11 @@ public static function expandAtomic( $return_type->fq_classlike_name = $self_class; } - if ($evaluate_class_constants && $codebase->classOrInterfaceExists($return_type->fq_classlike_name)) { + if ($evaluate_class_constants) { + if (!$codebase->classOrInterfaceExists($return_type->fq_classlike_name)) { + throw new UnresolvableConstantException($return_type->fq_classlike_name, $return_type->const_name); + } + try { $class_constant_type = $codebase->classlikes->getClassConstantType( $return_type->fq_classlike_name, @@ -374,6 +379,8 @@ public static function expandAtomic( return array_values($const_type_atomic->type_params[1]->getAtomicTypes()); } } + } else { + throw new UnresolvableConstantException($return_type->fq_classlike_name, $return_type->const_name); } } diff --git a/src/Psalm/Issue/ClassConstantIssue.php b/src/Psalm/Issue/ClassConstantIssue.php new file mode 100644 index 00000000000..fc5443caee1 --- /dev/null +++ b/src/Psalm/Issue/ClassConstantIssue.php @@ -0,0 +1,22 @@ +const_id = $const_id; + } +} diff --git a/src/Psalm/Issue/CodeIssue.php b/src/Psalm/Issue/CodeIssue.php index c6d55215edd..0b8035efd09 100644 --- a/src/Psalm/Issue/CodeIssue.php +++ b/src/Psalm/Issue/CodeIssue.php @@ -10,7 +10,9 @@ abstract class CodeIssue { + /** @var int */ public const ERROR_LEVEL = -1; + /** @var int<0, max> */ public const SHORTCODE = 0; /** @@ -88,8 +90,8 @@ public function toIssueData(string $severity): IssueData $snippet_bounds[1], $location->getColumn(), $location->getEndColumn(), - (int) static::SHORTCODE, - (int) static::ERROR_LEVEL, + static::SHORTCODE, + static::ERROR_LEVEL, $this instanceof TaintedInput ? $this->getTaintTrace() : null, diff --git a/src/Psalm/Issue/InvalidConstantAssignmentValue.php b/src/Psalm/Issue/InvalidConstantAssignmentValue.php new file mode 100644 index 00000000000..a2c00a6d48e --- /dev/null +++ b/src/Psalm/Issue/InvalidConstantAssignmentValue.php @@ -0,0 +1,9 @@ + - * @psalm-suppress PossiblyUnusedProperty */ public $attributes = []; + /** + * @var array + */ + public $suppressed_issues = []; + /** * @var ?string */ @@ -53,10 +76,11 @@ class ClassConstantStorage /** * @param ClassLikeAnalyzer::VISIBILITY_* $visibility */ - public function __construct(?Union $type, int $visibility, ?CodeLocation $location) + public function __construct(?Union $type, ?Union $inferred_type, int $visibility, ?CodeLocation $location) { $this->visibility = $visibility; $this->location = $location; $this->type = $type; + $this->inferred_type = $inferred_type; } } 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 b03a87250d4..0dcd0630670 100644 --- a/tests/ConstantTest.php +++ b/tests/ConstantTest.php @@ -10,6 +10,38 @@ class ConstantTest extends TestCase use InvalidCodeAnalysisTestTrait; use ValidCodeAnalysisTestTrait; + // TODO: Waiting for https://github.com/vimeo/psalm/issues/7125 + // public function testKeyofSelfConstDoesntImplyKeyofStaticConst(): void + // { + // $this->expectException(CodeException::class); + // $this->expectExceptionMessage("PossiblyUndefinedIntArrayOffset"); + + // $this->testConfig->ensure_array_int_offsets_exist = true; + + // $file_path = getcwd() . '/src/somefile.php'; + + // $this->addFile( + // $file_path, + // ' */ + // public const CONST = [1, 2, 3]; + + // /** + // * @param key-of $key + // */ + // public function bar(int $key): int + // { + // return static::CONST[$key]; + // } + // } + // ' + // ); + + // $this->analyzeFile($file_path, new Context()); + // } + /** * @return iterable,ignored_issues?:list, php_version?: string}> */ @@ -273,14 +305,17 @@ function foo(array $arg): void {} 'lateConstantResolutionParentStringConcat' => [ 'code' => ' [ + 'code' => ' [ + 'code' => ' [ + 'code' => ' [ + 'code' => ' $arg */ + function foo(array $arg): void {} + foo([...A::ARR]); + ', + ], ]; } @@ -1511,6 +1601,96 @@ enum State { 'ignored_issues' => [], 'php_version' => '8.1', ], + 'returnValueofNonExistantConstant' => [ + 'code' => ' + */ + public function bar(): string + { + return self::BAR[0]; + } + } + ', + 'error_message' => 'UnresolvableConstant', + ], + 'returnValueofStaticConstant' => [ + 'code' => ' + */ + public function bar(): string + { + return static::BAR[0]; + } + } + ', + 'error_message' => 'UnresolvableConstant', + ], + 'takeKeyofNonExistantConstant' => [ + 'code' => ' $key + */ + public function bar(int $key): string + { + return static::BAR[$key]; + } + } + ', + 'error_message' => 'UnresolvableConstant', + ], + 'takeKeyofStaticConstant' => [ + 'code' => ' $key + */ + public function bar(int $key): string + { + return static::BAR[$key]; + } + } + ', + 'error_message' => 'UnresolvableConstant', + ], + 'invalidConstantAssignmentType' => [ + 'code' => ' "InvalidConstantAssignmentValue", + ], + 'invalidConstantAssignmentTypeResolvedLate' => [ + 'code' => ' "InvalidConstantAssignmentValue", + ], ]; } } diff --git a/tests/Template/ClassTemplateExtendsTest.php b/tests/Template/ClassTemplateExtendsTest.php index f30f842a1e9..1200e53195d 100644 --- a/tests/Template/ClassTemplateExtendsTest.php +++ b/tests/Template/ClassTemplateExtendsTest.php @@ -28,6 +28,7 @@ public function providerValidCodeParse(): iterable */ abstract class Tuple { + /** @var int */ const ARITY = 0; /** @@ -36,7 +37,7 @@ abstract class Tuple */ public function arity(): int { - return (int)static::ARITY; + return static::ARITY; } /** @@ -74,7 +75,7 @@ public function __construct($_0) { */ public function arity(): int { - return (int)static::ARITY; + return static::ARITY; } /** diff --git a/tests/TypeReconciliation/ArrayKeyExistsTest.php b/tests/TypeReconciliation/ArrayKeyExistsTest.php index 457a5898de8..5eef13b14d3 100644 --- a/tests/TypeReconciliation/ArrayKeyExistsTest.php +++ b/tests/TypeReconciliation/ArrayKeyExistsTest.php @@ -202,7 +202,6 @@ function foo(array $array = []): void { 'assertArrayKeyExistsRefinesType' => [ 'code' => ' */ public const DAYS = [ 1 => "mon", 2 => "tue",