feat: handle local type aliasing in class definition

Type aliases can now be added to a class definition.

Both PHPStan and Psalm syntax are handled.

```php
/**
 * @phpstan-type SomeTypeAlias = array{foo: string}
 * @psalm-type SomeOtherTypeAlias = array{bar: int}
 */
final class SomeClass
{
    /** @var SomeTypeAlias */
    public array $someTypeAlias;

    /** @var SomeOtherTypeAlias */
    public array $someOtherTypeAlias;
}
```
This commit is contained in:
Romain Canon 2021-12-02 22:01:55 +01:00
parent 99b4f4f7aa
commit 56142dea5b
5 changed files with 215 additions and 1 deletions

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Definition\Exception;
use LogicException;
use function implode;
final class ClassTypeAliasesDuplication extends LogicException
{
/**
* @param class-string $className
*/
public function __construct(string $className, string ...$names)
{
$names = implode('`, `', $names);
parent::__construct(
"The following type aliases already exist in class `$className`: `$names`.",
1638477604
);
}
}

View File

@ -6,19 +6,25 @@ namespace CuyZ\Valinor\Definition\Repository\Reflection;
use CuyZ\Valinor\Definition\ClassDefinition;
use CuyZ\Valinor\Definition\ClassSignature;
use CuyZ\Valinor\Definition\Exception\ClassTypeAliasesDuplication;
use CuyZ\Valinor\Definition\Methods;
use CuyZ\Valinor\Definition\Properties;
use CuyZ\Valinor\Definition\Repository\AttributesRepository;
use CuyZ\Valinor\Definition\Repository\ClassDefinitionRepository;
use CuyZ\Valinor\Type\Parser\Exception\InvalidType;
use CuyZ\Valinor\Type\Parser\Factory\Specifications\ClassAliasSpecification;
use CuyZ\Valinor\Type\Parser\Factory\Specifications\ClassContextSpecification;
use CuyZ\Valinor\Type\Parser\Factory\Specifications\HandleClassGenericSpecification;
use CuyZ\Valinor\Type\Parser\Factory\Specifications\TypeAliasAssignerSpecification;
use CuyZ\Valinor\Type\Parser\Factory\TypeParserFactory;
use CuyZ\Valinor\Type\Type;
use CuyZ\Valinor\Type\Types\UnresolvableType;
use CuyZ\Valinor\Utility\Reflection\Reflection;
use ReflectionMethod;
use ReflectionProperty;
use function array_intersect_key;
use function array_keys;
use function array_map;
final class ReflectionClassDefinitionRepository implements ClassDefinitionRepository
@ -70,13 +76,55 @@ final class ReflectionClassDefinitionRepository implements ClassDefinitionReposi
new ClassContextSpecification($signature->className())
);
$generics = $signature->generics();
$localAliases = $this->localTypeAliases($signature);
$duplicates = array_intersect_key($generics, $localAliases);
if (count($duplicates) > 0) {
throw new ClassTypeAliasesDuplication($signature->className(), ...array_keys($duplicates));
}
$aliases = $generics + $localAliases;
$advancedParser = $this->typeParserFactory->get(
new ClassContextSpecification($signature->className()),
new ClassAliasSpecification($signature->className()),
new HandleClassGenericSpecification(),
new TypeAliasAssignerSpecification($aliases)
);
return new ReflectionTypeResolver($nativeParser, $advancedParser);
}
/**
* @return array<string, Type>
*/
private function localTypeAliases(ClassSignature $signature): array
{
$reflection = Reflection::class($signature->className());
$rawTypes = Reflection::localTypeAliases($reflection);
$typeParser = $this->typeParserFactory->get(
new ClassContextSpecification($signature->className()),
new ClassAliasSpecification($signature->className()),
new HandleClassGenericSpecification(),
new TypeAliasAssignerSpecification($signature->generics()),
);
return new ReflectionTypeResolver($nativeParser, $advancedParser);
$types = [];
foreach ($rawTypes as $name => $raw) {
try {
$types[$name] = $typeParser->parse($raw);
} catch (InvalidType $exception) {
$raw = trim($raw);
$types[$name] = new UnresolvableType(
"The type `$raw` for local alias `$name` of the class `{$signature->className()}` could not be resolved: {$exception->getMessage()}"
);
}
}
return $types;
}
}

View File

