Skip to content

Commit

Permalink
Merge pull request #1261 from Namoshek/feature-phpstan-phpdoc-parser
Browse files Browse the repository at this point in the history
Use phpstan/phpdoc-parser to retrieve additional type information from PhpDoc
  • Loading branch information
goetas authored Nov 8, 2020
2 parents 2b331ea + f80d4fb commit 0f2bcfd
Show file tree
Hide file tree
Showing 4 changed files with 182 additions and 32 deletions.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"doctrine/annotations": "^1.0",
"doctrine/instantiator": "^1.0.3",
"doctrine/lexer": "^1.1",
"jms/metadata": "^2.0"
"jms/metadata": "^2.0",
"phpstan/phpdoc-parser": "^0.4"
},
"suggest": {
"doctrine/cache": "Required if you like to use cache functionality.",
Expand Down
185 changes: 154 additions & 31 deletions src/Metadata/Driver/DocBlockTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,61 +4,189 @@

namespace JMS\Serializer\Metadata\Driver;

use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode;
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
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;

class DocBlockTypeResolver
{
/** resolve type hints from property */
private const CLASS_PROPERTY_TYPE_HINT_REGEX = '#@var[\s]*([^\n\$]*)#';
/** resolve single use statements */
private const SINGLE_USE_STATEMENTS_REGEX = '/^[^\S\r\n]*use[\s]*([^;\n]*)[\s]*;$/m';
/** resolve group use statements */
private const GROUP_USE_STATEMENTS_REGEX = '/^[^\S\r\n]*use[[\s]*([^;\n]*)[\s]*{([a-zA-Z0-9\s\n\r,]*)};$/m';
private const GLOBAL_NAMESPACE_PREFIX = '\\';

/**
* @var PhpDocParser
*/
protected $phpDocParser;

/**
* @var Lexer
*/
protected $lexer;

public function __construct()
{
$constExprParser = new ConstExprParser();
$typeParser = new TypeParser($constExprParser);

$this->phpDocParser = new PhpDocParser($typeParser, $constExprParser);
$this->lexer = new Lexer();
}

/**
* Attempts to retrieve additional type information from a PhpDoc block. Throws in case of ambiguous type
* information and will return null if no helpful type information could be retrieved.
*
* @param \ReflectionProperty $reflectionProperty
*
* @return string|null
*/
public function getPropertyDocblockTypeHint(\ReflectionProperty $reflectionProperty): ?string
{
if (!$reflectionProperty->getDocComment()) {
return null;
}

preg_match_all(self::CLASS_PROPERTY_TYPE_HINT_REGEX, $reflectionProperty->getDocComment(), $matchedDocBlockParameterTypes);
if (!isset($matchedDocBlockParameterTypes[1][0])) {
// First we tokenize the PhpDoc comment and parse the tokens into a PhpDocNode.
$tokens = $this->lexer->tokenize($reflectionProperty->getDocComment());
$phpDocNode = $this->phpDocParser->parse(new TokenIterator($tokens));

// Then we retrieve a flattened list of annotated types excluding null.
$varTagValues = $phpDocNode->getVarTagValues();
$types = $this->flattenVarTagValueTypes($varTagValues);
$typesWithoutNull = $this->filterNullFromTypes($types);

// The PhpDoc does not contain additional type information.
if (0 === count($typesWithoutNull)) {
return null;
}

$typeHint = trim($matchedDocBlockParameterTypes[1][0]);
if ($this->isArrayWithoutAnyType($typeHint)) {
return null;
// The PhpDoc contains multiple non-null types which produces ambiguity when deserializing.
if (count($typesWithoutNull) > 1) {
$typeHint = implode('|', array_map(static function (TypeNode $type) {
return (string) $type;
}, $types));

throw new \InvalidArgumentException(sprintf("Can't use union type %s for collection in %s:%s", $typeHint, $reflectionProperty->getDeclaringClass()->getName(), $reflectionProperty->getName()));
}

$unionTypeHint = [];
foreach (explode('|', $typeHint) as $singleTypeHint) {
if ('null' !== $singleTypeHint) {
$unionTypeHint[] = $singleTypeHint;
}
// Only one type is left, so we only need to differentiate between arrays, generics and other types.
$type = $typesWithoutNull[0];

// Simple array without concrete type: array
if ($this->isSimpleType($type, 'array')) {
return null;
}

$typeHint = implode('|', $unionTypeHint);
if (count($unionTypeHint) > 1) {
throw new \InvalidArgumentException(sprintf("Can't use union type %s for collection in %s:%s", $typeHint, $reflectionProperty->getDeclaringClass()->getName(), $reflectionProperty->getName()));
// Normal array syntax: Product[] | \Foo\Bar\Product[]
if ($type instanceof ArrayTypeNode) {
$resolvedType = $this->resolveTypeFromTypeNode($type->type, $reflectionProperty);

return 'array<' . $resolvedType . '>';
}

if (false !== strpos($typeHint, 'array<')) {
$resolvedTypes = [];
preg_match_all('#array<(.*)>#', $typeHint, $genericTypesToResolve);
$genericTypesToResolve = $genericTypesToResolve[1][0];
foreach (explode(',', $genericTypesToResolve) as $genericTypeToResolve) {
$resolvedTypes[] = $this->resolveType(trim($genericTypeToResolve), $reflectionProperty);
// Generic array syntax: array<Product> | array<\Foo\Bar\Product> | array<int,Product>
if ($type instanceof GenericTypeNode) {
if (!$this->isSimpleType($type->type, 'array')) {
throw new \InvalidArgumentException(sprintf("Can't use non-array generic type %s for collection in %s:%s", (string) $type->type, $reflectionProperty->getDeclaringClass()->getName(), $reflectionProperty->getName()));
}

$resolvedTypes = array_map(function (TypeNode $node) use ($reflectionProperty) {
return $this->resolveTypeFromTypeNode($node, $reflectionProperty);
}, $type->genericTypes);

return 'array<' . implode(',', $resolvedTypes) . '>';
} elseif (false !== strpos($typeHint, '[]')) {
$typeHint = rtrim($typeHint, '[]');
$typeHint = $this->resolveType($typeHint, $reflectionProperty);
}

return 'array<' . $typeHint . '>';
// Primitives and class names: Collection | \Foo\Bar\Product | string
return $this->resolveTypeFromTypeNode($type, $reflectionProperty);
}

/**
* Returns a flat list of types of the given var tags. Union types are flattened as well.
*
* @param VarTagValueNode[] $varTagValues
*
* @return TypeNode[]
*/
private function flattenVarTagValueTypes(array $varTagValues): array
{
return array_merge(...array_map(static function (VarTagValueNode $node) {
if ($node->type instanceof UnionTypeNode) {
return $node->type->types;
}

return [$node->type];
}, $varTagValues));
}

/**
* Filters the null type from the given types array. If no null type is found, the array is returned unchanged.
*
* @param TypeNode[] $types
*
* @return TypeNode[]
*/
private function filterNullFromTypes(array $types): array
{
return array_filter(array_map(function (TypeNode $node) {
return $this->isNullType($node) ? null : $node;
}, $types));
}

/**
* Determines if the given type is a null type.
*
* @param TypeNode $typeNode
*
* @return bool
*/
private function isNullType(TypeNode $typeNode): bool
{
return $this->isSimpleType($typeNode, 'null');
}

/**
* Determines if the given node represents a simple type.
*
* @param TypeNode $typeNode
* @param string $simpleType
*
* @return bool
*/
private function isSimpleType(TypeNode $typeNode, string $simpleType): bool
{
return $typeNode instanceof IdentifierTypeNode && $typeNode->name === $simpleType;
}

/**
* Attempts to resolve the fully qualified type from the given node. If the node is not suitable for type
* retrieval, an exception is thrown.
*
* @param TypeNode $typeNode
* @param \ReflectionProperty $reflectionProperty
*
* @return string
*
* @throws \InvalidArgumentException
*/
private function resolveTypeFromTypeNode(TypeNode $typeNode, \ReflectionProperty $reflectionProperty): string
{
if (!($typeNode instanceof IdentifierTypeNode)) {
throw new \InvalidArgumentException(sprintf("Can't use unsupported type %s for collection in %s:%s", (string) $typeNode, $reflectionProperty->getDeclaringClass()->getName(), $reflectionProperty->getName()));
}

return $this->resolveType($typeHint, $reflectionProperty);
return $this->resolveType($typeNode->name, $reflectionProperty);
}

private function expandClassNameUsingUseStatements(string $typeHint, \ReflectionClass $declaringClass, \ReflectionProperty $reflectionProperty): string
Expand Down Expand Up @@ -154,11 +282,6 @@ private function resolveType(string $typeHint, \ReflectionProperty $reflectionPr
return ltrim($typeHint, '\\');
}

private function isArrayWithoutAnyType(string $typeHint): bool
{
return 'array' === $typeHint;
}

private function isClassOrInterface(string $typeHint): bool
{
return class_exists($typeHint) || interface_exists($typeHint);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace JMS\Serializer\Tests\Fixtures\DocBlockType\Collection;

class CollectionOfClassesWithNullSingleLinePhpDoc
{
/** @var Product[]|null */
public ?array $productIds;
}
15 changes: 15 additions & 0 deletions tests/Metadata/Driver/DocBlockDriverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use JMS\Serializer\Tests\Fixtures\DocBlockType\Collection\CollectionOfClassesFromTraitInsideTrait;
use JMS\Serializer\Tests\Fixtures\DocBlockType\Collection\CollectionOfClassesWithFullNamespacePath;
use JMS\Serializer\Tests\Fixtures\DocBlockType\Collection\CollectionOfClassesWithNull;
use JMS\Serializer\Tests\Fixtures\DocBlockType\Collection\CollectionOfClassesWithNullSingleLinePhpDoc;
use JMS\Serializer\Tests\Fixtures\DocBlockType\Collection\CollectionOfInterfacesFromDifferentNamespace;
use JMS\Serializer\Tests\Fixtures\DocBlockType\Collection\CollectionOfInterfacesFromGlobalNamespace;
use JMS\Serializer\Tests\Fixtures\DocBlockType\Collection\CollectionOfInterfacesFromSameNamespace;
Expand Down Expand Up @@ -139,6 +140,20 @@ public function testInferDocBlockCollectionOfClassesIgnoringNullTypeHint()
);
}

public function testInferDocBlockCollectionOfClassesIgnoringNullTypeHintWithSingleLinePhpDoc()
{
if (PHP_VERSION_ID < 70400) {
$this->markTestSkipped(sprintf('%s requires PHP 7.4', TypedPropertiesDriver::class));
}

$m = $this->resolve(CollectionOfClassesWithNullSingleLinePhpDoc::class);

self::assertEquals(
['name' => 'array', 'params' => [['name' => Product::class, 'params' => []]]],
$m->propertyMetadata['productIds']->type
);
}

public function testThrowingExceptionWhenUnionTypeIsUsedForCollection()
{
if (PHP_VERSION_ID < 70400) {
Expand Down

0 comments on commit 0f2bcfd

Please sign in to comment.