From c6698ced09455c68298a7da5137e589a4727f509 Mon Sep 17 00:00:00 2001 From: Mathias Arlaud Date: Mon, 1 Jul 2024 16:00:19 +0200 Subject: [PATCH] [TypeInfo] Add PhpDocAwareReflectionTypeResolver --- .../FrameworkExtension.php | 15 +++- .../Extractor/ReflectionExtractor.php | 14 ++- src/Symfony/Component/TypeInfo/CHANGELOG.md | 5 ++ .../Tests/Fixtures/DummyWithPhpDoc.php | 21 +++++ .../PhpDocAwareReflectionTypeResolverTest.php | 44 ++++++++++ .../Tests/TypeResolver/TypeResolverTest.php | 17 ++-- .../PhpDocAwareReflectionTypeResolver.php | 87 +++++++++++++++++++ .../TypeResolver/StringTypeResolver.php | 11 +-- .../TypeInfo/TypeResolver/TypeResolver.php | 50 ++++++----- 9 files changed, 223 insertions(+), 41 deletions(-) create mode 100644 src/Symfony/Component/TypeInfo/Tests/Fixtures/DummyWithPhpDoc.php create mode 100644 src/Symfony/Component/TypeInfo/Tests/TypeResolver/PhpDocAwareReflectionTypeResolverTest.php create mode 100644 src/Symfony/Component/TypeInfo/TypeResolver/PhpDocAwareReflectionTypeResolver.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 7cc67725ec461..04d45f59f9115 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -14,6 +14,7 @@ use Composer\InstalledVersions; use Http\Client\HttpAsyncClient; use Http\Client\HttpClient; +use Symfony\Component\TypeInfo\TypeResolver\PhpDocAwareReflectionTypeResolver; use phpDocumentor\Reflection\DocBlockFactoryInterface; use phpDocumentor\Reflection\Types\ContextFactory; use PhpParser\Parser; @@ -1974,11 +1975,21 @@ private function registerTypeInfoConfiguration(ContainerBuilder $container, PhpF if (ContainerBuilder::willBeAvailable('phpstan/phpdoc-parser', PhpDocParser::class, ['symfony/framework-bundle', 'symfony/type-info'])) { $container->register('type_info.resolver.string', StringTypeResolver::class); + $container->register('type_info.resolver.reflection_parameter.phpdoc_aware', PhpDocAwareReflectionTypeResolver::class) + ->setArguments([new Reference('type_info.resolver.reflection_parameter'), new Reference('type_info.resolver.string'), new Reference('type_info.type_context_factory')]); + $container->register('type_info.resolver.reflection_property.phpdoc_aware', PhpDocAwareReflectionTypeResolver::class) + ->setArguments([new Reference('type_info.resolver.reflection_property'), new Reference('type_info.resolver.string'), new Reference('type_info.type_context_factory')]); + $container->register('type_info.resolver.reflection_return.phpdoc_aware', PhpDocAwareReflectionTypeResolver::class) + ->setArguments([new Reference('type_info.resolver.reflection_return'), new Reference('type_info.resolver.string'), new Reference('type_info.type_context_factory')]); + /** @var ServiceLocatorArgument $resolversLocator */ $resolversLocator = $container->getDefinition('type_info.resolver')->getArgument(0); - $resolversLocator->setValues($resolversLocator->getValues() + [ + $resolversLocator->setValues([ 'string' => new Reference('type_info.resolver.string'), - ]); + \ReflectionParameter::class => new Reference('type_info.resolver.reflection_parameter.phpdoc_aware'), + \ReflectionProperty::class => new Reference('type_info.resolver.reflection_property.phpdoc_aware'), + \ReflectionFunctionAbstract::class => new Reference('type_info.resolver.reflection_return.phpdoc_aware'), + ] + $resolversLocator->getValues()); } } diff --git a/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php b/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php index 953e33f04f27c..b97d846aa570a 100644 --- a/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php +++ b/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php @@ -24,6 +24,11 @@ use Symfony\Component\String\Inflector\InflectorInterface; use Symfony\Component\TypeInfo\Exception\UnsupportedException; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; +use Symfony\Component\TypeInfo\TypeResolver\ReflectionParameterTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\ReflectionPropertyTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\ReflectionReturnTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\ReflectionTypeResolver; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; @@ -102,7 +107,14 @@ public function __construct( $this->methodReflectionFlags = $this->getMethodsFlags($accessFlags); $this->propertyReflectionFlags = $this->getPropertyFlags($accessFlags); $this->inflector = $inflector ?? new EnglishInflector(); - $this->typeResolver = TypeResolver::create(); + + $typeContextFactory = new TypeContextFactory(); + $this->typeResolver = TypeResolver::create([ + \ReflectionType::class => $reflectionTypeResolver = new ReflectionTypeResolver(), + \ReflectionParameter::class => new ReflectionParameterTypeResolver($reflectionTypeResolver, $typeContextFactory), + \ReflectionProperty::class => new ReflectionPropertyTypeResolver($reflectionTypeResolver, $typeContextFactory), + \ReflectionFunctionAbstract::class => new ReflectionReturnTypeResolver($reflectionTypeResolver, $typeContextFactory), + ]); $this->arrayMutatorPrefixesFirst = array_merge($this->arrayMutatorPrefixes, array_diff($this->mutatorPrefixes, $this->arrayMutatorPrefixes)); $this->arrayMutatorPrefixesLast = array_reverse($this->arrayMutatorPrefixesFirst); diff --git a/src/Symfony/Component/TypeInfo/CHANGELOG.md b/src/Symfony/Component/TypeInfo/CHANGELOG.md index 6eb821cdebc51..c98ffeb4ac107 100644 --- a/src/Symfony/Component/TypeInfo/CHANGELOG.md +++ b/src/Symfony/Component/TypeInfo/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.2 +--- + + * Add `PhpDocAwareReflectionTypeResolver` resolver + 7.1 --- diff --git a/src/Symfony/Component/TypeInfo/Tests/Fixtures/DummyWithPhpDoc.php b/src/Symfony/Component/TypeInfo/Tests/Fixtures/DummyWithPhpDoc.php new file mode 100644 index 0000000000000..479ccfa2afc01 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/Fixtures/DummyWithPhpDoc.php @@ -0,0 +1,21 @@ + + */ + public mixed $arrayOfDummies = []; + + /** + * @param Dummy $dummy + * + * @return Dummy + */ + public function getNextDummy(mixed $dummy): mixed + { + throw new \BadMethodCallException(sprintf('"%s" is not implemented.', __METHOD__)); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/PhpDocAwareReflectionTypeResolverTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/PhpDocAwareReflectionTypeResolverTest.php new file mode 100644 index 0000000000000..261fd19f18e96 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/PhpDocAwareReflectionTypeResolverTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\Tests\TypeResolver; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\TypeInfo\Tests\Fixtures\Dummy; +use Symfony\Component\TypeInfo\Tests\Fixtures\DummyWithPhpDoc; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; +use Symfony\Component\TypeInfo\TypeResolver\PhpDocAwareReflectionTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; + +class PhpDocAwareReflectionTypeResolverTest extends TestCase +{ + public function testReadPhpDoc() + { + $resolver = new PhpDocAwareReflectionTypeResolver(TypeResolver::create(), new StringTypeResolver(), new TypeContextFactory()); + $reflection = new \ReflectionClass(DummyWithPhpDoc::class); + + $this->assertEquals(Type::array(Type::object(Dummy::class)), $resolver->resolve($reflection->getProperty('arrayOfDummies'))); + $this->assertEquals(Type::object(Dummy::class), $resolver->resolve($reflection->getMethod('getNextDummy'))); + $this->assertEquals(Type::object(Dummy::class), $resolver->resolve($reflection->getMethod('getNextDummy')->getParameters()[0])); + } + + public function testFallbackWhenNoPhpDoc() + { + $resolver = new PhpDocAwareReflectionTypeResolver(TypeResolver::create(), new StringTypeResolver(), new TypeContextFactory()); + $reflection = new \ReflectionClass(Dummy::class); + + $this->assertEquals(Type::int(), $resolver->resolve($reflection->getProperty('id'))); + $this->assertEquals(Type::int(), $resolver->resolve($reflection->getMethod('getId'))); + $this->assertEquals(Type::int(), $resolver->resolve($reflection->getMethod('setId')->getParameters()[0])); + } +} diff --git a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/TypeResolverTest.php b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/TypeResolverTest.php index 3b778ab71c88d..1757a0ae0a685 100644 --- a/src/Symfony/Component/TypeInfo/Tests/TypeResolver/TypeResolverTest.php +++ b/src/Symfony/Component/TypeInfo/Tests/TypeResolver/TypeResolverTest.php @@ -12,7 +12,6 @@ namespace Symfony\Component\TypeInfo\Tests\TypeResolver; use PHPUnit\Framework\TestCase; -use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\TypeInfo\Exception\UnsupportedException; use Symfony\Component\TypeInfo\Tests\Fixtures\Dummy; use Symfony\Component\TypeInfo\Type; @@ -38,7 +37,7 @@ public function testCannotFindResolver() $this->expectException(UnsupportedException::class); $this->expectExceptionMessage('Cannot find any resolver for "int" type.'); - $resolver = new TypeResolver(new ServiceLocator([])); + $resolver = TypeResolver::create([]); $resolver->resolve(1); } @@ -59,13 +58,13 @@ public function testUseProperResolver() $reflectionReturnTypeResolver = $this->createMock(TypeResolverInterface::class); $reflectionReturnTypeResolver->method('resolve')->willReturn(Type::template('REFLECTION_RETURN_TYPE')); - $resolver = new TypeResolver(new ServiceLocator([ - 'string' => fn () => $stringResolver, - \ReflectionType::class => fn () => $reflectionTypeResolver, - \ReflectionParameter::class => fn () => $reflectionParameterResolver, - \ReflectionProperty::class => fn () => $reflectionPropertyResolver, - \ReflectionFunctionAbstract::class => fn () => $reflectionReturnTypeResolver, - ])); + $resolver = TypeResolver::create([ + 'string' => $stringResolver, + \ReflectionType::class => $reflectionTypeResolver, + \ReflectionParameter::class => $reflectionParameterResolver, + \ReflectionProperty::class => $reflectionPropertyResolver, + \ReflectionFunctionAbstract::class => $reflectionReturnTypeResolver, + ]); $this->assertEquals(Type::template('STRING'), $resolver->resolve('foo')); $this->assertEquals( diff --git a/src/Symfony/Component/TypeInfo/TypeResolver/PhpDocAwareReflectionTypeResolver.php b/src/Symfony/Component/TypeInfo/TypeResolver/PhpDocAwareReflectionTypeResolver.php new file mode 100644 index 0000000000000..5c6104afbb2e5 --- /dev/null +++ b/src/Symfony/Component/TypeInfo/TypeResolver/PhpDocAwareReflectionTypeResolver.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\TypeInfo\TypeResolver; + +use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode; +use PHPStan\PhpDocParser\Lexer\Lexer; +use PHPStan\PhpDocParser\Parser\ConstExprParser; +use PHPStan\PhpDocParser\Parser\PhpDocParser; +use PHPStan\PhpDocParser\Parser\TokenIterator; +use PHPStan\PhpDocParser\Parser\TypeParser; +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContext; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface; + +/** + * Resolves type on reflection prioriziting PHP documentation. + * + * @author Mathias Arlaud + * + * @internal + */ +final readonly class PhpDocAwareReflectionTypeResolver implements TypeResolverInterface +{ + public function __construct( + private TypeResolverInterface $reflectionTypeResolver, + private TypeResolverInterface $stringTypeResolver, + private TypeContextFactory $typeContextFactory, + private PhpDocParser $phpDocParser = new PhpDocParser(new TypeParser(), new ConstExprParser()), + private Lexer $lexer = new Lexer(), + ) { + } + + public function resolve(mixed $subject, ?TypeContext $typeContext = null): Type + { + if (!$subject instanceof \ReflectionProperty && !$subject instanceof \ReflectionParameter && !$subject instanceof \ReflectionFunctionAbstract) { + throw new UnsupportedException(sprintf('Expected subject to be a "ReflectionProperty", a "ReflectionParameter" or a "ReflectionFunctionAbstract", "%s" given.', get_debug_type($subject)), $subject); + } + + $docComment = match (true) { + $subject instanceof \ReflectionProperty => $subject->getDocComment(), + $subject instanceof \ReflectionParameter => $subject->getDeclaringFunction()->getDocComment(), + $subject instanceof \ReflectionFunctionAbstract => $subject->getDocComment(), + }; + + if (!$docComment) { + return $this->reflectionTypeResolver->resolve($subject); + } + + $typeContext ??= $this->typeContextFactory->createFromReflection($subject); + + $tagName = match (true) { + $subject instanceof \ReflectionProperty => '@var', + $subject instanceof \ReflectionParameter => '@param', + $subject instanceof \ReflectionFunctionAbstract => '@return', + }; + + $tokens = new TokenIterator($this->lexer->tokenize($docComment)); + $docNode = $this->phpDocParser->parse($tokens); + + foreach ($docNode->getTagsByName($tagName) as $tag) { + $tagValue = $tag->value; + + if ( + $tagValue instanceof VarTagValueNode + || $tagValue instanceof ParamTagValueNode && $tagName && '$'.$subject->getName() === $tagValue->parameterName + || $tagValue instanceof ReturnTagValueNode + ) { + return $this->stringTypeResolver->resolve((string) $tagValue, $typeContext); + } + } + + return $this->reflectionTypeResolver->resolve($subject); + } +} diff --git a/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php b/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php index 3c97e397bb060..793eb394e9df0 100644 --- a/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php +++ b/src/Symfony/Component/TypeInfo/TypeResolver/StringTypeResolver.php @@ -58,13 +58,10 @@ final class StringTypeResolver implements TypeResolverInterface */ private static array $classExistCache = []; - private readonly Lexer $lexer; - private readonly TypeParser $parser; - - public function __construct() - { - $this->lexer = new Lexer(); - $this->parser = new TypeParser(new ConstExprParser()); + public function __construct( + private Lexer $lexer = new Lexer(), + private TypeParser $parser = new TypeParser(new ConstExprParser()), + ) { } public function resolve(mixed $subject, ?TypeContext $typeContext = null): Type diff --git a/src/Symfony/Component/TypeInfo/TypeResolver/TypeResolver.php b/src/Symfony/Component/TypeInfo/TypeResolver/TypeResolver.php index baf011575a1f2..373249c479e24 100644 --- a/src/Symfony/Component/TypeInfo/TypeResolver/TypeResolver.php +++ b/src/Symfony/Component/TypeInfo/TypeResolver/TypeResolver.php @@ -61,29 +61,35 @@ public function resolve(mixed $subject, ?TypeContext $typeContext = null): Type return $resolver->resolve($subject, $typeContext); } - public static function create(): self + /** + * @param array|null $resolvers + */ + public static function create(?array $resolvers = null): self { - $resolvers = new class() implements ContainerInterface { - private readonly array $resolvers; + if (null === $resolvers) { + $stringTypeResolver = class_exists(PhpDocParser::class) ? new StringTypeResolver() : null; + $typeContextFactory = new TypeContextFactory($stringTypeResolver); + $reflectionTypeResolver = new ReflectionTypeResolver(); + + $resolvers = [ + \ReflectionType::class => $reflectionTypeResolver, + \ReflectionParameter::class => new ReflectionParameterTypeResolver($reflectionTypeResolver, $typeContextFactory), + \ReflectionProperty::class => new ReflectionPropertyTypeResolver($reflectionTypeResolver, $typeContextFactory), + \ReflectionFunctionAbstract::class => new ReflectionReturnTypeResolver($reflectionTypeResolver, $typeContextFactory), + ]; + + if (null !== $stringTypeResolver) { + $resolvers['string'] = $stringTypeResolver; + $resolvers[\ReflectionParameter::class] = new PhpDocAwareReflectionTypeResolver($resolvers[\ReflectionParameter::class], $stringTypeResolver, $typeContextFactory); + $resolvers[\ReflectionProperty::class] = new PhpDocAwareReflectionTypeResolver($resolvers[\ReflectionProperty::class], $stringTypeResolver, $typeContextFactory); + $resolvers[\ReflectionFunctionAbstract::class] = new PhpDocAwareReflectionTypeResolver($resolvers[\ReflectionFunctionAbstract::class], $stringTypeResolver, $typeContextFactory); + } + } - public function __construct() - { - $stringTypeResolver = class_exists(PhpDocParser::class) ? new StringTypeResolver() : null; - $typeContextFactory = new TypeContextFactory($stringTypeResolver); - $reflectionTypeResolver = new ReflectionTypeResolver(); - - $resolvers = [ - \ReflectionType::class => $reflectionTypeResolver, - \ReflectionParameter::class => new ReflectionParameterTypeResolver($reflectionTypeResolver, $typeContextFactory), - \ReflectionProperty::class => new ReflectionPropertyTypeResolver($reflectionTypeResolver, $typeContextFactory), - \ReflectionFunctionAbstract::class => new ReflectionReturnTypeResolver($reflectionTypeResolver, $typeContextFactory), - ]; - - if (null !== $stringTypeResolver) { - $resolvers['string'] = $stringTypeResolver; - } - - $this->resolvers = $resolvers; + $resolversContainer = new class($resolvers) implements ContainerInterface { + public function __construct( + private readonly array $resolvers, + ) { } public function has(string $id): bool @@ -97,6 +103,6 @@ public function get(string $id): TypeResolverInterface } }; - return new self($resolvers); + return new self($resolversContainer); } }