From bb758c60ddf7cfabc7bdc8e79208199a2ff3743c Mon Sep 17 00:00:00 2001 From: lacatoire Date: Wed, 15 Apr 2026 22:47:07 +0200 Subject: [PATCH 1/3] Support psalm's class-string-map utility type Rewrites `class-string-map` into `array, >` before passing the input to the phpstan parser (which otherwise chokes on the `T of Foo` template binding syntax). Nested occurrences are expanded from the innermost outwards. Fixes #266 --- src/TypeResolver.php | 82 +++++++++++++++++++++++++++++++++ tests/unit/TypeResolverTest.php | 30 ++++++++++++ 2 files changed, 112 insertions(+) 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..4ffd700 100644 --- a/tests/unit/TypeResolverTest.php +++ b/tests/unit/TypeResolverTest.php @@ -196,6 +196,36 @@ 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\Context * @uses \phpDocumentor\Reflection\Types\Nullable From d6a881df71f3fad4d5c5c65d6cd3a6072270aab2 Mon Sep 17 00:00:00 2001 From: lacatoire Date: Wed, 15 Apr 2026 22:49:22 +0200 Subject: [PATCH 2/3] Add nested and malformed class-string-map test coverage --- tests/unit/TypeResolverTest.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/unit/TypeResolverTest.php b/tests/unit/TypeResolverTest.php index 4ffd700..50da3a4 100644 --- a/tests/unit/TypeResolverTest.php +++ b/tests/unit/TypeResolverTest.php @@ -226,6 +226,32 @@ public function testResolvingClassStringMapWithNullableValue(): void $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(); + + $this->expectException(\RuntimeException::class); + $fixture->resolve('class-string-map', new Context('')); + } + /** * @uses \phpDocumentor\Reflection\Types\Context * @uses \phpDocumentor\Reflection\Types\Nullable From 3984a329e05c0827a03f9571ed5eeaee2e2e293e Mon Sep 17 00:00:00 2001 From: lacatoire Date: Wed, 15 Apr 2026 22:51:44 +0200 Subject: [PATCH 3/3] Tighten malformed class-string-map assertion with message match --- tests/unit/TypeResolverTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/TypeResolverTest.php b/tests/unit/TypeResolverTest.php index 50da3a4..2cc22ef 100644 --- a/tests/unit/TypeResolverTest.php +++ b/tests/unit/TypeResolverTest.php @@ -248,7 +248,9 @@ 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('')); }