Skip to content

Commit

Permalink
fix: properly handle nested local type aliases
Browse files Browse the repository at this point in the history
The following annotation now works properly:

```php
/**
 * @phpstan-type Address = array{street?: string, city: string}
 * @phpstan-type User = array{name: non-empty-string, address: Address}
 */
final class SomeClass
{
    public function __construct(
        /** @var User */
        public $value,
    ) {}
}

(new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map(SomeClass::class, [
        'name' => 'John Doe',
        'address' => [
            'street' => 'Bron-Yr-Aur',
            'city' => 'SY20 8QA, Machynlleth',
        ],
    ]);
 ```
  • Loading branch information
romm committed Apr 7, 2024
1 parent 6942755 commit 1278392
Show file tree
Hide file tree
Showing 12 changed files with 103 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ private function extractImportedAliasesFromDocBlock(string $className): array

$importedAliases = [];

$annotations = (new Annotations($docBlock))->allOf(
$annotations = (new Annotations($docBlock))->filteredByPriority(
'@phpstan-import-type',
'@psalm-import-type',
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ public function resolveLocalTypeAliases(ObjectType $type): array
return [];
}

$typeParser = $this->typeParserFactory->buildAdvancedTypeParserForClass($type);

$types = [];

foreach ($localAliases as $name => $raw) {
try {
$typeParser = $this->typeParserFactory->buildAdvancedTypeParserForClass($type, $types);

$types[$name] = $typeParser->parse($raw);
} catch (InvalidType $exception) {
$types[$name] = UnresolvableType::forLocalAlias($raw, $name, $type, $exception);
Expand All @@ -61,7 +61,7 @@ private function extractLocalAliasesFromDocBlock(string $className): array

$aliases = [];

$annotations = (new Annotations($docBlock))->allOf(
$annotations = (new Annotations($docBlock))->filteredInOrder(
'@phpstan-type',
'@psalm-type',
);
Expand All @@ -80,7 +80,7 @@ private function extractLocalAliasesFromDocBlock(string $className): array
$key = key($tokens);

if ($key !== null) {
$aliases[$name] ??= $annotation->allAfter($key);
$aliases[$name] = $annotation->allAfter($key);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ private function extractParentTypeFromDocBlock(ReflectionClass $reflection): arr
return [];
}

$annotations = (new Annotations($docBlock))->allOf(
$annotations = (new Annotations($docBlock))->filteredByPriority(
'@phpstan-extends',
'@psalm-extends',
'@extends',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

use function array_key_exists;
use function array_keys;
use function array_reverse;
use function current;
use function key;

Expand Down Expand Up @@ -40,7 +39,7 @@ public function resolveTemplatesFrom(string $className): array

$templates = [];

$annotations = (new Annotations($docBlock))->allOf(
$annotations = (new Annotations($docBlock))->filteredByPriority(
'@phpstan-template',
'@psalm-template',
'@template',
Expand Down Expand Up @@ -72,6 +71,6 @@ public function resolveTemplatesFrom(string $className): array
}
}

return array_reverse($templates);
return $templates;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,6 @@ private function extractReturnTypeFromDocBlock(ReflectionFunctionAbstract $refle
'@phpstan-return',
'@psalm-return',
'@return',
);
)?->raw();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ private function extractTypeFromDocBlock(ReflectionParameter $reflection): ?stri
return null;
}

$annotations = (new Annotations($docBlock))->allOf(
$annotations = (new Annotations($docBlock))->filteredByPriority(
'@phpstan-param',
'@psalm-param',
'@param',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,6 @@ public function extractTypeFromDocBlock(ReflectionProperty $reflection): ?string
'@phpstan-var',
'@psalm-var',
'@var',
);
)?->raw();
}
}
40 changes: 29 additions & 11 deletions src/Type/Parser/Lexer/Annotations.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@

namespace CuyZ\Valinor\Type\Parser\Lexer;

use function array_filter;
use function array_merge;
use function current;
use function in_array;
use function trim;

/** @internal */
final class Annotations
{
/** @var array<non-empty-string, non-empty-list<TokenizedAnnotation>> */
/** @var list<TokenizedAnnotation> */
private array $annotations = [];

public function __construct(string $docBlock)
Expand All @@ -27,7 +29,7 @@ public function __construct(string $docBlock)
$current = $this->trimArrayTips($current);

if ($current !== []) {
$this->annotations[$token][] = new TokenizedAnnotation($current);
array_unshift($this->annotations, new TokenizedAnnotation($token, $current));
}

$current = [];
Expand All @@ -37,31 +39,47 @@ public function __construct(string $docBlock)
}
}

public function firstOf(string ...$annotations): ?string
public function firstOf(string ...$annotations): ?TokenizedAnnotation
{
foreach ($annotations as $annotation) {
if (isset($this->annotations[$annotation])) {
return $this->annotations[$annotation][0]->raw();
foreach ($this->annotations as $tokenizedAnnotation) {
if ($tokenizedAnnotation->name() === $annotation) {
return $tokenizedAnnotation;
}
}
}

return null;
}

/**
* @return array<TokenizedAnnotation>
*/
public function filteredInOrder(string ...$annotations): array
{
return array_filter(
$this->annotations,
static fn (TokenizedAnnotation $tokenizedAnnotation) => in_array($tokenizedAnnotation->name(), $annotations, true),
);
}

/**
* @return list<TokenizedAnnotation>
*/
public function allOf(string ...$annotations): array
public function filteredByPriority(string ...$annotations): array
{
$all = [];
$result = [];

foreach ($annotations as $annotation) {
if (isset($this->annotations[$annotation])) {
$all = array_merge($all, $this->annotations[$annotation]);
}
$filtered = array_filter(
$this->annotations,
static fn (TokenizedAnnotation $tokenizedAnnotation) => $tokenizedAnnotation->name() === $annotation,
);

$result = array_merge($result, $filtered);
}

return $all;
return $result;
}

private function sanitizeDocComment(string $value): string
Expand Down
10 changes: 10 additions & 0 deletions src/Type/Parser/Lexer/TokenizedAnnotation.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,20 @@
final class TokenizedAnnotation
{
public function __construct(
/** @var non-empty-string */
private string $name,
/** @var non-empty-list<string>> */
private array $tokens,
) {}

/**
* @return non-empty-string
*/
public function name(): string
{
return $this->name;
}

public function splice(int $length): string
{
return implode('', array_splice($this->tokens, 0, $length));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,47 @@ public static function local_type_alias_is_resolved_properly_data_provider(): it
'SomeType' => 'int<42, 1337>',
]
];

yield 'types can be nested' => [
'className' => (
/**
* @phpstan-type SomeType = non-empty-string
* @phpstan-type SomeNestedType = array<SomeType>
*/
new class () {}
)::class,
[
'SomeType' => 'non-empty-string',
'SomeNestedType' => 'array<non-empty-string>',
]
];

yield 'PHPStan type can use a Psalm type' => [
'className' => (
/**
* @psalm-type SomeType = non-empty-string
* @phpstan-type SomeNestedType = array<SomeType>
*/
new class () {}
)::class,
[
'SomeType' => 'non-empty-string',
'SomeNestedType' => 'array<non-empty-string>',
]
];

yield 'Psalm type can use a PHPStan type' => [
'className' => (
/**
* @phpstan-type SomeType = non-empty-string
* @psalm-type SomeNestedType = array<SomeType>
*/
new class () {}
)::class,
[
'SomeType' => 'non-empty-string',
'SomeNestedType' => 'array<non-empty-string>',
]
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,5 +139,15 @@ public static function property_type_is_resolved_properly_data_provider(): itera
}, 'foo'),
'non-empty-string',
];

yield 'docBlock present but no @var annotation' => [
new ReflectionProperty(new class () {
/**
* Some comment
*/
public string $foo;
}, 'foo'),
'string',
];
}
}
2 changes: 1 addition & 1 deletion tests/Unit/Type/Parser/Lexer/AnnotationsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public function test_annotations_are_parsed_properly(string $docBlock, array $ex
foreach ($expectedAnnotations as $name => $expected) {
$result = array_map(
fn (TokenizedAnnotation $annotation) => $annotation->raw(),
$annotations->allOf($name)
$annotations->filteredByPriority($name)
);

self::assertSame($expected, $result);
Expand Down

0 comments on commit 1278392

Please sign in to comment.