diff --git a/config.xsd b/config.xsd index ab37ca2dcd3..50d54ed9622 100644 --- a/config.xsd +++ b/config.xsd @@ -481,6 +481,7 @@ + diff --git a/docs/running_psalm/issues/UnresolvableConstant.md b/docs/running_psalm/issues/UnresolvableConstant.md new file mode 100644 index 00000000000..4a4ce88f63c --- /dev/null +++ b/docs/running_psalm/issues/UnresolvableConstant.md @@ -0,0 +1,22 @@ +# UnresolvableConstant + +Emitted when Psalm cannot resolve a constant used in `key-of` or `value-of`. Note that `static::CONST` is considered +unresolvable for `key-of` and `value-of`, since the literal keys and values can't be resolved due to the possibility +of being overridden by child classes. + +```php + + */ + public function bar(): string + { + return self::BAR[0]; + } +} +``` diff --git a/src/Psalm/Exception/UnresolvableConstantException.php b/src/Psalm/Exception/UnresolvableConstantException.php new file mode 100644 index 00000000000..3295f663f99 --- /dev/null +++ b/src/Psalm/Exception/UnresolvableConstantException.php @@ -0,0 +1,23 @@ +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 cc6ca0d0976..708b6c5ec31 100644 --- a/src/Psalm/Internal/Analyzer/FunctionLike/ReturnTypeAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/FunctionLike/ReturnTypeAnalyzer.php @@ -10,6 +10,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; @@ -39,6 +40,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 fe99cb29fa8..e02233daee0 100644 --- a/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php @@ -9,6 +9,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; @@ -34,6 +35,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; @@ -993,17 +995,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/Fetch/ClassConstFetchAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ClassConstFetchAnalyzer.php index 96c1db298fa..19aae12d290 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ClassConstFetchAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ClassConstFetchAnalyzer.php @@ -234,7 +234,9 @@ public static function analyze( $fq_class_name, $stmt->name->name, $class_visibility, - $statements_analyzer + $statements_analyzer, + [], + $stmt->class->parts[0] === "self" ); } catch (InvalidArgumentException $_) { return true; @@ -360,7 +362,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); diff --git a/src/Psalm/Internal/Codebase/ClassLikes.php b/src/Psalm/Internal/Codebase/ClassLikes.php index 4d4e86050c6..fbd6d6aef13 100644 --- a/src/Psalm/Internal/Codebase/ClassLikes.php +++ b/src/Psalm/Internal/Codebase/ClassLikes.php @@ -1634,7 +1634,8 @@ public function getClassConstantType( string $constant_name, int $visibility, ?StatementsAnalyzer $statements_analyzer = null, - array $visited_constant_ids = [] + array $visited_constant_ids = [], + bool $static_binding = false, ): ?Type\Union { $class_name = strtolower($class_name); @@ -1661,15 +1662,18 @@ public function getClassConstantType( } if ($constant_storage->unresolved_node) { - $constant_storage->type = new Type\Union([ConstantTypeResolver::resolve( + $constant_storage->inferred_type = new Type\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 $static_binding ? $constant_storage->inferred_type : $constant_storage->type; } elseif (isset($storage->enum_cases[$constant_name])) { return new Type\Union([new Type\Atomic\TEnumCase($storage->name, $constant_name)]); } diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php index fe53120f93b..cef5090cb9e 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php @@ -1190,6 +1190,7 @@ private function visitClassConstDeclaration( $existing_constants = $storage->constants; $comment = $stmt->getDocComment(); + $var_comment = null; $deprecated = false; $description = null; $config = $this->config; @@ -1202,19 +1203,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]) ) { @@ -1228,6 +1241,35 @@ 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; + if ($var_comment !== null && $var_comment->type !== null) { + $const_type = $var_comment->type; + + 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, $stmt->isProtected() @@ -1241,30 +1283,22 @@ private function visitClassConstDeclaration( ) ); + $constant_storage->inferred_type = $inferred_type; + $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(array_values($const_type->getAtomicTypes())[0]) === Type\Atomic\TString::class + if ($inferred_type + && !( + $const->value instanceof Concat + && $inferred_type->isSingle() + && get_class($inferred_type->getSingleAtomic()) === Type\Atomic\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 e114165bc63..4622251c350 100644 --- a/src/Psalm/Internal/Type/SimpleAssertionReconciler.php +++ b/src/Psalm/Internal/Type/SimpleAssertionReconciler.php @@ -17,6 +17,7 @@ use Psalm\Type\Atomic\TCallableArray; use Psalm\Type\Atomic\TCallableKeyedArray; use Psalm\Type\Atomic\TCallableList; +use Psalm\Type\Atomic\TClassConstant; use Psalm\Type\Atomic\TClassString; use Psalm\Type\Atomic\TEmpty; use Psalm\Type\Atomic\TEmptyMixed; @@ -1563,6 +1564,11 @@ private static function reconcileInArray( return $existing_var_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 e1b31c29eed..8f6ebf17103 100644 --- a/src/Psalm/Internal/Type/TypeExpander.php +++ b/src/Psalm/Internal/Type/TypeExpander.php @@ -3,6 +3,7 @@ use Psalm\Codebase; use Psalm\Exception\CircularReferenceException; +use Psalm\Exception\UnresolvableConstantException; use Psalm\Internal\Type\SimpleAssertionReconciler; use Psalm\Internal\Type\SimpleNegatedAssertionReconciler; use Psalm\Internal\Type\TypeParser; @@ -336,7 +337,11 @@ function ($constant_name) use ($const_name_part): bool { $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, @@ -363,6 +368,8 @@ function ($constant_name) use ($const_name_part): bool { 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/CodeIssue.php b/src/Psalm/Issue/CodeIssue.php index 7e7fc151209..0cb3024829f 100644 --- a/src/Psalm/Issue/CodeIssue.php +++ b/src/Psalm/Issue/CodeIssue.php @@ -9,7 +9,9 @@ abstract class CodeIssue { + /** @var int */ public const ERROR_LEVEL = -1; + /** @var int<0, max> */ public const SHORTCODE = 0; /** @@ -111,8 +113,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/UnresolvableConstant.php b/src/Psalm/Issue/UnresolvableConstant.php new file mode 100644 index 00000000000..8c08586d44d --- /dev/null +++ b/src/Psalm/Issue/UnresolvableConstant.php @@ -0,0 +1,8 @@ + [ + ' [ + ' [ + ' + */ + public function bar(): string + { + return self::BAR[0]; + } + } + ', + 'error_message' => 'UnresolvableConstant', + ], + 'returnValueofStaticConstant' => [ + ' + */ + public function bar(): string + { + return static::BAR[0]; + } + } + ', + 'error_message' => 'UnresolvableConstant', + ], + 'takeKeyofNonExistantConstant' => [ + ' $key + */ + public function bar(int $key): string + { + return static::BAR[$key]; + } + } + ', + 'error_message' => 'UnresolvableConstant', + ], + 'takeKeyofStaticConstant' => [ + ' $key + */ + public function bar(int $key): string + { + return static::BAR[$key]; + } + } + ', + 'error_message' => 'UnresolvableConstant', + ], + 'SKIPPED-keyofSelfConstDoesntImplyKeyofStaticConst' => [ + ' */ + public const CONST = [1, 2, 3]; + + /** + * @param key-of $key + */ + public function bar(int $key): int + { + return static::CONST[$key]; + } + } + ', + 'error_message' => 'MixedArrayAccess', + ], ]; } } diff --git a/tests/Template/ClassTemplateExtendsTest.php b/tests/Template/ClassTemplateExtendsTest.php index 23b6aaf54e3..ed95c8b21f6 100644 --- a/tests/Template/ClassTemplateExtendsTest.php +++ b/tests/Template/ClassTemplateExtendsTest.php @@ -27,6 +27,7 @@ public function providerValidCodeParse(): iterable */ abstract class Tuple { + /** @var int */ const ARITY = 0; /** @@ -35,7 +36,7 @@ abstract class Tuple */ public function arity(): int { - return (int)static::ARITY; + return static::ARITY; } /** @@ -73,7 +74,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 4c33dc8c0f8..f13a88d1367 100644 --- a/tests/TypeReconciliation/ArrayKeyExistsTest.php +++ b/tests/TypeReconciliation/ArrayKeyExistsTest.php @@ -201,7 +201,6 @@ function foo(array $array = []): void { 'assertArrayKeyExistsRefinesType' => [ ' */ public const DAYS = [ 1 => "mon", 2 => "tue",