From 9931c3705d6746e7ec2d0f419ad39a7af3390b76 Mon Sep 17 00:00:00 2001 From: Baptiste Leduc Date: Fri, 12 Mar 2021 16:40:10 +0100 Subject: [PATCH] Add PhpStanExtractor --- composer.json | 1 + .../Bundle/FrameworkBundle/CHANGELOG.md | 1 + .../FrameworkExtension.php | 10 + .../Component/PropertyInfo/CHANGELOG.md | 5 + .../Extractor/PhpStanExtractor.php | 262 ++++++++++++ .../PropertyInfo/PhpStan/NameScope.php | 64 +++ .../PropertyInfo/PhpStan/NameScopeFactory.php | 56 +++ .../Tests/Extractor/PhpStanExtractorTest.php | 384 ++++++++++++++++++ .../Tests/Fixtures/DummyUnionType.php | 43 ++ src/Symfony/Component/PropertyInfo/Type.php | 10 + .../PropertyInfo/Util/PhpStanTypeHelper.php | 178 ++++++++ .../Component/PropertyInfo/composer.json | 1 + 12 files changed, 1015 insertions(+) create mode 100644 src/Symfony/Component/PropertyInfo/Extractor/PhpStanExtractor.php create mode 100644 src/Symfony/Component/PropertyInfo/PhpStan/NameScope.php create mode 100644 src/Symfony/Component/PropertyInfo/PhpStan/NameScopeFactory.php create mode 100644 src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php create mode 100644 src/Symfony/Component/PropertyInfo/Tests/Fixtures/DummyUnionType.php create mode 100644 src/Symfony/Component/PropertyInfo/Util/PhpStanTypeHelper.php diff --git a/composer.json b/composer.json index a933c12e8fc89..0e069e008b051 100644 --- a/composer.json +++ b/composer.json @@ -138,6 +138,7 @@ "paragonie/sodium_compat": "^1.8", "pda/pheanstalk": "^4.0", "php-http/httplug": "^1.0|^2.0", + "phpstan/phpdoc-parser": "^0.4", "predis/predis": "~1.1", "psr/http-client": "^1.0", "psr/simple-cache": "^1.0", diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 2ee32e3c8a9cd..8861ac77659d3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -19,6 +19,7 @@ CHANGELOG * Bind the `default_context` parameter onto serializer's encoders and normalizers * Add support for `statusCode` default parameter when loading a template directly from route using the `Symfony\Bundle\FrameworkBundle\Controller\TemplateController` controller * Deprecate `translation:update` command, use `translation:extract` instead + * Add `PhpStanExtractor` support for the PropertyInfo component 5.3 --- diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 41f7ce35aa23b..b61b543905f0e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -16,6 +16,7 @@ use Doctrine\Common\Annotations\Reader; use Http\Client\HttpClient; use phpDocumentor\Reflection\DocBlockFactoryInterface; +use PHPStan\PhpDocParser\Parser\PhpDocParser; use Psr\Cache\CacheItemPoolInterface; use Psr\Container\ContainerInterface as PsrContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface as PsrEventDispatcherInterface; @@ -160,6 +161,7 @@ use Symfony\Component\Notifier\Recipient\Recipient; use Symfony\Component\Notifier\Transport\TransportFactoryInterface as NotifierTransportFactoryInterface; use Symfony\Component\PropertyAccess\PropertyAccessor; +use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface; use Symfony\Component\PropertyInfo\PropertyDescriptionExtractorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; @@ -1833,6 +1835,14 @@ private function registerPropertyInfoConfiguration(ContainerBuilder $container, $loader->load('property_info.php'); + if ( + ContainerBuilder::willBeAvailable('phpstan/phpdoc-parser', PhpDocParser::class, ['symfony/framework-bundle', 'symfony/property-info'], true) + && ContainerBuilder::willBeAvailable('phpdocumentor/type-resolver', PhpDocParser::class, ['symfony/framework-bundle', 'symfony/property-info'], true) + ) { + $definition = $container->register('property_info.phpstan_extractor', PhpStanExtractor::class); + $definition->addTag('property_info.type_extractor', ['priority' => -1000]); + } + if (ContainerBuilder::willBeAvailable('phpdocumentor/reflection-docblock', DocBlockFactoryInterface::class, ['symfony/framework-bundle', 'symfony/property-info'], true)) { $definition = $container->register('property_info.php_doc_extractor', 'Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor'); $definition->addTag('property_info.description_extractor', ['priority' => -1000]); diff --git a/src/Symfony/Component/PropertyInfo/CHANGELOG.md b/src/Symfony/Component/PropertyInfo/CHANGELOG.md index 5e23cf8e2ebbb..8963b940ebed0 100644 --- a/src/Symfony/Component/PropertyInfo/CHANGELOG.md +++ b/src/Symfony/Component/PropertyInfo/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.4 +--- + + * Add PhpStanExtractor + 5.3 --- diff --git a/src/Symfony/Component/PropertyInfo/Extractor/PhpStanExtractor.php b/src/Symfony/Component/PropertyInfo/Extractor/PhpStanExtractor.php new file mode 100644 index 0000000000000..014a846315462 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Extractor/PhpStanExtractor.php @@ -0,0 +1,262 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Extractor; + +use PHPStan\PhpDocParser\Ast\PhpDoc\InvalidTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; +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\PropertyInfo\PhpStan\NameScopeFactory; +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\PropertyInfo\Util\PhpStanTypeHelper; + +/** + * Extracts data using PHPStan parser. + * + * @author Baptiste Leduc + */ +final class PhpStanExtractor implements PropertyTypeExtractorInterface, ConstructorArgumentTypeExtractorInterface +{ + public const PROPERTY = 0; + public const ACCESSOR = 1; + public const MUTATOR = 2; + + /** @var PhpDocParser */ + private $phpDocParser; + + /** @var Lexer */ + private $lexer; + + /** @var NameScopeFactory */ + private $nameScopeFactory; + + private $docBlocks = []; + private $phpStanTypeHelper; + private $mutatorPrefixes; + private $accessorPrefixes; + private $arrayMutatorPrefixes; + + public function __construct(array $mutatorPrefixes = null, array $accessorPrefixes = null, array $arrayMutatorPrefixes = null) + { + $this->phpStanTypeHelper = new PhpStanTypeHelper(); + $this->mutatorPrefixes = $mutatorPrefixes ?? ReflectionExtractor::$defaultMutatorPrefixes; + $this->accessorPrefixes = $accessorPrefixes ?? ReflectionExtractor::$defaultAccessorPrefixes; + $this->arrayMutatorPrefixes = $arrayMutatorPrefixes ?? ReflectionExtractor::$defaultArrayMutatorPrefixes; + + $this->phpDocParser = new PhpDocParser(new TypeParser(new ConstExprParser()), new ConstExprParser()); + $this->lexer = new Lexer(); + $this->nameScopeFactory = new NameScopeFactory(); + } + + public function getTypes(string $class, string $property, array $context = []): ?array + { + /** @var $docNode PhpDocNode */ + [$docNode, $source, $prefix] = $this->getDocBlock($class, $property); + $nameScope = $this->nameScopeFactory->create($class); + if (null === $docNode) { + return null; + } + + switch ($source) { + case self::PROPERTY: + $tag = '@var'; + break; + + case self::ACCESSOR: + $tag = '@return'; + break; + + case self::MUTATOR: + $tag = '@param'; + break; + } + + $parentClass = null; + $types = []; + foreach ($docNode->getTagsByName($tag) as $tagDocNode) { + if ($tagDocNode->value instanceof InvalidTagValueNode) { + continue; + } + + foreach ($this->phpStanTypeHelper->getTypes($tagDocNode->value, $nameScope) as $type) { + switch ($type->getClassName()) { + case 'self': + case 'static': + $resolvedClass = $class; + break; + + case 'parent': + if (false !== $resolvedClass = $parentClass ?? $parentClass = get_parent_class($class)) { + break; + } + // no break + + default: + $types[] = $type; + continue 2; + } + + $types[] = new Type(Type::BUILTIN_TYPE_OBJECT, $type->isNullable(), $resolvedClass, $type->isCollection(), $type->getCollectionKeyTypes(), $type->getCollectionValueTypes()); + } + } + + if (!isset($types[0])) { + return null; + } + + if (!\in_array($prefix, $this->arrayMutatorPrefixes, true)) { + return $types; + } + + return [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), $types[0])]; + } + + public function getTypesFromConstructor(string $class, string $property): ?array + { + if (null === $tagDocNode = $this->getDocBlockFromConstructor($class, $property)) { + return null; + } + + $types = []; + foreach ($this->phpStanTypeHelper->getTypes($tagDocNode, $this->nameScopeFactory->create($class)) as $type) { + $types[] = $type; + } + + if (!isset($types[0])) { + return null; + } + + return $types; + } + + private function getDocBlockFromConstructor(string $class, string $property): ?ParamTagValueNode + { + try { + $reflectionClass = new \ReflectionClass($class); + } catch (\ReflectionException $e) { + return null; + } + + if (null === $reflectionConstructor = $reflectionClass->getConstructor()) { + return null; + } + + $rawDocNode = $reflectionConstructor->getDocComment(); + $tokens = new TokenIterator($this->lexer->tokenize($rawDocNode)); + $phpDocNode = $this->phpDocParser->parse($tokens); + $tokens->consumeTokenType(Lexer::TOKEN_END); + + return $this->filterDocBlockParams($phpDocNode, $property); + } + + private function filterDocBlockParams(PhpDocNode $docNode, string $allowedParam): ?ParamTagValueNode + { + $tags = array_values(array_filter($docNode->getTagsByName('@param'), function ($tagNode) use ($allowedParam) { + return $tagNode instanceof PhpDocTagNode && ('$'.$allowedParam) === $tagNode->value->parameterName; + })); + + if (!$tags) { + return null; + } + + return $tags[0]->value; + } + + private function getDocBlock(string $class, string $property): array + { + $propertyHash = $class.'::'.$property; + + if (isset($this->docBlocks[$propertyHash])) { + return $this->docBlocks[$propertyHash]; + } + + $ucFirstProperty = ucfirst($property); + + if ($docBlock = $this->getDocBlockFromProperty($class, $property)) { + $data = [$docBlock, self::PROPERTY, null]; + } elseif ([$docBlock] = $this->getDocBlockFromMethod($class, $ucFirstProperty, self::ACCESSOR)) { + $data = [$docBlock, self::ACCESSOR, null]; + } elseif ([$docBlock, $prefix] = $this->getDocBlockFromMethod($class, $ucFirstProperty, self::MUTATOR)) { + $data = [$docBlock, self::MUTATOR, $prefix]; + } else { + $data = [null, null, null]; + } + + return $this->docBlocks[$propertyHash] = $data; + } + + private function getDocBlockFromProperty(string $class, string $property): ?PhpDocNode + { + // Use a ReflectionProperty instead of $class to get the parent class if applicable + try { + $reflectionProperty = new \ReflectionProperty($class, $property); + } catch (\ReflectionException $e) { + return null; + } + + if (null === $rawDocNode = $reflectionProperty->getDocComment() ?: null) { + return null; + } + + $tokens = new TokenIterator($this->lexer->tokenize($rawDocNode)); + $phpDocNode = $this->phpDocParser->parse($tokens); + $tokens->consumeTokenType(Lexer::TOKEN_END); + + return $phpDocNode; + } + + private function getDocBlockFromMethod(string $class, string $ucFirstProperty, int $type): ?array + { + $prefixes = self::ACCESSOR === $type ? $this->accessorPrefixes : $this->mutatorPrefixes; + $prefix = null; + + foreach ($prefixes as $prefix) { + $methodName = $prefix.$ucFirstProperty; + + try { + $reflectionMethod = new \ReflectionMethod($class, $methodName); + if ($reflectionMethod->isStatic()) { + continue; + } + + if ( + (self::ACCESSOR === $type && 0 === $reflectionMethod->getNumberOfRequiredParameters()) + || (self::MUTATOR === $type && $reflectionMethod->getNumberOfParameters() >= 1) + ) { + break; + } + } catch (\ReflectionException $e) { + // Try the next prefix if the method doesn't exist + } + } + + if (!isset($reflectionMethod)) { + return null; + } + + if (null === $rawDocNode = $reflectionMethod->getDocComment() ?: null) { + return null; + } + + $tokens = new TokenIterator($this->lexer->tokenize($rawDocNode)); + $phpDocNode = $this->phpDocParser->parse($tokens); + $tokens->consumeTokenType(Lexer::TOKEN_END); + + return [$phpDocNode, $prefix]; + } +} diff --git a/src/Symfony/Component/PropertyInfo/PhpStan/NameScope.php b/src/Symfony/Component/PropertyInfo/PhpStan/NameScope.php new file mode 100644 index 0000000000000..8bc9f7dfd4ba3 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/PhpStan/NameScope.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\PhpStan; + +/** + * NameScope class adapted from PHPStan code. + * + * @copyright Copyright (c) 2016, PHPStan https://github.com/phpstan/phpstan-src + * @copyright Copyright (c) 2016, Ondřej Mirtes + * @author Baptiste Leduc + * + * @internal + */ +final class NameScope +{ + private $className; + private $namespace; + /** @var array alias(string) => fullName(string) */ + private $uses; + + public function __construct(string $className, string $namespace, array $uses = []) + { + $this->className = $className; + $this->namespace = $namespace; + $this->uses = $uses; + } + + public function resolveStringName(string $name): string + { + if (0 === strpos($name, '\\')) { + return ltrim($name, '\\'); + } + + $nameParts = explode('\\', $name); + if (isset($this->uses[$nameParts[0]])) { + if (1 === \count($nameParts)) { + return $this->uses[$nameParts[0]]; + } + array_shift($nameParts); + + return sprintf('%s\\%s', $this->uses[$nameParts[0]], implode('\\', $nameParts)); + } + + if (null !== $this->namespace) { + return sprintf('%s\\%s', $this->namespace, $name); + } + + return $name; + } + + public function resolveRootClass(): string + { + return $this->resolveStringName($this->className); + } +} diff --git a/src/Symfony/Component/PropertyInfo/PhpStan/NameScopeFactory.php b/src/Symfony/Component/PropertyInfo/PhpStan/NameScopeFactory.php new file mode 100644 index 0000000000000..a5eb8b47fcfde --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/PhpStan/NameScopeFactory.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\PhpStan; + +use phpDocumentor\Reflection\Types\ContextFactory; + +/** + * @author Baptiste Leduc + * + * @internal + */ +final class NameScopeFactory +{ + public function create(string $fullClassName): NameScope + { + $path = explode('\\', $fullClassName); + $className = array_pop($path); + [$namespace, $uses] = $this->extractFromFullClassName($fullClassName); + + foreach (class_uses($fullClassName) as $traitFullClassName) { + [, $traitUses] = $this->extractFromFullClassName($traitFullClassName); + $uses = array_merge($uses, $traitUses); + } + + return new NameScope($className, $namespace, $uses); + } + + private function extractFromFullClassName(string $fullClassName): array + { + $reflection = new \ReflectionClass($fullClassName); + $namespace = trim($reflection->getNamespaceName(), '\\'); + $fileName = $reflection->getFileName(); + + if (\is_string($fileName) && is_file($fileName)) { + if (false === $contents = file_get_contents($fileName)) { + throw new \RuntimeException(sprintf('Unable to read file "%s".', $fileName)); + } + + $factory = new ContextFactory(); + $context = $factory->createForNamespace($namespace, $contents); + + return [$namespace, $context->getNamespaceAliases()]; + } + + return [$namespace, []]; + } +} diff --git a/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php new file mode 100644 index 0000000000000..c5c24254a7fbe --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php @@ -0,0 +1,384 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Tests\Extractor; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; +use Symfony\Component\PropertyInfo\Tests\Fixtures\DefaultValue; +use Symfony\Component\PropertyInfo\Tests\Fixtures\Dummy; +use Symfony\Component\PropertyInfo\Tests\Fixtures\ParentDummy; +use Symfony\Component\PropertyInfo\Tests\Fixtures\TraitUsage\DummyUsedInTrait; +use Symfony\Component\PropertyInfo\Tests\Fixtures\TraitUsage\DummyUsingTrait; +use Symfony\Component\PropertyInfo\Type; + +/** + * @author Baptiste Leduc + */ +class PhpStanExtractorTest extends TestCase +{ + /** + * @var PhpStanExtractor + */ + private $extractor; + + protected function setUp(): void + { + $this->extractor = new PhpStanExtractor(); + } + + /** + * @dataProvider typesProvider + */ + public function testExtract($property, array $type = null) + { + $this->assertEquals($type, $this->extractor->getTypes('Symfony\Component\PropertyInfo\Tests\Fixtures\Dummy', $property)); + } + + public function testParamTagTypeIsOmitted() + { + $this->assertNull($this->extractor->getTypes(PhpStanOmittedParamTagTypeDocBlock::class, 'omittedType')); + } + + public function invalidTypesProvider() + { + return [ + 'pub' => ['pub'], + 'stat' => ['stat'], + 'foo' => ['foo'], + 'bar' => ['bar'], + ]; + } + + /** + * @dataProvider invalidTypesProvider + */ + public function testInvalid($property) + { + $this->assertNull($this->extractor->getTypes('Symfony\Component\PropertyInfo\Tests\Fixtures\InvalidDummy', $property)); + } + + /** + * @dataProvider typesWithNoPrefixesProvider + */ + public function testExtractTypesWithNoPrefixes($property, array $type = null) + { + $noPrefixExtractor = new PhpStanExtractor([], [], []); + + $this->assertEquals($type, $noPrefixExtractor->getTypes('Symfony\Component\PropertyInfo\Tests\Fixtures\Dummy', $property)); + } + + public function typesProvider() + { + return [ + ['foo', null], + ['bar', [new Type(Type::BUILTIN_TYPE_STRING)]], + ['baz', [new Type(Type::BUILTIN_TYPE_INT)]], + ['foo2', [new Type(Type::BUILTIN_TYPE_FLOAT)]], + ['foo3', [new Type(Type::BUILTIN_TYPE_CALLABLE)]], + ['foo4', [new Type(Type::BUILTIN_TYPE_NULL)]], + ['foo5', null], + [ + 'files', + [ + new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, 'SplFileInfo')), + new Type(Type::BUILTIN_TYPE_RESOURCE), + ], + ], + ['bal', [new Type(Type::BUILTIN_TYPE_OBJECT, false, 'DateTime')]], + ['parent', [new Type(Type::BUILTIN_TYPE_OBJECT, false, 'Symfony\Component\PropertyInfo\Tests\Fixtures\ParentDummy')]], + ['collection', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, 'DateTime'))]], + ['nestedCollection', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING, false)))]], + ['mixedCollection', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [new Type(Type::BUILTIN_TYPE_INT)], null)]], + ['a', [new Type(Type::BUILTIN_TYPE_INT)]], + ['b', [new Type(Type::BUILTIN_TYPE_OBJECT, true, 'Symfony\Component\PropertyInfo\Tests\Fixtures\ParentDummy')]], + ['c', [new Type(Type::BUILTIN_TYPE_BOOL, true)]], + ['d', [new Type(Type::BUILTIN_TYPE_BOOL)]], + ['e', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_RESOURCE))]], + ['f', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, 'DateTime'))]], + ['g', [new Type(Type::BUILTIN_TYPE_ARRAY, true, null, true)]], + ['h', [new Type(Type::BUILTIN_TYPE_STRING, true)]], + ['j', [new Type(Type::BUILTIN_TYPE_OBJECT, true, 'DateTime')]], + ['nullableCollectionOfNonNullableElements', [new Type(Type::BUILTIN_TYPE_ARRAY, true, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_INT, false))]], + ['donotexist', null], + ['staticGetter', null], + ['staticSetter', null], + ['emptyVar', null], + ['arrayWithKeys', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_STRING))]], + ['arrayOfMixed', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_STRING), null)]], + ['self', [new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)]], + ]; + } + + /** + * @dataProvider provideCollectionTypes + */ + public function testExtractCollection($property, array $type = null) + { + $this->testExtract($property, $type); + } + + public function provideCollectionTypes() + { + return [ + ['iteratorCollection', [new Type(Type::BUILTIN_TYPE_OBJECT, false, 'Iterator', true, null, new Type(Type::BUILTIN_TYPE_STRING))]], + ['iteratorCollectionWithKey', [new Type(Type::BUILTIN_TYPE_OBJECT, false, 'Iterator', true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING))]], + [ + 'nestedIterators', + [new Type( + Type::BUILTIN_TYPE_OBJECT, + false, + 'Iterator', + true, + new Type(Type::BUILTIN_TYPE_INT), + new Type(Type::BUILTIN_TYPE_OBJECT, false, 'Iterator', true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING)) + )], + ], + [ + 'arrayWithKeys', + [new Type( + Type::BUILTIN_TYPE_ARRAY, + false, + null, + true, + new Type(Type::BUILTIN_TYPE_STRING), + new Type(Type::BUILTIN_TYPE_STRING) + )], + ], + [ + 'arrayWithKeysAndComplexValue', + [new Type( + Type::BUILTIN_TYPE_ARRAY, + false, + null, + true, + new Type(Type::BUILTIN_TYPE_STRING), + new Type( + Type::BUILTIN_TYPE_ARRAY, + true, + null, + true, + new Type(Type::BUILTIN_TYPE_INT), + new Type(Type::BUILTIN_TYPE_STRING, true) + ) + )], + ], + ]; + } + + /** + * @dataProvider typesWithCustomPrefixesProvider + */ + public function testExtractTypesWithCustomPrefixes($property, array $type = null) + { + $customExtractor = new PhpStanExtractor(['add', 'remove'], ['is', 'can']); + + $this->assertEquals($type, $customExtractor->getTypes('Symfony\Component\PropertyInfo\Tests\Fixtures\Dummy', $property)); + } + + public function typesWithCustomPrefixesProvider() + { + return [ + ['foo', null], + ['bar', [new Type(Type::BUILTIN_TYPE_STRING)]], + ['baz', [new Type(Type::BUILTIN_TYPE_INT)]], + ['foo2', [new Type(Type::BUILTIN_TYPE_FLOAT)]], + ['foo3', [new Type(Type::BUILTIN_TYPE_CALLABLE)]], + ['foo4', [new Type(Type::BUILTIN_TYPE_NULL)]], + ['foo5', null], + [ + 'files', + [ + new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, 'SplFileInfo')), + new Type(Type::BUILTIN_TYPE_RESOURCE), + ], + ], + ['bal', [new Type(Type::BUILTIN_TYPE_OBJECT, false, 'DateTime')]], + ['parent', [new Type(Type::BUILTIN_TYPE_OBJECT, false, 'Symfony\Component\PropertyInfo\Tests\Fixtures\ParentDummy')]], + ['collection', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, 'DateTime'))]], + ['nestedCollection', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING, false)))]], + ['mixedCollection', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [new Type(Type::BUILTIN_TYPE_INT)], null)]], + ['a', null], + ['b', null], + ['c', [new Type(Type::BUILTIN_TYPE_BOOL, true)]], + ['d', [new Type(Type::BUILTIN_TYPE_BOOL)]], + ['e', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_RESOURCE))]], + ['f', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, 'DateTime'))]], + ['g', [new Type(Type::BUILTIN_TYPE_ARRAY, true, null, true)]], + ['h', [new Type(Type::BUILTIN_TYPE_STRING, true)]], + ['j', [new Type(Type::BUILTIN_TYPE_OBJECT, true, 'DateTime')]], + ['nullableCollectionOfNonNullableElements', [new Type(Type::BUILTIN_TYPE_ARRAY, true, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_INT, false))]], + ['donotexist', null], + ['staticGetter', null], + ['staticSetter', null], + ]; + } + + public function typesWithNoPrefixesProvider() + { + return [ + ['foo', null], + ['bar', [new Type(Type::BUILTIN_TYPE_STRING)]], + ['baz', [new Type(Type::BUILTIN_TYPE_INT)]], + ['foo2', [new Type(Type::BUILTIN_TYPE_FLOAT)]], + ['foo3', [new Type(Type::BUILTIN_TYPE_CALLABLE)]], + ['foo4', [new Type(Type::BUILTIN_TYPE_NULL)]], + ['foo5', null], + [ + 'files', + [ + new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, 'SplFileInfo')), + new Type(Type::BUILTIN_TYPE_RESOURCE), + ], + ], + ['bal', [new Type(Type::BUILTIN_TYPE_OBJECT, false, 'DateTime')]], + ['parent', [new Type(Type::BUILTIN_TYPE_OBJECT, false, 'Symfony\Component\PropertyInfo\Tests\Fixtures\ParentDummy')]], + ['collection', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, 'DateTime'))]], + ['nestedCollection', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING, false)))]], + ['mixedCollection', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [new Type(Type::BUILTIN_TYPE_INT)], null)]], + ['a', null], + ['b', null], + ['c', null], + ['d', null], + ['e', null], + ['f', null], + ['g', [new Type(Type::BUILTIN_TYPE_ARRAY, true, null, true)]], + ['h', [new Type(Type::BUILTIN_TYPE_STRING, true)]], + ['j', [new Type(Type::BUILTIN_TYPE_OBJECT, true, 'DateTime')]], + ['nullableCollectionOfNonNullableElements', [new Type(Type::BUILTIN_TYPE_ARRAY, true, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_INT, false))]], + ['donotexist', null], + ['staticGetter', null], + ['staticSetter', null], + ]; + } + + public function dockBlockFallbackTypesProvider() + { + return [ + 'pub' => [ + 'pub', [new Type(Type::BUILTIN_TYPE_STRING)], + ], + 'protAcc' => [ + 'protAcc', [new Type(Type::BUILTIN_TYPE_INT)], + ], + 'protMut' => [ + 'protMut', [new Type(Type::BUILTIN_TYPE_BOOL)], + ], + ]; + } + + /** + * @dataProvider dockBlockFallbackTypesProvider + */ + public function testDocBlockFallback($property, $types) + { + $this->assertEquals($types, $this->extractor->getTypes('Symfony\Component\PropertyInfo\Tests\Fixtures\DockBlockFallback', $property)); + } + + /** + * @dataProvider propertiesDefinedByTraitsProvider + */ + public function testPropertiesDefinedByTraits(string $property, Type $type) + { + $this->assertEquals([$type], $this->extractor->getTypes(DummyUsingTrait::class, $property)); + } + + public function propertiesDefinedByTraitsProvider(): array + { + return [ + ['propertyInTraitPrimitiveType', new Type(Type::BUILTIN_TYPE_STRING)], + ['propertyInTraitObjectSameNamespace', new Type(Type::BUILTIN_TYPE_OBJECT, false, DummyUsedInTrait::class)], + ['propertyInTraitObjectDifferentNamespace', new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)], + ]; + } + + /** + * @dataProvider propertiesStaticTypeProvider + */ + public function testPropertiesStaticType(string $class, string $property, Type $type) + { + $this->assertEquals([$type], $this->extractor->getTypes($class, $property)); + } + + public function propertiesStaticTypeProvider(): array + { + return [ + [ParentDummy::class, 'propertyTypeStatic', new Type(Type::BUILTIN_TYPE_OBJECT, false, ParentDummy::class)], + [Dummy::class, 'propertyTypeStatic', new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)], + ]; + } + + /** + * @dataProvider propertiesParentTypeProvider + */ + public function testPropertiesParentType(string $class, string $property, ?array $types) + { + $this->assertEquals($types, $this->extractor->getTypes($class, $property)); + } + + public function propertiesParentTypeProvider(): array + { + return [ + [ParentDummy::class, 'parentAnnotationNoParent', [new Type(Type::BUILTIN_TYPE_OBJECT, false, 'parent')]], + [Dummy::class, 'parentAnnotation', [new Type(Type::BUILTIN_TYPE_OBJECT, false, ParentDummy::class)]], + ]; + } + + /** + * @dataProvider constructorTypesProvider + */ + public function testExtractConstructorTypes($property, array $type = null) + { + $this->assertEquals($type, $this->extractor->getTypesFromConstructor('Symfony\Component\PropertyInfo\Tests\Fixtures\ConstructorDummy', $property)); + } + + public function constructorTypesProvider() + { + return [ + ['date', [new Type(Type::BUILTIN_TYPE_INT)]], + ['timezone', [new Type(Type::BUILTIN_TYPE_OBJECT, false, 'DateTimeZone')]], + ['dateObject', [new Type(Type::BUILTIN_TYPE_OBJECT, false, 'DateTimeInterface')]], + ['dateTime', null], + ['ddd', null], + ]; + } + + /** + * @dataProvider unionTypesProvider + */ + public function testExtractorUnionTypes(string $property, array $types) + { + $this->assertEquals($types, $this->extractor->getTypes('Symfony\Component\PropertyInfo\Tests\Fixtures\DummyUnionType', $property)); + } + + public function unionTypesProvider(): array + { + return [ + ['a', [new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_INT)]], + ['b', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [new Type(Type::BUILTIN_TYPE_INT)], [new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_INT)])]], + ['c', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [], [new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_INT)])]], + ['d', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_INT)], [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [], [new Type(Type::BUILTIN_TYPE_STRING)])])]], + ['e', [new Type(Type::BUILTIN_TYPE_OBJECT, true, Dummy::class, true, [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [], [new Type(Type::BUILTIN_TYPE_STRING)])], [new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [new Type(Type::BUILTIN_TYPE_INT)], [new Type(Type::BUILTIN_TYPE_STRING, false, null, true, [], [new Type(Type::BUILTIN_TYPE_OBJECT, false, DefaultValue::class)])])]), new Type(Type::BUILTIN_TYPE_OBJECT, false, ParentDummy::class)]], + ]; + } +} + +class PhpStanOmittedParamTagTypeDocBlock +{ + /** + * The type is omitted here to ensure that the extractor doesn't choke on missing types. + * + * @param $omittedTagType + */ + public function setOmittedType(array $omittedTagType) + { + } +} diff --git a/src/Symfony/Component/PropertyInfo/Tests/Fixtures/DummyUnionType.php b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/DummyUnionType.php new file mode 100644 index 0000000000000..60af596bad3b3 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/DummyUnionType.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Tests\Fixtures; + +/** + * @author Baptiste Leduc + */ +class DummyUnionType +{ + /** + * @var string|int + */ + public $a; + + /** + * @var (string|int)[] + */ + public $b; + + /** + * @var array + */ + public $c; + + /** + * @var array> + */ + public $d; + + /** + * @var (Dummy, (int | (string)[])> | ParentDummy | null) + */ + public $e; +} diff --git a/src/Symfony/Component/PropertyInfo/Type.php b/src/Symfony/Component/PropertyInfo/Type.php index 31ffaf388422c..6aecc01c3ed96 100644 --- a/src/Symfony/Component/PropertyInfo/Type.php +++ b/src/Symfony/Component/PropertyInfo/Type.php @@ -53,6 +53,16 @@ class Type self::BUILTIN_TYPE_ITERABLE, ]; + /** + * List of PHP builtin collection types. + * + * @var string[] + */ + public static $builtinCollectionTypes = [ + self::BUILTIN_TYPE_ARRAY, + self::BUILTIN_TYPE_ITERABLE, + ]; + private $builtinType; private $nullable; private $class; diff --git a/src/Symfony/Component/PropertyInfo/Util/PhpStanTypeHelper.php b/src/Symfony/Component/PropertyInfo/Util/PhpStanTypeHelper.php new file mode 100644 index 0000000000000..297fd542b7329 --- /dev/null +++ b/src/Symfony/Component/PropertyInfo/Util/PhpStanTypeHelper.php @@ -0,0 +1,178 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Util; + +use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; +use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; +use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; +use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode; +use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; +use Symfony\Component\PropertyInfo\PhpStan\NameScope; +use Symfony\Component\PropertyInfo\Type; + +/** + * Transforms a php doc tag value to a {@link Type} instance. + * + * @author Baptiste Leduc + * + * @internal + */ +final class PhpStanTypeHelper +{ + /** + * Creates a {@see Type} from a PhpDocTagValueNode type. + * + * @return Type[] + */ + public function getTypes(PhpDocTagValueNode $node, NameScope $nameScope): array + { + if ($node instanceof ParamTagValueNode || $node instanceof ReturnTagValueNode || $node instanceof VarTagValueNode) { + return $this->compressNullableType($this->extractTypes($node->type, $nameScope)); + } + + return []; + } + + /** + * Because PhpStan extract null as a separated type when Symfony / PHP compress it in the first available type we + * need this method to mimic how Symfony want null types. + * + * @param Type[] $types + * + * @return Type[] + */ + private function compressNullableType(array $types): array + { + $firstTypeIndex = null; + $nullableTypeIndex = null; + + foreach ($types as $k => $type) { + if (null === $firstTypeIndex && Type::BUILTIN_TYPE_NULL !== $type->getBuiltinType() && !$type->isNullable()) { + $firstTypeIndex = $k; + } + + if (null === $nullableTypeIndex && Type::BUILTIN_TYPE_NULL === $type->getBuiltinType()) { + $nullableTypeIndex = $k; + } + + if (null !== $firstTypeIndex && null !== $nullableTypeIndex) { + break; + } + } + + if (null !== $firstTypeIndex && null !== $nullableTypeIndex) { + $firstType = $types[$firstTypeIndex]; + $types[$firstTypeIndex] = new Type( + $firstType->getBuiltinType(), + true, + $firstType->getClassName(), + $firstType->isCollection(), + $firstType->getCollectionKeyTypes(), + $firstType->getCollectionValueTypes() + ); + unset($types[$nullableTypeIndex]); + } + + return array_values($types); + } + + /** + * @return Type[] + */ + private function extractTypes(TypeNode $node, NameScope $nameScope): array + { + if ($node instanceof UnionTypeNode) { + $types = []; + foreach ($node->types as $type) { + foreach ($this->extractTypes($type, $nameScope) as $subType) { + $types[] = $subType; + } + } + + return $this->compressNullableType($types); + } + if ($node instanceof GenericTypeNode) { + $mainTypes = $this->extractTypes($node->type, $nameScope); + + $collectionKeyTypes = []; + $collectionKeyValues = []; + if (1 === \count($node->genericTypes)) { + foreach ($this->extractTypes($node->genericTypes[0], $nameScope) as $subType) { + $collectionKeyValues[] = $subType; + } + } elseif (2 === \count($node->genericTypes)) { + foreach ($this->extractTypes($node->genericTypes[0], $nameScope) as $keySubType) { + $collectionKeyTypes[] = $keySubType; + } + foreach ($this->extractTypes($node->genericTypes[1], $nameScope) as $valueSubType) { + $collectionKeyValues[] = $valueSubType; + } + } + + return [new Type($mainTypes[0]->getBuiltinType(), $mainTypes[0]->isNullable(), $mainTypes[0]->getClassName(), true, $collectionKeyTypes, $collectionKeyValues)]; + } + if ($node instanceof ArrayShapeNode) { + return [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true)]; + } + if ($node instanceof ArrayTypeNode) { + return [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [new Type(Type::BUILTIN_TYPE_INT)], $this->extractTypes($node->type, $nameScope))]; + } + if ($node instanceof CallableTypeNode || $node instanceof CallableTypeParameterNode) { + return [new Type(Type::BUILTIN_TYPE_CALLABLE)]; + } + if ($node instanceof NullableTypeNode) { + $subTypes = $this->extractTypes($node->type, $nameScope); + if (\count($subTypes) > 1) { + $subTypes[] = new Type(Type::BUILTIN_TYPE_NULL); + + return $subTypes; + } + + return [new Type($subTypes[0]->getBuiltinType(), true, $subTypes[0]->getClassName(), $subTypes[0]->isCollection(), $subTypes[0]->getCollectionKeyTypes(), $subTypes[0]->getCollectionValueTypes())]; + } + if ($node instanceof ThisTypeNode) { + return [new Type(Type::BUILTIN_TYPE_OBJECT, false, $nameScope->resolveRootClass())]; + } + if ($node instanceof IdentifierTypeNode) { + if (\in_array($node->name, Type::$builtinTypes)) { + return [new Type($node->name, false, null, \in_array($node->name, Type::$builtinCollectionTypes))]; + } + + switch ($node->name) { + case 'integer': + return [new Type(Type::BUILTIN_TYPE_INT)]; + case 'mixed': + return []; // mixed seems to be ignored in all other extractors + case 'parent': + return [new Type(Type::BUILTIN_TYPE_OBJECT, false, $node->name)]; + case 'static': + case 'self': + return [new Type(Type::BUILTIN_TYPE_OBJECT, false, $nameScope->resolveRootClass())]; + case 'void': + return [new Type(Type::BUILTIN_TYPE_NULL)]; + } + + return [new Type(Type::BUILTIN_TYPE_OBJECT, false, $nameScope->resolveStringName($node->name))]; + } + + return []; + } +} diff --git a/src/Symfony/Component/PropertyInfo/composer.json b/src/Symfony/Component/PropertyInfo/composer.json index b2b030730c929..09f5194f31bde 100644 --- a/src/Symfony/Component/PropertyInfo/composer.json +++ b/src/Symfony/Component/PropertyInfo/composer.json @@ -33,6 +33,7 @@ "symfony/cache": "^4.4|^5.0|^6.0", "symfony/dependency-injection": "^4.4|^5.0|^6.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "phpstan/phpdoc-parser": "^0.4", "doctrine/annotations": "^1.10.4" }, "conflict": {