Skip to content

Commit

Permalink
feat: handle local type aliasing in class definition
Browse files Browse the repository at this point in the history
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;
}
```
  • Loading branch information
romm committed Dec 7, 2021
1 parent 99b4f4f commit 56142de
Show file tree
Hide file tree
Showing 5 changed files with 215 additions and 1 deletion.
25 changes: 25 additions & 0 deletions src/Definition/Exception/ClassTypeAliasesDuplication.php
Original file line number Diff line number Diff line change
@@ -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
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,25 @@

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
Expand Down Expand Up @@ -70,13 +76,55 @@ private function typeResolver(ClassSignature $signature): ReflectionTypeResolver
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($signature->generics()),
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()),
);

$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;
}
}
19 changes: 19 additions & 0 deletions src/Utility/Reflection/Reflection.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use function get_class;
use function implode;
use function preg_match;
use function preg_match_all;
use function preg_replace;
use function trim;

Expand Down Expand Up @@ -117,6 +118,24 @@ public static function docBlockReturnType(ReflectionFunctionAbstract $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) {
Expand Down
101 changes: 101 additions & 0 deletions tests/Integration/Mapping/Type/LocalTypeAliasMappingTest.php
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
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;
Expand Down Expand Up @@ -271,4 +273,23 @@ public function publicMethod(): string

$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()]));
}
}

0 comments on commit 56142de

Please sign in to comment.