Skip to content
Open
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
82 changes: 82 additions & 0 deletions src/TypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,12 @@
use function class_implements;
use function get_class;
use function in_array;
use function preg_match;
use function preg_quote;
use function preg_replace;
use function sprintf;
use function strlen;
use function stripos;
use function strpos;
use function strtolower;
use function substr;
Expand Down Expand Up @@ -256,6 +261,8 @@ public function resolve(string $type, ?Context $context = null): Type
$context = new Context('');
}

$type = $this->expandClassStringMap($type);

$tokens = $this->lexer->tokenize($type);
$tokenIterator = new TokenIterator($tokens);

Expand Down Expand Up @@ -689,4 +696,79 @@ function (TypeNode $node) use ($context): Type {
$nodes
);
}

/**
* Rewrites the psalm-specific `class-string-map<T of Foo, T>` utility type into the equivalent
* `array<class-string<Foo>, <value-with-T-substituted>>` so the underlying phpstan parser can handle it.
* Nested occurrences are expanded repeatedly from the innermost outwards.
*
* @psalm-mutation-free
*/
private function expandClassStringMap(string $type): string
{
while (($start = stripos($type, 'class-string-map<')) !== false) {
$contentStart = $start + strlen('class-string-map<');
$depth = 1;
$end = null;
for ($i = $contentStart, $length = strlen($type); $i < $length; $i++) {
$char = $type[$i];
if ($char === '<') {
$depth++;
} elseif ($char === '>') {
$depth--;
if ($depth === 0) {
$end = $i;
break;
}
}
}

if ($end === null) {
break;
}

$inner = substr($type, $contentStart, $end - $contentStart);
$commaAt = $this->findTopLevelComma($inner);
if ($commaAt === null) {
break;
}

$binding = trim(substr($inner, 0, $commaAt));
$value = trim(substr($inner, $commaAt + 1));
if (!preg_match('/^(\w+)\s+of\s+(.+)$/su', $binding, $matches)) {
break;
}

$template = $matches[1];
$bound = trim($matches[2]);
$substituted = (string) preg_replace(
'/\b' . preg_quote($template, '/') . '\b/u',
$bound,
$value
);

$replacement = sprintf('array<class-string<%s>, %s>', $bound, $substituted);
$type = substr($type, 0, $start) . $replacement . substr($type, $end + 1);
}

return $type;
}

/** @psalm-mutation-free */
private function findTopLevelComma(string $inner): ?int
{
$depth = 0;
for ($i = 0, $length = strlen($inner); $i < $length; $i++) {
$char = $inner[$i];
if ($char === '<') {
$depth++;
} elseif ($char === '>') {
$depth--;
} elseif ($char === ',' && $depth === 0) {
return $i;
}
}

return null;
}
}
58 changes: 58 additions & 0 deletions tests/unit/TypeResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,64 @@ public function testResolvingTypedArrays(): void
$this->assertInstanceOf(String_::class, $resolvedType->getValueType());
}

/**
* @uses \phpDocumentor\Reflection\Types\Array_
* @uses \phpDocumentor\Reflection\Types\Context
* @uses \phpDocumentor\Reflection\PseudoTypes\ClassString
*/
public function testResolvingClassStringMap(): void
{
$fixture = new TypeResolver();

$resolvedType = $fixture->resolve('class-string-map<T of \\Foo, T>', new Context(''));

$this->assertInstanceOf(Array_::class, $resolvedType);
$this->assertSame('array<class-string<\Foo>, \Foo>', (string) $resolvedType);
}

/**
* @uses \phpDocumentor\Reflection\Types\Array_
* @uses \phpDocumentor\Reflection\Types\Context
* @uses \phpDocumentor\Reflection\PseudoTypes\ClassString
*/
public function testResolvingClassStringMapWithNullableValue(): void
{
$fixture = new TypeResolver();

$resolvedType = $fixture->resolve('class-string-map<T of \\Foo, T|null>', new Context(''));

$this->assertInstanceOf(Array_::class, $resolvedType);
$this->assertSame('array<class-string<\Foo>, \Foo|null>', (string) $resolvedType);
}

/**
* @uses \phpDocumentor\Reflection\Types\Array_
* @uses \phpDocumentor\Reflection\Types\Context
* @uses \phpDocumentor\Reflection\PseudoTypes\ClassString
* @uses \phpDocumentor\Reflection\PseudoTypes\List_
*/
public function testResolvingNestedClassStringMap(): void
{
$fixture = new TypeResolver();

$resolvedType = $fixture->resolve('list<class-string-map<T of \\Foo, T>>', new Context(''));

$this->assertSame('list<array<class-string<\Foo>, \Foo>>', (string) $resolvedType);
}

/**
* @uses \phpDocumentor\Reflection\Types\Context
*/
public function testMalformedClassStringMapFallsThroughToParserError(): void
{
$fixture = new TypeResolver();

// TypeResolver wraps the underlying PHPStan ParserException into a RuntimeException.
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessageMatches('/Unexpected token/');
$fixture->resolve('class-string-map<T of \\Foo>', new Context(''));
}

/**
* @uses \phpDocumentor\Reflection\Types\Context
* @uses \phpDocumentor\Reflection\Types\Nullable
Expand Down