Repository: CuyZ/Valinor (php)

Issue:
Nested type aliases are not supported 
```php
<?php

use CuyZ\Valinor\MapperBuilder;

require 'vendor/autoload.php';

/**
 * @psalm-type TCur = array{test:string}
 *
 * @psalm-type TCountry = array{currency: TCur}
 */
final class a {

    /**
     * @param TCountry $a
     */
    public function __construct(
        public readonly array $a,
    ) {
    }
}

(new MapperBuilder)->mapper()->map(a::class, ['currency' => ['test' => 'uwu']]);
```

fails with "The type `array{currency: TCur}` for local alias `TCountry` of the class `a` could not be resolved: Cannot parse unknown symbol `TCur`."
Mixing phpstan-type and psalm-type phpdocs breaks type resolution
Example:

```php
<?php

require 'vendor/autoload.php';

/**
 * @psalm-type AliasShapedArray = array{foo: string, bar: int}
 * @phpstan-type AliasShapedArrayNested = array{baz: AliasShapedArray}
 */
class a {
    /** 
     * @param AliasShapedArrayNested $a  
     */
    public function __construct(
        public readonly array $a,
        public readonly int $b = 0
    )
    {
        
    }
}

$res = (new \CuyZ\Valinor\MapperBuilder())
    ->allowSuperfluousKeys()
    ->mapper()
    ->map(
        'a',
        [
            'a' => ['baz' => ['foo' => 'test', 'bar' => 123]],
        ]
    );
var_dump($res);
```

Result:
```
PHP Fatal error:  Uncaught AssertionError: assert(!$type instanceof UnresolvableType) in /home/daniil/repos/valinor/src/Mapper/Tree/Shell.php:31
Stack trace:
#0 /home/daniil/repos/valinor/src/Mapper/Tree/Shell.php(31): assert()
#1 /home/daniil/repos/valinor/src/Mapper/Tree/Shell.php(41): CuyZ\Valinor\Mapper\Tree\Shell->__construct()
#2 /home/daniil/repos/valinor/src/Mapper/Tree/Builder/ObjectNodeBuilder.php(50): CuyZ\Valinor\Mapper\Tree\Shell->child()
#3 /home/daniil/repos/valinor/src/Mapper/Tree/Builder/ObjectNodeBuilder.php(23): CuyZ\Valinor\Mapper\Tree\Builder\ObjectNodeBuilder->children()
#4 /home/daniil/repos/valinor/src/Mapper/Tree/Builder/NativeClassNodeBuilder.php(39): CuyZ\Valinor\Mapper\Tree\Builder\ObjectNodeBuilder->build()
#5 /home/daniil/repos/valinor/src/Mapper/Tree/Builder/CasterNodeBuilder.php(24): CuyZ\Valinor\Mapper\Tree\Builder\NativeClassNodeBuilder->build()
#6 /home/daniil/repos/valinor/src/Mapper/Tree/Builder/UnionNodeBuilder.php(37): CuyZ\Valinor\Mapper\Tree\Builder\CasterNodeBuilder->build()
#7 /home/daniil/repos/valinor/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php(51): CuyZ\Valinor\Mapper\Tree\Builder\UnionNodeBuilder->build()
#8 /home/daniil/repos/valinor/src/Mapper/Tree/Builder/CasterProxyNodeBuilder.php(24): CuyZ\Valinor\Mapper\Tree\Builder\InterfaceNodeBuilder->build()
#9 /home/daniil/repos/valinor/src/Mapper/Tree/Builder/IterableNodeBuilder.php(26): CuyZ\Valinor\Mapper\Tree\Builder\CasterProxyNodeBuilder->build()
#10 /home/daniil/repos/valinor/src/Mapper/Tree/Builder/StrictNodeBuilder.php(36): CuyZ\Valinor\Mapper\Tree\Builder\IterableNodeBuilder->build()
#11 /home/daniil/repos/valinor/src/Mapper/Tree/Builder/ErrorCatcherNodeBuilder.php(33): CuyZ\Valinor\Mapper\Tree\Builder\StrictNodeBuilder->build()
#12 /home/daniil/repos/valinor/src/Mapper/Tree/Builder/RootNodeBuilder.php(16): CuyZ\Valinor\Mapper\Tree\Builder\ErrorCatcherNodeBuilder->build()
#13 /home/daniil/repos/valinor/src/Mapper/TypeTreeMapper.php(44): CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder->build()
#14 /home/daniil/repos/valinor/src/Mapper/TypeTreeMapper.php(25): CuyZ\Valinor\Mapper\TypeTreeMapper->node()
#15 /home/daniil/repos/valinor/a.php(25): CuyZ\Valinor\Mapper\TypeTreeMapper->map()
#16 {main}
  thrown in /home/daniil/repos/valinor/src/Mapper/Tree/Shell.php on line 31
```