@ -21,6 +21,7 @@ use RuntimeException;
use function get_class;
use function implode;
use function preg_match;
use function preg_match_all;
use function preg_replace;
use function trim;
@ -117,6 +118,24 @@ final class Reflection
return trim($matches[1]);
}
/**
* @param ReflectionClass<object> $reflection
* @return array<string, string>
*/
public static function localTypeAliases(ReflectionClass $reflection): array
{
$types = [];
$docComment = self::sanitizeDocComment($reflection);
preg_match_all('/@(phpstan|psalm)-type\s+([a-zA-Z]\w*)\s*=?\s*([\w\s?|&<>\'",-:\\\\\[\]{}]+)/', $docComment, $matches);
foreach ($matches[2] as $key => $name) {
$types[(string)$name] = $matches[3][$key];
}
return $types;
}
public static function ofCallable(callable $callable): ReflectionFunctionAbstract
{
if ($callable instanceof Closure) {

View File

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping\Type;
use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Tests\Integration\IntegrationTest;
final class LocalTypeAliasMappingTest extends IntegrationTest
{
public function test_values_are_mapped_properly(): void
{
$source = [
'aliasWithEqualsSign' => 42,
'aliasWithoutEqualsSign' => 42,
'aliasShapedArray' => [
'foo' => 'foo',
'bar' => 1337,
],
'aliasGeneric' => [42, 1337],
];
foreach ([PhpStanLocalAliases::class, PsalmLocalAliases::class] as $class) {
try {
$result = $this->mapperBuilder
->mapper()
->map($class, $source);
self::assertSame(42, $result->aliasWithEqualsSign);
self::assertSame(42, $result->aliasWithoutEqualsSign);
self::assertSame($source['aliasShapedArray'], $result->aliasShapedArray);
self::assertSame($source['aliasGeneric'], $result->aliasGeneric->aliasArray);
} catch (MappingError $error) {
$this->mappingFail($error);
}
}
}
}
/**
* @template T
* @phpstan-type AliasArray = T[]
*/
class GenericObjectWithPhpStanLocalAlias
{
/** @var AliasArray */
public array $aliasArray;
}
/**
* @phpstan-type AliasWithEqualsSign = int
* @phpstan-type AliasWithoutEqualsSign int
* @phpstan-type AliasShapedArray = array{foo: string, bar: int}
* @phpstan-type AliasGeneric = GenericObjectWithPhpStanLocalAlias<int>
*/
class PhpStanLocalAliases
{
/** @var AliasWithEqualsSign */
public int $aliasWithEqualsSign;
/** @var AliasWithoutEqualsSign */
public int $aliasWithoutEqualsSign;
/** @var AliasShapedArray */
public array $aliasShapedArray;
/** @var AliasGeneric */
public GenericObjectWithPhpStanLocalAlias $aliasGeneric;
}
/**
* @template T
* @psalm-type AliasArray = T[]
*/
class GenericObjectWithPsalmLocalAlias
{
/** @var AliasArray */
public array $aliasArray;
}
/**
* @psalm-type AliasWithEqualsSign = int
* @psalm-type AliasWithoutEqualsSign int
* @psalm-type AliasShapedArray = array{foo: string, bar: int}
* @psalm-type AliasGeneric = GenericObjectWithPsalmLocalAlias<int>
*/
class PsalmLocalAliases
{
/** @var AliasWithEqualsSign */
public int $aliasWithEqualsSign;
/** @var AliasWithoutEqualsSign */
public int $aliasWithoutEqualsSign;
/** @var AliasShapedArray */
public array $aliasShapedArray;
/** @var AliasGeneric */
public GenericObjectWithPsalmLocalAlias $aliasGeneric;
}

View File

@ -5,11 +5,13 @@ declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Unit\Definition\Repository\Reflection;
use CuyZ\Valinor\Definition\ClassSignature;
use CuyZ\Valinor\Definition\Exception\ClassTypeAliasesDuplication;
use CuyZ\Valinor\Definition\Exception\InvalidParameterDefaultValue;
use CuyZ\Valinor\Definition\Exception\InvalidPropertyDefaultValue;
use CuyZ\Valinor\Definition\Exception\TypesDoNotMatch;
use CuyZ\Valinor\Definition\Repository\Reflection\ReflectionClassDefinitionRepository;
use CuyZ\Valinor\Tests\Fake\Definition\Repository\FakeAttributesRepository;
use CuyZ\Valinor\Tests\Fake\Type\FakeType;
use CuyZ\Valinor\Tests\Fake\Type\Parser\Factory\FakeTypeParserFactory;
use CuyZ\Valinor\Type\StringType;
use CuyZ\Valinor\Type\Types\BooleanType;
@ -271,4 +273,23 @@ final class ReflectionClassDefinitionRepositoryTest extends TestCase
$this->repository->for(new ClassSignature($class));
}
public function test_class_with_local_type_alias_name_duplication_throws_exception(): void
{
$class = get_class(
/**
* @template T
* @template AnotherTemplate
* @psalm-type T = int
* @phpstan-type AnotherTemplate = int
*/
new class () { }
);
$this->expectException(ClassTypeAliasesDuplication::class);
$this->expectExceptionCode(1638477604);
$this->expectExceptionMessage("The following type aliases already exist in class `$class`: `T`, `AnotherTemplate`.");
$this->repository->for(new ClassSignature($class, ['T' => new FakeType(), 'AnotherTemplate' => new FakeType()]));
}
}