Skip to content

[TypeInfo] Fix type alias resolving #60641

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@
namespace Symfony\Component\TypeInfo\Tests\Fixtures;

/**
* @phpstan-type CustomArray = array{0: CustomInt, 1: CustomString, 2: bool}
* @phpstan-type CustomString = string
*
* @phpstan-import-type CustomInt from DummyWithPhpDoc
* @phpstan-import-type CustomInt from DummyWithPhpDoc as AliasedCustomInt
*
* @psalm-type PsalmCustomArray = array{0: PsalmCustomInt, 1: PsalmCustomString, 2: bool}
* @psalm-type PsalmCustomString = string
*
* @psalm-import-type PsalmCustomInt from DummyWithPhpDoc
* @psalm-import-type PsalmCustomInt from DummyWithPhpDoc as PsalmAliasedCustomInt
*/
Expand Down Expand Up @@ -53,9 +57,31 @@ final class DummyWithTypeAliases
public mixed $psalmOtherAliasedExternalAlias;
}

/**
* @phpstan-type Foo = array{0: Bar}
* @phpstan-type Bar = array{0: Foo}
*/
final class DummyWithRecursiveTypeAliases
{
}

/**
* @phpstan-type Invalid = SomethingInvalid
*/
final class DummyWithInvalidTypeAlias
{
}

/**
* @phpstan-import-type Invalid from DummyWithTypeAliases
*/
final class DummyWithInvalidTypeAliasImport
{
}

/**
* @phpstan-import-type Invalid from int
*/
final class DummyWithTypeAliasImportedFromInvalidClassName
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@
use Symfony\Component\TypeInfo\Exception\LogicException;
use Symfony\Component\TypeInfo\Tests\Fixtures\AbstractDummy;
use Symfony\Component\TypeInfo\Tests\Fixtures\Dummy;
use Symfony\Component\TypeInfo\Tests\Fixtures\DummyWithInvalidTypeAlias;
use Symfony\Component\TypeInfo\Tests\Fixtures\DummyWithInvalidTypeAliasImport;
use Symfony\Component\TypeInfo\Tests\Fixtures\DummyWithRecursiveTypeAliases;
use Symfony\Component\TypeInfo\Tests\Fixtures\DummyWithTemplates;
use Symfony\Component\TypeInfo\Tests\Fixtures\DummyWithTypeAliases;
use Symfony\Component\TypeInfo\Tests\Fixtures\DummyWithTypeAliasImportedFromInvalidClassName;
use Symfony\Component\TypeInfo\Tests\Fixtures\DummyWithUses;
use Symfony\Component\TypeInfo\Type;
use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory;
Expand Down Expand Up @@ -128,27 +131,33 @@ public function testCollectTypeAliases()
$this->assertEquals([
'CustomString' => Type::string(),
'CustomInt' => Type::int(),
'CustomArray' => Type::arrayShape([0 => Type::int(), 1 => Type::string(), 2 => Type::bool()]),
'AliasedCustomInt' => Type::int(),
'PsalmCustomString' => Type::string(),
'PsalmCustomInt' => Type::int(),
'PsalmCustomArray' => Type::arrayShape([0 => Type::int(), 1 => Type::string(), 2 => Type::bool()]),
'PsalmAliasedCustomInt' => Type::int(),
], $this->typeContextFactory->createFromClassName(DummyWithTypeAliases::class)->typeAliases);

$this->assertEquals([
'CustomString' => Type::string(),
'CustomInt' => Type::int(),
'CustomArray' => Type::arrayShape([0 => Type::int(), 1 => Type::string(), 2 => Type::bool()]),
'AliasedCustomInt' => Type::int(),
'PsalmCustomString' => Type::string(),
'PsalmCustomInt' => Type::int(),
'PsalmCustomArray' => Type::arrayShape([0 => Type::int(), 1 => Type::string(), 2 => Type::bool()]),
'PsalmAliasedCustomInt' => Type::int(),
], $this->typeContextFactory->createFromReflection(new \ReflectionClass(DummyWithTypeAliases::class))->typeAliases);

$this->assertEquals([
'CustomString' => Type::string(),
'CustomInt' => Type::int(),
'CustomArray' => Type::arrayShape([0 => Type::int(), 1 => Type::string(), 2 => Type::bool()]),
'AliasedCustomInt' => Type::int(),
'PsalmCustomString' => Type::string(),
'PsalmCustomInt' => Type::int(),
'PsalmCustomArray' => Type::arrayShape([0 => Type::int(), 1 => Type::string(), 2 => Type::bool()]),
'PsalmAliasedCustomInt' => Type::int(),
], $this->typeContextFactory->createFromReflection(new \ReflectionProperty(DummyWithTypeAliases::class, 'localAlias'))->typeAliases);
}
Expand All @@ -167,4 +176,28 @@ public function testThrowWhenImportingInvalidAlias()

$this->typeContextFactory->createFromClassName(DummyWithInvalidTypeAliasImport::class);
}

public function testThrowWhenCannotResolveTypeAlias()
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage('Cannot resolve "Invalid" type alias.');