Pull request:
fix: properly handle nested local type aliases
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',
        ],
    ]);
 ```

Fixes #308
Fixes #428 

Patch:
diff --git a/src/Definition/Repository/Reflection/TypeResolver/ClassImportedTypeAliasResolver.php b/src/Definition/Repository/Reflection/TypeResolver/ClassImportedTypeAliasResolver.php
index d951200..84ae421 100644
--- a/src/Definition/Repository/Reflection/TypeResolver/ClassImportedTypeAliasResolver.php
+++ b/src/Definition/Repository/Reflection/TypeResolver/ClassImportedTypeAliasResolver.php
@@ -77,7 +77,7 @@ final class ClassImportedTypeAliasResolver
 
         $importedAliases = [];
 
-        $annotations = (new Annotations($docBlock))->allOf(
+        $annotations = (new Annotations($docBlock))->filteredByPriority(
             '@phpstan-import-type',
             '@psalm-import-type',
         );
diff --git a/src/Definition/Repository/Reflection/TypeResolver/ClassLocalTypeAliasResolver.php b/src/Definition/Repository/Reflection/TypeResolver/ClassLocalTypeAliasResolver.php
index 84d0752..dd57537 100644
--- a/src/Definition/Repository/Reflection/TypeResolver/ClassLocalTypeAliasResolver.php
+++ b/src/Definition/Repository/Reflection/TypeResolver/ClassLocalTypeAliasResolver.php
@@ -32,12 +32,12 @@ final class ClassLocalTypeAliasResolver
             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);
@@ -61,7 +61,7 @@ final class ClassLocalTypeAliasResolver
 
         $aliases = [];
 
-        $annotations = (new Annotations($docBlock))->allOf(
+        $annotations = (new Annotations($docBlock))->filteredInOrder(
             '@phpstan-type',
             '@psalm-type',
         );
@@ -80,7 +80,7 @@ final class ClassLocalTypeAliasResolver
             $key = key($tokens);
 
             if ($key !== null) {
-                $aliases[$name] ??= $annotation->allAfter($key);
+                $aliases[$name] = $annotation->allAfter($key);
             }
         }
 
diff --git a/src/Definition/Repository/Reflection/TypeResolver/ClassParentTypeResolver.php b/src/Definition/Repository/Reflection/TypeResolver/ClassParentTypeResolver.php
index f6b8ec3..e2afd92 100644
--- a/src/Definition/Repository/Reflection/TypeResolver/ClassParentTypeResolver.php
+++ b/src/Definition/Repository/Reflection/TypeResolver/ClassParentTypeResolver.php
@@ -72,7 +72,7 @@ final class ClassParentTypeResolver
             return [];
         }
 
-        $annotations = (new Annotations($docBlock))->allOf(
+        $annotations = (new Annotations($docBlock))->filteredByPriority(
             '@phpstan-extends',
             '@psalm-extends',
             '@extends',
diff --git a/src/Definition/Repository/Reflection/TypeResolver/ClassTemplatesResolver.php b/src/Definition/Repository/Reflection/TypeResolver/ClassTemplatesResolver.php
index cc2d36c..6f90973 100644
--- a/src/Definition/Repository/Reflection/TypeResolver/ClassTemplatesResolver.php
+++ b/src/Definition/Repository/Reflection/TypeResolver/ClassTemplatesResolver.php
@@ -10,7 +10,6 @@ use CuyZ\Valinor\Utility\Reflection\Reflection;
 
 use function array_key_exists;
 use function array_keys;
-use function array_reverse;
 use function current;
 use function key;
 
@@ -40,7 +39,7 @@ final class ClassTemplatesResolver
 
         $templates = [];
 
-        $annotations = (new Annotations($docBlock))->allOf(
+        $annotations = (new Annotations($docBlock))->filteredByPriority(
             '@phpstan-template',
             '@psalm-template',
             '@template',
@@ -72,6 +71,6 @@ final class ClassTemplatesResolver
             }
         }
 
-        return array_reverse($templates);
+        return $templates;
     }
 }
diff --git a/src/Definition/Repository/Reflection/TypeResolver/FunctionReturnTypeResolver.php b/src/Definition/Repository/Reflection/TypeResolver/FunctionReturnTypeResolver.php
index 9d4bf27..7750af3 100644
--- a/src/Definition/Repository/Reflection/TypeResolver/FunctionReturnTypeResolver.php
+++ b/src/Definition/Repository/Reflection/TypeResolver/FunctionReturnTypeResolver.php
@@ -37,6 +37,6 @@ final class FunctionReturnTypeResolver
             '@phpstan-return',
             '@psalm-return',
             '@return',
-        );
+        )?->raw();
     }
 }
diff --git a/src/Definition/Repository/Reflection/TypeResolver/ParameterTypeResolver.php b/src/Definition/Repository/Reflection/TypeResolver/ParameterTypeResolver.php
index 836ddb5..bab24b0 100644
--- a/src/Definition/Repository/Reflection/TypeResolver/ParameterTypeResolver.php
+++ b/src/Definition/Repository/Reflection/TypeResolver/ParameterTypeResolver.php
@@ -58,7 +58,7 @@ final class ParameterTypeResolver
             return null;
         }
 
-        $annotations = (new Annotations($docBlock))->allOf(
+        $annotations = (new Annotations($docBlock))->filteredByPriority(
             '@phpstan-param',
             '@psalm-param',
             '@param',
diff --git a/src/Definition/Repository/Reflection/TypeResolver/PropertyTypeResolver.php b/src/Definition/Repository/Reflection/TypeResolver/PropertyTypeResolver.php
index 864156a..f2212e8 100644
--- a/src/Definition/Repository/Reflection/TypeResolver/PropertyTypeResolver.php
+++ b/src/Definition/Repository/Reflection/TypeResolver/PropertyTypeResolver.php
@@ -37,6 +37,6 @@ final class PropertyTypeResolver
             '@phpstan-var',
             '@psalm-var',
             '@var',
-        );
+        )?->raw();
     }
 }
diff --git a/src/Type/Parser/Lexer/Annotations.php b/src/Type/Parser/Lexer/Annotations.php
index 3105d8c..8cc7fa6 100644
--- a/src/Type/Parser/Lexer/Annotations.php
+++ b/src/Type/Parser/Lexer/Annotations.php
@@ -4,14 +4,16 @@ declare(strict_types=1);
 
 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)
@@ -27,7 +29,7 @@ final class Annotations
                 $current = $this->trimArrayTips($current);
 
                 if ($current !== []) {
-                    $this->annotations[$token][] = new TokenizedAnnotation($current);
+                    array_unshift($this->annotations, new TokenizedAnnotation($token, $current));
                 }
 
                 $current = [];
@@ -37,31 +39,47 @@ final class Annotations
         }
     }
 
-    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
diff --git a/src/Type/Parser/Lexer/TokenizedAnnotation.php b/src/Type/Parser/Lexer/TokenizedAnnotation.php
index 3f40c4b..4e4c403 100644
--- a/src/Type/Parser/Lexer/TokenizedAnnotation.php
+++ b/src/Type/Parser/Lexer/TokenizedAnnotation.php
@@ -11,10 +11,20 @@ use function trim;
 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));


Test patch:
diff --git a/tests/Functional/Definition/Repository/Reflection/TypeResolver/ClassLocalTypeAliasResolverTest.php b/tests/Functional/Definition/Repository/Reflection/TypeResolver/ClassLocalTypeAliasResolverTest.php
index f162f10..b32b87d 100644
--- a/tests/Functional/Definition/Repository/Reflection/TypeResolver/ClassLocalTypeAliasResolverTest.php
+++ b/tests/Functional/Definition/Repository/Reflection/TypeResolver/ClassLocalTypeAliasResolverTest.php
@@ -81,5 +81,47 @@ final class ClassLocalTypeAliasResolverTest extends TestCase
                 '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>',
+            ]
+        ];
     }
 }
diff --git a/tests/Functional/Definition/Repository/Reflection/TypeResolver/PropertyTypeResolverTest.php b/tests/Functional/Definition/Repository/Reflection/TypeResolver/PropertyTypeResolverTest.php
index 4a37f90..0aa0dfb 100644
--- a/tests/Functional/Definition/Repository/Reflection/TypeResolver/PropertyTypeResolverTest.php
+++ b/tests/Functional/Definition/Repository/Reflection/TypeResolver/PropertyTypeResolverTest.php
@@ -139,5 +139,15 @@ final class PropertyTypeResolverTest extends TestCase
             }, 'foo'),
             'non-empty-string',
         ];
+
+        yield 'docBlock present but no @var annotation' => [
+            new ReflectionProperty(new class () {
+                /**
+                 * Some comment
+                 */
+                public string $foo;
+            }, 'foo'),
+            'string',
+        ];
     }
 }
diff --git a/tests/Unit/Type/Parser/Lexer/AnnotationsTest.php b/tests/Unit/Type/Parser/Lexer/AnnotationsTest.php
index 579d3a4..f6cec9b 100644
--- a/tests/Unit/Type/Parser/Lexer/AnnotationsTest.php
+++ b/tests/Unit/Type/Parser/Lexer/AnnotationsTest.php
@@ -24,7 +24,7 @@ final class AnnotationsTest extends TestCase
         foreach ($expectedAnnotations as $name => $expected) {
             $result = array_map(
                 fn (TokenizedAnnotation $annotation) => $annotation->raw(),
-                $annotations->allOf($name)
+                $annotations->filteredByPriority($name)
             );
 
             self::assertSame($expected, $result);