Skip to content
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

Use phpstan/phpdoc-parser to retrieve additional type information from PhpDoc #1261

Merged
merged 6 commits into from
Nov 8, 2020
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
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