Skip to content

Commit

Permalink
Support type annotations for class consts (fixes vimeo#942).
Browse files Browse the repository at this point in the history
  • Loading branch information
AndrolGenhald committed Dec 10, 2021
1 parent ae765df commit 5bbcb63
Show file tree
Hide file tree
Showing 16 changed files with 330 additions and 59 deletions.
1 change: 1 addition & 0 deletions config.xsd
Expand Up @@ -481,6 +481,7 @@
<xs:element name="UnnecessaryVarAnnotation" type="IssueHandlerType" minOccurs="0" />
<xs:element name="UnrecognizedExpression" type="IssueHandlerType" minOccurs="0" />
<xs:element name="UnrecognizedStatement" type="IssueHandlerType" minOccurs="0" />
<xs:element name="UnresolvableConstant" type="IssueHandlerType" minOccurs="0" />
<xs:element name="UnresolvableInclude" type="IssueHandlerType" minOccurs="0" />
<xs:element name="UnsafeInstantiation" type="IssueHandlerType" minOccurs="0" />
<xs:element name="UnsafeGenericInstantiation" type="IssueHandlerType" minOccurs="0" />
Expand Down
22 changes: 22 additions & 0 deletions 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
<?php

class Foo
{
public const BAR = ['bar'];

/**
* @return value-of<self::BAT>
*/
public function bar(): string
{
return self::BAR[0];
}
}
```
23 changes: 23 additions & 0 deletions src/Psalm/Exception/UnresolvableConstantException.php
@@ -0,0 +1,23 @@
<?php
namespace Psalm\Exception;

use Exception;

class UnresolvableConstantException extends Exception
{
/**
* @var string
*/
public $class_name;

/**
* @var string
*/
public $const_name;

public function __construct(string $class_name, string $const_name)
{
$this->class_name = $class_name;
$this->const_name = $const_name;
}
}
32 changes: 23 additions & 9 deletions src/Psalm/Internal/Analyzer/FunctionLike/ReturnTypeAnalyzer.php
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 26 additions & 11 deletions src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 7 additions & 3 deletions src/Psalm/Internal/Codebase/ClassLikes.php
Expand Up @@ -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);

Expand All @@ -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)]);
}
Expand Down
90 changes: 62 additions & 28 deletions src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php
Expand Up @@ -1190,6 +1190,7 @@ private function visitClassConstDeclaration(
$existing_constants = $storage->constants;

$comment = $stmt->getDocComment();
$var_comment = null;
$deprecated = false;
$description = null;
$config = $this->config;
Expand All @@ -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])
) {
Expand All @@ -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()
Expand All @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/Psalm/Internal/Type/SimpleAssertionReconciler.php
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion src/Psalm/Internal/Type/TypeExpander.php
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
}

Expand Down

0 comments on commit 5bbcb63

Please sign in to comment.