Passed
Pull Request — master (#1449)
by
unknown
08:56 queued 06:42
created

DocBlockTypeResolver::getPhpstanType()   B

Complexity

Conditions 8
Paths 5

Size

Total Lines 29
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 18
nc 5
nop 3
dl 0
loc 29
rs 8.4444
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace JMS\Serializer\Metadata\Driver\DocBlockDriver;
6
7
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
8
use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode;
9
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
10
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
11
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
12
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
13
use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
14
use PHPStan\PhpDocParser\Lexer\Lexer;
15
use PHPStan\PhpDocParser\Parser\ConstExprParser;
16
use PHPStan\PhpDocParser\Parser\PhpDocParser;
17
use PHPStan\PhpDocParser\Parser\TokenIterator;
18
use PHPStan\PhpDocParser\Parser\TypeParser;
19
20
/**
21
 * @internal
22
 */
23
final class DocBlockTypeResolver
24
{
25
    /** resolve single use statements */
26
    private const SINGLE_USE_STATEMENTS_REGEX = '/^[^\S\r\n]*use[\s]*([^;\n]*)[\s]*;$/m';
27
    /** resolve group use statements */
28
    private const GROUP_USE_STATEMENTS_REGEX = '/^[^\S\r\n]*use[[\s]*([^;\n]*)[\s]*{([a-zA-Z0-9\s\n\r,]*)};$/m';
29
    private const GLOBAL_NAMESPACE_PREFIX = '\\';
30
    private const PHPSTAN_ARRAY_SHAPE = '/^([^\s]*) array{.*/m';
31
    private const PHPSTAN_ARRAY_TYPE = '/^([^\s]*) array<(.*)>/m';
32
33
    /**
34
     * @var PhpDocParser
35
     */
36
    protected $phpDocParser;
37
38
    /**
39
     * @var Lexer
40
     */
41
    protected $lexer;
42
43
    public function __construct()
44
    {
45
        $constExprParser = new ConstExprParser();
46
        $typeParser = new TypeParser($constExprParser);
47
48
        $this->phpDocParser = new PhpDocParser($typeParser, $constExprParser);
49
        $this->lexer = new Lexer();
50
    }
51
52
    /**
53
     * Attempts to retrieve additional type information from a PhpDoc block. Throws in case of ambiguous type
54
     * information and will return null if no helpful type information could be retrieved.
55
     *
56
     * @param \ReflectionProperty $reflectionProperty
57
     *
58
     * @return string|null
59
     */
60
    public function getPropertyDocblockTypeHint(\ReflectionProperty $reflectionProperty): ?string
61
    {
62
        if (!$reflectionProperty->getDocComment()) {
63
            return null;
64
        }
65
66
        // First we tokenize the PhpDoc comment and parse the tokens into a PhpDocNode.
67
        $tokens = $this->lexer->tokenize($reflectionProperty->getDocComment());
68
        $phpDocNode = $this->phpDocParser->parse(new TokenIterator($tokens));
69
70
        // Then we retrieve a flattened list of annotated types excluding null.
71
        $varTagValues = $phpDocNode->getVarTagValues();
72
        $types = $this->flattenVarTagValueTypes($varTagValues);
73
        $typesWithoutNull = $this->filterNullFromTypes($types);
74
75
        // The PhpDoc does not contain additional type information.
76
        if (0 === count($typesWithoutNull)) {
77
            return null;
78
        }
79
80
        // The PhpDoc contains multiple non-null types which produces ambiguity when deserializing.
81
        if (count($typesWithoutNull) > 1) {
82
            return null;
83
        }
84
85
        // Only one type is left, so we only need to differentiate between arrays, generics and other types.
86
        $type = $typesWithoutNull[0];
87
88
        // Simple array without concrete type: array
89
        if ($this->isSimpleType($type, 'array') || $this->isSimpleType($type, 'list')) {
90
            return null;
91
        }
92
93
        // Normal array syntax: Product[] | \Foo\Bar\Product[]
94
        if ($type instanceof ArrayTypeNode) {
95
            $resolvedType = $this->resolveTypeFromTypeNode($type->type, $reflectionProperty);
96
97
            return 'array<' . $resolvedType . '>';
98
        }
99
100
        // Generic array syntax: array<Product> | array<\Foo\Bar\Product> | array<int,Product>
101
        if ($type instanceof GenericTypeNode) {
102
            if ($this->isSimpleType($type->type, 'array')) {
103
                $resolvedTypes = array_map(function (TypeNode $node) use ($reflectionProperty) {
104
                    return $this->resolveTypeFromTypeNode($node, $reflectionProperty);
105
                }, $type->genericTypes);
106
107
                return 'array<' . implode(',', $resolvedTypes) . '>';
108
            }
109
110
            if ($this->isSimpleType($type->type, 'list')) {
111
                $resolvedTypes = array_map(function (TypeNode $node) use ($reflectionProperty) {
112
                    return $this->resolveTypeFromTypeNode($node, $reflectionProperty);
113
                }, $type->genericTypes);
114
115
                return 'array<int, ' . implode(',', $resolvedTypes) . '>';
116
            }
117
118
            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()));
119
        }
120
121
        // Primitives and class names: Collection | \Foo\Bar\Product | string
122
        return $this->resolveTypeFromTypeNode($type, $reflectionProperty);
123
    }
