8000 [PropertyInfo] Add `PhpStanExtractor` by Korbeil · Pull Request #40457 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[PropertyInfo] Add PhpStanExtractor #40457

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Oct 30, 2021
Merged
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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
Expand Down
5 changes: 5 additions & 0 deletions src/Symfony/Component/PropertyInfo/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
CHANGELOG
=========

5.4
---

* Add PhpStanExtractor

5.3
---

Expand Down
262 changes: 262 additions & 0 deletions src/Symfony/Component/PropertyInfo/Extractor/PhpStanExtractor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <baptiste.leduc@gmail.com>
*/
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];
}
}
64 changes: 64 additions & 0 deletions src/Symfony/Component/PropertyInfo/PhpStan/NameScope.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <baptiste.leduc@gmail.com>
*
* @internal
*/
final class NameScope
{
private $className;
private $namespace;
/** @var array<string, string> 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);
}
}
Loading
0