diff --git a/src/TypeResolver.php b/src/TypeResolver.php index 9b6cfb7..859abb1 100644 --- a/src/TypeResolver.php +++ b/src/TypeResolver.php @@ -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; @@ -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); @@ -689,4 +696,79 @@ function (TypeNode $node) use ($context): Type { $nodes ); } + + /** + * Rewrites the psalm-specific `class-string-map` utility type into the equivalent + * `array, >` 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, %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; + } } diff --git a/tests/unit/TypeResolverTest.php b/tests/unit/TypeResolverTest.php index 9aff0d6..2cc22ef 100644 --- a/tests/unit/TypeResolverTest.php +++ b/tests/unit/TypeResolverTest.php @@ -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', new Context('')); + + $this->assertInstanceOf(Array_::class, $resolvedType); + $this->assertSame('array, \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', new Context('')); + + $this->assertInstanceOf(Array_::class, $resolvedType); + $this->assertSame('array, \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>', new Context('')); + + $this->assertSame('list, \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', new Context('')); + } + /** * @uses \phpDocumentor\Reflection\Types\Context * @uses \phpDocumentor\Reflection\Types\Nullable