1
0
mirror of https://github.com/danog/psalm.git synced 2024-11-27 04:45:20 +01:00

feat(types): add properties-of<T> type

This commit is contained in:
Patrick Remy 2022-01-09 21:17:42 +01:00 committed by Patrick Remy
parent 997bded2e3
commit 30fac906c6
No known key found for this signature in database
GPG Key ID: FE25C0B14C0500CD
5 changed files with 546 additions and 0 deletions

View File

@ -5,7 +5,11 @@ namespace Psalm\Internal\Type;
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;
use Psalm\Storage\Assertion\IsType;
use Psalm\Storage\PropertyStorage;
use Psalm\Type\Atomic;
use Psalm\Type\Atomic\TArray;
use Psalm\Type\Atomic\TCallable;
@ -23,10 +27,12 @@ use Psalm\Type\Atomic\TKeyedArray;
use Psalm\Type\Atomic\TList;
use Psalm\Type\Atomic\TLiteralClassString;
use Psalm\Type\Atomic\TLiteralInt;
use Psalm\Type\Atomic\TLiteralString;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Atomic\TNever;
use Psalm\Type\Atomic\TNull;
use Psalm\Type\Atomic\TObjectWithProperties;
use Psalm\Type\Atomic\TPropertiesOf;
use Psalm\Type\Atomic\TTemplateParam;
use Psalm\Type\Atomic\TTypeAlias;
use Psalm\Type\Atomic\TValueOfArray;
@ -302,6 +308,56 @@ class TypeExpander
return [$return_type];
}
if ($return_type instanceof TPropertiesOf) {
if ($return_type->fq_classlike_name === 'self' && $self_class) {
$return_type->fq_classlike_name = $self_class;
}
if ($return_type->fq_classlike_name === 'static' && $self_class) {
$return_type->fq_classlike_name = is_string($static_class_type) ? $static_class_type : $self_class;
}
if (!$codebase->classExists($return_type->fq_classlike_name)) {
return $return_type;
}
// Get and merge all properties from parent classes
$class_storage = $codebase->classlike_storage_provider->get($return_type->fq_classlike_name);
$properties_types = $class_storage->properties;
foreach ($class_storage->parent_classes as $parent_class) {
if (!$codebase->classOrInterfaceExists($parent_class)) {
continue;
}
$parent_class_storage = $codebase->classlike_storage_provider->get($parent_class);
$properties_types = array_merge(
$properties_types,
$parent_class_storage->properties
);
}
// Filter properties if configured
if ($return_type->visibility_filter !== null) {
$properties_types = array_filter(
$properties_types,
function (PropertyStorage $property) use ($return_type): bool {
return $property->visibility === $return_type->visibility_filter;
}
);
}
// Return property names as literal string
$properties = array_map(
function (string $property_name): TLiteralString {
return new TLiteralString($property_name);
},
array_keys($properties_types)
);
if (empty($properties)) {
return $return_type;
}
return $properties;
}
if ($return_type instanceof TTypeAlias) {
$declaring_fq_classlike_name = $return_type->declaring_fq_classlike_name;

View File

@ -55,6 +55,7 @@ use Psalm\Type\Atomic\TNonEmptyList;
use Psalm\Type\Atomic\TNull;
use Psalm\Type\Atomic\TObject;
use Psalm\Type\Atomic\TObjectWithProperties;
use Psalm\Type\Atomic\TPropertiesOf;
use Psalm\Type\Atomic\TTemplateIndexedAccess;
use Psalm\Type\Atomic\TTemplateKeyOf;
use Psalm\Type\Atomic\TTemplateParam;
@ -685,6 +686,35 @@ class TypeParser
);
}
if (in_array($generic_type_value, TPropertiesOf::tokenNames())) {
if (count($generic_params) !== 1) {
throw new TypeParseTreeException($generic_type_value . ' requires exactly one parameter.');
}
$class_name = (string) $generic_params[0];
if (isset($template_type_map[$class_name])) {
throw new TypeParseTreeException('Template types are not allowed in ' . $generic_type_value . ' param');
}
$param_union_types = array_values($generic_params[0]->getAtomicTypes());
if (count($param_union_types) > 1) {
throw new TypeParseTreeException('Union types are not allowed in ' . $generic_type_value . ' param');
}
if (!$param_union_types[0] instanceof TNamedObject) {
throw new TypeParseTreeException('Param should be a named object in ' . $generic_type_value);
}
return new TPropertiesOf(
$class_name,
$param_union_types[0],
TPropertiesOf::filterForTokenName($generic_type_value)
);
}
if ($generic_type_value === 'key-of') {
$param_name = $generic_params[0]->getId(false);

View File

@ -78,6 +78,10 @@ class TypeTokenizer
'array-key' => true,
'key-of' => true,
'value-of' => true,
'properties-of' => true,
'public-properties-of' => true,
'protected-properties-of' => true,
'private-properties-of' => true,
'non-empty-countable' => true,
'list' => true,
'non-empty-list' => true,

View File

@ -0,0 +1,126 @@
<?php
namespace Psalm\Type\Atomic;
use Psalm\Type\Atomic;
/**
* Type that resolves to property names of a class/interface.
*
* @psalm-type TokenName = 'properties-of'|'public-properties-of'|'protected-properties-of'|'private-properties-of'
*/
class TPropertiesOf extends Atomic
{
// These should match the values of
// `Psalm\Internal\Analyzer\ClassLikeAnalyzer::VISIBILITY_*`, as they are
// used to compared against properties visibililty.
public const VISIBILITY_PUBLIC = 1;
public const VISIBILITY_PROTECTED = 2;
public const VISIBILITY_PRIVATE = 3;
/**
* @var string
*/
public $fq_classlike_name;
/**
* @var TNamedObject
*/
public $classlike_type;
/**
* @var self::VISIBILITY_*|null
*/
public $visibility_filter;
/**
* @return list<TokenName>
*/
public static function tokenNames(): array
{
return [
'properties-of',
'public-properties-of',
'protected-properties-of',
'private-properties-of'
];
}
/**
* @param TokenName $tokenName
* @return self::VISIBILITY_*|null
*/
public static function filterForTokenName(string $token_name): ?int
{
switch ($token_name) {
case 'public-properties-of':
return self::VISIBILITY_PUBLIC;
case 'protected-properties-of':
return self::VISIBILITY_PROTECTED;
case 'private-properties-of':
return self::VISIBILITY_PRIVATE;
default:
return null;
}
}
/**
* @return TokenName
*/
private static function tokenNameForFilter(?int $visibility_filter): string
{
switch ($visibility_filter) {
case self::VISIBILITY_PUBLIC:
return 'public-properties-of';
case self::VISIBILITY_PROTECTED:
return 'protected-properties-of';
case self::VISIBILITY_PRIVATE:
return 'private-properties-of';
default:
return 'properties-of';
}
}
/**
* Constructs a new instance of a generic type
*
* @param self::VISIBILITY_*|null $visibility_filter
*/
public function __construct(string $fq_classlike_name, TNamedObject $classlike_type, ?int $visibility_filter = null)
{
$this->fq_classlike_name = $fq_classlike_name;
$this->classlike_type = $classlike_type;
$this->visibility_filter = $visibility_filter;
}
public function getKey(bool $include_extra = true): string
{
return self::tokenNameForFilter($this->visibility_filter) . '<' . $this->classlike_type . '>';
}
public function __toString(): string
{
return $this->getKey();
}
public function getId(bool $nested = false): string
{
return $this->getKey();
}
/**
* @param array<lowercase-string, string> $aliased_classes
*/
public function toPhpString(
?string $namespace,
array $aliased_classes,
?string $this_class,
int $php_major_version,
int $php_minor_version
): string {
return $this->getKey();
}
public function canBeFullyExpressedInPhp(int $php_major_version, int $php_minor_version): bool
{
return false;
}
}

330
tests/PropertiesOfTest.php Normal file
View File

@ -0,0 +1,330 @@
<?php
namespace Psalm\Tests;
use Psalm\Tests\Traits\InvalidCodeAnalysisTestTrait;
use Psalm\Tests\Traits\ValidCodeAnalysisTestTrait;
class PropertiesOfTest extends TestCase
{
use ValidCodeAnalysisTestTrait;
use InvalidCodeAnalysisTestTrait;
/**
* @return iterable<string,array{string,assertions?:array<string,string>,error_levels?:string[]}>
*/
public function providerValidCodeParse(): iterable
{
return [
'varStatement' => [
'<?php
class A {
public int $foo = 42;
}
/** @var properties-of<A> */
$test = \'foo\';
',
],
'returnStatement' => [
'<?php
class A {
public int $foo = 42;
}
/** @return properties-of<A> */
function returnPropertyOfA() {
return \'foo\';
}
',
],
'publicPropertiesOf' => [
'<?php
class A {
/** @var mixed */
public $foo;
/** @var mixed */
private $bar;
/** @var mixed */
protected $adams;
}
/** @return public-properties-of<A> */
function returnPropertyOfA() {
return \'foo\';
}
',
],
'protectedPropertiesOf' => [
'<?php
class A {
/** @var mixed */
public $foo;
/** @var mixed */
private $bar;
/** @var mixed */
protected $adams;
}
/** @return protected-properties-of<A> */
function returnPropertyOfA() {
return \'adams\';
}
',
],
'privatePropertiesOf' => [
'<?php
class A {
/** @var mixed */
public $foo;
/** @var mixed */
private $bar;
/** @var mixed */
protected $adams;
}
/** @return private-properties-of<A> */
function returnPropertyOfA() {
return \'bar\';
}
',
],
'allPropertiesOf' => [
'<?php
class A {
/** @var mixed */
public $foo;
/** @var mixed */
private $bar;
/** @var mixed */
protected $adams;
}
/** @return properties-of<A> */
function returnPropertyOfA(int $visibility) {
if ($visibility === 1) {
return \'foo\';
} elseif ($visibility === 2) {
return \'bar\';
} else {
return \'adams\';
}
}
',
],
'usePropertiesOfSelfAsArrayKey' => [
'<?php
class A {
/** @var int */
public $a = 1;
/** @var int */
public $b = 2;
/** @return array<properties-of<self>, int> */
public function asArray() {
return [
\'a\' => $this->a,
\'b\' => $this->b
];
}
}',
],
'usePropertiesOfStaticAsArrayKey' => [
'<?php
class A {
/** @var int */
public $a = 1;
/** @var int */
public $b = 2;
/** @return array<properties-of<static>, int> */
public function asArray() {
return [
\'a\' => $this->a,
\'b\' => $this->b
];
}
}
class B extends A {
/** @var int */
public $c = 3;
public function asArray() {
return [
\'a\' => $this->a,
\'b\' => $this->b,
\'c\' => $this->c,
];
}
}
',
],
'propertiesOfMultipleInheritanceStaticAsArrayKey' => [
'<?php
class A {
/** @var int */
public $a = 1;
/** @var int */
public $b = 2;
/** @return array<properties-of<static>, int> */
public function asArray() {
return [
\'a\' => $this->a,
\'b\' => $this->b
];
}
}
class B extends A {
/** @var int */
public $c = 3;
}
class C extends B {
/** @var int */
public $d = 4;
public function asArray() {
return [
\'a\' => $this->a,
\'b\' => $this->b,
\'c\' => $this->c,
\'d\' => $this->d,
];
}
}
',
],
];
}
/**
* @return iterable<string,array{string,error_message:string,1?:string[],2?:bool,3?:string}>
*/
public function providerInvalidCodeParse(): iterable
{
return [
'onlyOneTemplateParam' => [
'<?php
class A {}
class B {}
/** @var properties-of<A, B> */
$test = \'foobar\';
',
'error_message' => 'InvalidDocblock',
],
'publicPropertiesOfPicksNoPrivate' => [
'<?php
class A {
/** @var mixed */
public $foo;
/** @var mixed */
private $bar;
/** @var mixed */
protected $adams;
}
/** @return public-properties-of<A> */
function returnPropertyOfA() {
return \'bar\';
}
',
'error_message' => 'InvalidReturnStatement'
],
'publicPropertiesOfPicksNoProtected' => [
'<?php
class A {
/** @var mixed */
public $foo;
/** @var mixed */
private $bar;
/** @var mixed */
protected $adams;
}
/** @return public-properties-of<A> */
function returnPropertyOfA() {
return \'adams\';
}
',
'error_message' => 'InvalidReturnStatement'
],
'protectedPropertiesOfPicksNoPublic' => [
'<?php
class A {
/** @var mixed */
public $foo;
/** @var mixed */
private $bar;
/** @var mixed */
protected $adams;
}
/** @return protected-properties-of<A> */
function returnPropertyOfA() {
return \'foo\';
}
',
'error_message' => 'InvalidReturnStatement'
],
'protectedPropertiesOfPicksNoPrivate' => [
'<?php
class A {
/** @var mixed */
public $foo;
/** @var mixed */
private $bar;
/** @var mixed */
protected $adams;
}
/** @return protected-properties-of<A> */
function returnPropertyOfA() {
return \'bar\';
}
',
'error_message' => 'InvalidReturnStatement'
],
'privatePropertiesOfPicksNoPublic' => [
'<?php
class A {
/** @var mixed */
public $foo;
/** @var mixed */
private $bar;
/** @var mixed */
protected $adams;
}
/** @return private-properties-of<A> */
function returnPropertyOfA() {
return \'foo\';
}
',
'error_message' => 'InvalidReturnStatement'
],
'privatePropertiesOfPicksNoProtected' => [
'<?php
class A {
/** @var mixed */
public $foo;
/** @var mixed */
private $bar;
/** @var mixed */
protected $adams;
}
/** @return private-properties-of<A> */
function returnPropertyOfA() {
return \'adams\';
}
',
'error_message' => 'InvalidReturnStatement'
],
];
}
}