124
125
    /**
126
     * Returns a flat list of types of the given var tags. Union types are flattened as well.
127
     *
128
     * @param VarTagValueNode[] $varTagValues
129
     *
130
     * @return TypeNode[]
131
     */
132
    private function flattenVarTagValueTypes(array $varTagValues): array
133
    {
134
        if ([] === $varTagValues) {
135
            return [];
136
        }
137
138
        return array_merge(...array_map(static function (VarTagValueNode $node) {
139
            if ($node->type instanceof UnionTypeNode) {
140
                return $node->type->types;
141
            }
142
143
            return [$node->type];
144
        }, $varTagValues));
145
    }
146
147
    /**
148
     * Filters the null type from the given types array. If no null type is found, the array is returned unchanged.
149
     *
150
     * @param TypeNode[] $types
151
     *
152
     * @return TypeNode[]
153
     */
154
    private function filterNullFromTypes(array $types): array
155
    {
156
        return array_values(array_filter(array_map(function (TypeNode $node) {
157
            return $this->isNullType($node) ? null : $node;
158
        }, $types)));
159
    }
160
161
    /**
162
     * Determines if the given type is a null type.
163
     *
164
     * @param TypeNode $typeNode
165
     *
166
     * @return bool
167
     */
168
    private function isNullType(TypeNode $typeNode): bool
169
    {
170
        return $this->isSimpleType($typeNode, 'null');
171
    }
172
173
    /**
174
     * Determines if the given node represents a simple type.
175
     *
176
     * @param TypeNode $typeNode
177
     * @param string $simpleType
178
     *
179
     * @return bool
180
     */
181
    private function isSimpleType(TypeNode $typeNode, string $simpleType): bool
182
    {
183
        return $typeNode instanceof IdentifierTypeNode && $typeNode->name === $simpleType;
184
    }
185
186
    /**
187
     * Attempts to resolve the fully qualified type from the given node. If the node is not suitable for type
188
     * retrieval, an exception is thrown.
189
     *
190
     * @param TypeNode $typeNode
191
     * @param \ReflectionProperty $reflectionProperty
192
     *
193
     * @return string
194
     *
195
     * @throws \InvalidArgumentException
196
     */
197
    private function resolveTypeFromTypeNode(TypeNode $typeNode, \ReflectionProperty $reflectionProperty): string
198
    {
199
        if (!($typeNode instanceof IdentifierTypeNode)) {
200
            throw new \InvalidArgumentException(sprintf("Can't use unsupported type %s for collection in %s:%s", (string) $typeNode, $reflectionProperty->getDeclaringClass()->getName(), $reflectionProperty->getName()));
201
        }
202
203
        return $this->resolveType($typeNode->name, $reflectionProperty);
204
    }
205
206
    private function expandClassNameUsingUseStatements(string $typeHint, \ReflectionClass $declaringClass, \ReflectionProperty $reflectionProperty): string
207
    {
208
        if ($this->isClassOrInterface($typeHint)) {
209
            return $typeHint;
210
        }
211
212
        $expandedClassName = $declaringClass->getNamespaceName() . '\\' . $typeHint;
213
        if ($this->isClassOrInterface($expandedClassName)) {
214
            return $expandedClassName;
215
        }
216
217
        $classContents = file_get_contents($declaringClass->getFileName());
218
        $foundUseStatements = $this->gatherGroupUseStatements($classContents);
219
        $foundUseStatements = array_merge($this->gatherSingleUseStatements($classContents), $foundUseStatements);
220
221
        foreach ($foundUseStatements as $statementClassName) {
222
            if ($alias = explode('as', $statementClassName)) {
223
                if (array_key_exists(1, $alias) && trim($alias[1]) === $typeHint) {
224
                    return trim($alias[0]);
225
                }
226
            }
227
228
            if ($this->endsWith($statementClassName, $typeHint)) {
229
                return $statementClassName;
230
            }
231
        }
232
233
        if ($declaringClass->getDocComment()) {
234
            $phpstanArrayType = $this->getPhpstanType($declaringClass, $typeHint, $reflectionProperty);
235
236
            if ($phpstanArrayType) {
237
                return $phpstanArrayType;
238
            }
239
        }
240
241
        throw new \InvalidArgumentException(sprintf("Can't use incorrect type %s for collection in %s:%s", $typeHint, $declaringClass->getName(), $reflectionProperty->getName()));
242
    }