$this->typeContextFactory->createFromClassName(DummyWithInvalidTypeAlias::class);
}

public function testThrowWhenTypeAliasNotImportedFromValidClassName()
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage('Type alias "Invalid" is not imported from a valid class name.');

$this->typeContextFactory->createFromClassName(DummyWithTypeAliasImportedFromInvalidClassName::class);
}

public function testThrowWhenImportingRecursiveTypeAliases()
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage('Cannot resolve "Bar" type alias.');

$this->typeContextFactory->createFromClassName(DummyWithRecursiveTypeAliases::class)->typeAliases;
}
}
79 changes: 66 additions & 13 deletions src/Symfony/Component/TypeInfo/TypeContext/TypeContextFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -199,32 +199,85 @@ private function collectTypeAliases(\ReflectionClass $reflection, TypeContext $t
}

$aliases = [];
foreach ($this->getPhpDocNode($rawDocNode)->getTagsByName('@psalm-type') + $this->getPhpDocNode($rawDocNode)->getTagsByName('@phpstan-type') as $tag) {
if (!$tag->value instanceof TypeAliasTagValueNode) {
$resolvedAliases = [];

foreach ($this->getPhpDocNode($rawDocNode)->getTagsByName('@psalm-import-type') + $this->getPhpDocNode($rawDocNode)->getTagsByName('@phpstan-import-type') as $tag) {
if (!$tag->value instanceof TypeAliasImportTagValueNode) {
continue;
}

$aliases[$tag->value->alias] = $this->stringTypeResolver->resolve((string) $tag->value->type, $typeContext);
$importedFromType = $this->stringTypeResolver->resolve((string) $tag->value->importedFrom, $typeContext);
if (!$importedFromType instanceof ObjectType) {
throw new LogicException(\sprintf('Type alias "%s" is not imported from a valid class name.', $tag->value->importedAlias));
}

$importedFromContext = $this->createFromClassName($importedFromType->getClassName());

$typeAlias = $importedFromContext->typeAliases[$tag->value->importedAlias] ?? null;
if (!$typeAlias) {
throw new LogicException(\sprintf('Cannot find any "%s" type alias in "%s".', $tag->value->importedAlias, $importedFromType->getClassName()));
}

$resolvedAliases[$tag->value->importedAs ?? $tag->value->importedAlias] = $typeAlias;
}

foreach ($this->getPhpDocNode($rawDocNode)->getTagsByName('@psalm-import-type') + $this->getPhpDocNode($rawDocNode)->getTagsByName('@phpstan-import-type') as $tag) {
if (!$tag->value instanceof TypeAliasImportTagValueNode) {
foreach ($this->getPhpDocNode($rawDocNode)->getTagsByName('@psalm-type') + $this->getPhpDocNode($rawDocNode)->getTagsByName('@phpstan-type') as $tag) {
if (!$tag->value instanceof TypeAliasTagValueNode) {
continue;
}

/** @var ObjectType $importedType */
$importedType = $this->stringTypeResolver->resolve((string) $tag->value->importedFrom, $typeContext);
$importedTypeContext = $this->createFromClassName($importedType->getClassName());
$aliases[$tag->value->alias] = (string) $tag->value->type;
}

$typeAlias = $importedTypeContext->typeAliases[$tag->value->importedAlias] ?? null;
if (!$typeAlias) {
throw new LogicException(\sprintf('Cannot find any "%s" type alias in "%s".', $tag->value->importedAlias, $importedType->getClassName()));
return $this->resolveTypeAliases($aliases, $resolvedAliases, $typeContext);
}

/**
* @param array<string, string> $toResolve
* @param array<string, Type> $resolved
*
* @return array<string, Type>
*/
private function resolveTypeAliases(array $toResolve, array $resolved, TypeContext $typeContext): array
{
if (!$toResolve) {
return [];
}

$typeContext = new TypeContext(
$typeContext->calledClassName,
$typeContext->declaringClassName,
$typeContext->namespace,
$typeContext->uses,
$typeContext->templates,
$typeContext->typeAliases + $resolved,
);

$succeeded = false;
$lastFailure = null;
$lastFailingAlias = null;

foreach ($toResolve as $alias => $type) {
try {
$resolved[$alias] = $this->stringTypeResolver->resolve($type, $typeContext);
unset($toResolve[$alias]);
$succeeded = true;
} catch (UnsupportedException $lastFailure) {
$lastFailingAlias = $alias;
}
}

// nothing has succeeded, the result won't be different from the
// previous one, we can stop here.
if (!$succeeded) {
throw new LogicException(\sprintf('Cannot resolve "%s" type alias.', $lastFailingAlias), 0, $lastFailure);
}

$aliases[$tag->value->importedAs ?? $tag->value->importedAlias] = $typeAlias;
if ($toResolve) {
return $this->resolveTypeAliases($toResolve, $resolved, $typeContext);
}

return $aliases;
return $resolved;
}

private function getPhpDocNode(string $rawDocNode): PhpDocNode
Expand Down
Loading