243
244
    private function endsWith(string $statementClassToCheck, string $typeHintToSearchFor): bool
245
    {
246
        $typeHintToSearchFor = '\\' . $typeHintToSearchFor;
247
248
        return substr($statementClassToCheck, -strlen($typeHintToSearchFor)) === $typeHintToSearchFor;
249
    }
250
251
    private function isPrimitiveType(string $type): bool
252
    {
253
        return in_array($type, ['int', 'float', 'bool', 'string']);
254
    }
255
256
    private function hasGlobalNamespacePrefix(string $typeHint): bool
257
    {
258
        return self::GLOBAL_NAMESPACE_PREFIX === $typeHint[0];
259
    }
260
261
    private function gatherGroupUseStatements(string $classContents): array
262
    {
263
        $foundUseStatements = [];
264
        preg_match_all(self::GROUP_USE_STATEMENTS_REGEX, $classContents, $foundGroupUseStatements);
265
        for ($useStatementIndex = 0; $useStatementIndex < count($foundGroupUseStatements[0]); $useStatementIndex++) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
266
            foreach (explode(',', $foundGroupUseStatements[2][$useStatementIndex]) as $singleUseStatement) {
267
                $foundUseStatements[] = trim($foundGroupUseStatements[1][$useStatementIndex]) . trim($singleUseStatement);
268
            }
269
        }
270
271
        return $foundUseStatements;
272
    }
273
274
    private function gatherSingleUseStatements(string $classContents): array
275
    {
276
        $foundUseStatements = [];
277
        preg_match_all(self::SINGLE_USE_STATEMENTS_REGEX, $classContents, $foundSingleUseStatements);
278
        for ($useStatementIndex = 0; $useStatementIndex < count($foundSingleUseStatements[0]); $useStatementIndex++) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
279
            $foundUseStatements[] = trim($foundSingleUseStatements[1][$useStatementIndex]);
280
        }
281
282
        return $foundUseStatements;
283
    }
284
285
    private function getDeclaringClassOrTrait(\ReflectionProperty $reflectionProperty): \ReflectionClass
286
    {
287
        foreach ($reflectionProperty->getDeclaringClass()->getTraits() as $trait) {
288
            foreach ($trait->getProperties() as $traitProperty) {
289
                if ($traitProperty->getName() === $reflectionProperty->getName()) {
290
                    return $this->getDeclaringClassOrTrait($traitProperty);
291
                }
292
            }
293
        }
294
295
        return $reflectionProperty->getDeclaringClass();
296
    }
297
298
    private function resolveType(string $typeHint, \ReflectionProperty $reflectionProperty): string
299
    {
300
        if (!$this->hasGlobalNamespacePrefix($typeHint) && !$this->isPrimitiveType($typeHint)) {
301
            $typeHint = $this->expandClassNameUsingUseStatements($typeHint, $this->getDeclaringClassOrTrait($reflectionProperty), $reflectionProperty);
302
        }
303
304
        return ltrim($typeHint, '\\');
305
    }
306
307
    private function isClassOrInterface(string $typeHint): bool
308
    {
309
        return class_exists($typeHint) || interface_exists($typeHint);
310
    }
311
312
    private function getPhpstanType(\ReflectionClass $declaringClass, string $typeHint, \ReflectionProperty $reflectionProperty): ?string
313
    {
314
        $tokens = $this->lexer->tokenize($declaringClass->getDocComment());
315
        $phpDocNode = $this->phpDocParser->parse(new TokenIterator($tokens));
316
        $self = $this;
317
318
        foreach ($phpDocNode->children as $node) {
319
            if ($node instanceof PhpDocTagNode && '@phpstan-type' === $node->name) {
320
                $phpstanType = $node->value->value;
0 ignored issues
show
Bug introduced by
Accessing value on the interface PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagValueNode suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
321
                preg_match_all(self::PHPSTAN_ARRAY_SHAPE, $phpstanType, $foundPhpstanArray);
322
                if (isset($foundPhpstanArray[1][0]) && $foundPhpstanArray[1][0] === $typeHint) {
323
                    return 'array';
324
                }
325
326
                preg_match_all(self::PHPSTAN_ARRAY_TYPE, $phpstanType, $foundPhpstanArray);
327
                if (isset($foundPhpstanArray[2][0]) && $foundPhpstanArray[1][0] === $typeHint) {
328
                    $types = explode(',', $foundPhpstanArray[2][0]);
329
330
                    return sprintf('array<%s>', implode(
331
                        ',',
332
                        array_map(static function (string $type) use ($reflectionProperty, $self) {
333
                            return $self->resolveType(trim($type), $reflectionProperty);
334
                        }, $types)
335
                    ));
336
                }
337
            }
338
        }
339
340
        return null;
341
    }
342
}
343