Passed
Pull Request — master (#1487)
by Martin Poirier
03:02
created

getPropertyDocblockTypeHint()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
rs 10
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\ParamTagValueNode;
8
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
9
use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode;
10
use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode;
11
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
12
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
13
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
14
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
15
use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
16
use PHPStan\PhpDocParser\Lexer\Lexer;
17
use PHPStan\PhpDocParser\Parser\ConstExprParser;
18
use PHPStan\PhpDocParser\Parser\PhpDocParser;
19
use PHPStan\PhpDocParser\Parser\TokenIterator;
20
use PHPStan\PhpDocParser\Parser\TypeParser;
21
22
/**
23
 * @internal
24
 */
25
final class DocBlockTypeResolver
26
{
27
    /** resolve single use statements */
28
    private const SINGLE_USE_STATEMENTS_REGEX = '/^[^\S\r\n]*use[\s]*([^;\n]*)[\s]*;$/m';
29
    /** resolve group use statements */
30
    private const GROUP_USE_STATEMENTS_REGEX = '/^[^\S\r\n]*use[[\s]*([^;\n]*)[\s]*{([a-zA-Z0-9\s\n\r,]*)};$/m';
31
    private const GLOBAL_NAMESPACE_PREFIX = '\\';
32
    private const PHPSTAN_ARRAY_SHAPE = '/^([^\s]*) array{.*/m';
33
    private const PHPSTAN_ARRAY_TYPE = '/^([^\s]*) array<(.*)>/m';
34
35
    /**
36
     * @var PhpDocParser
37
     */
38
    protected $phpDocParser;
39
40
    /**
41
     * @var Lexer
42
     */
43
    protected $lexer;
44
45
    public function __construct()
46
    {
47
        $constExprParser = new ConstExprParser();
48
        $typeParser = new TypeParser($constExprParser);
49
50
        $this->phpDocParser = new PhpDocParser($typeParser, $constExprParser);
51
        $this->lexer = new Lexer();
52
    }
53
54
    /**
55
     * Attempts to retrieve additional type information from a PhpDoc block. Throws in case of ambiguous type
56
     * information and will return null if no helpful type information could be retrieved.
57
     *
58
     * @param \ReflectionMethod $reflectionMethod
59
     *
60
     * @return string|null
61
     */
62
    public function getMethodDocblockTypeHint(\ReflectionMethod $reflectionMethod): ?string
63
    {
64
        return $this->getDocBlocTypeHint($reflectionMethod);
65
    }
66
67
    /**
68
     * Attempts to retrieve additional type information from a PhpDoc block. Throws in case of ambiguous type
69
     * information and will return null if no helpful type information could be retrieved.
70
     *
71
     * @param \ReflectionProperty $reflectionProperty
72
     *
73
     * @return string|null
74
     */
75
    public function getPropertyDocblockTypeHint(\ReflectionProperty $reflectionProperty): ?string
76
    {
77
        return $this->getDocBlocTypeHint($reflectionProperty);
78
    }
79
80
    /**
81
     * @param \ReflectionMethod|\ReflectionProperty $reflector
82
     *
83
     * @return string|null
84
     */
85
    private function getDocBlocTypeHint($reflector): ?string
86
    {
87
        $types = $this->resolveTypeFromDocblock($reflector);
88
89
        // The PhpDoc does not contain additional type information.
90
        if (0 === count($types)) {
91
            return null;
92
        }
93
94
        // The PhpDoc contains multiple non-null types which produces ambiguity when deserializing.
95
        if (count($types) > 1) {
96
            return null;
97
        }
98
99
        // Only one type is left, so we only need to differentiate between arrays, generics and other types.
100
        $type = $types[0];
101
102
        // Simple array without concrete type: array
103
        if ($this->isSimpleType($type, 'array') || $this->isSimpleType($type, 'list')) {
104
            return null;
105
        }
106
107
        // Normal array syntax: Product[] | \Foo\Bar\Product[]
108
        if ($type instanceof ArrayTypeNode) {
109
            $resolvedType = $this->resolveTypeFromTypeNode($type->type, $reflector);
110
111
            return 'array<' . $resolvedType . '>';
112
        }
113
114
        // Generic array syntax: array<Product> | array<\Foo\Bar\Product> | array<int,Product>
115
        if ($type instanceof GenericTypeNode) {
116
            if ($this->isSimpleType($type->type, 'array')) {
117
                $resolvedTypes = array_map(function (TypeNode $node) use ($reflector) {
118
                    return $this->resolveTypeFromTypeNode($node, $reflector);
119
                }, $type->genericTypes);
120
121
                return 'array<' . implode(',', $resolvedTypes) . '>';
122
            }
123
124
            if ($this->isSimpleType($type->type, 'list')) {
125
                $resolvedTypes = array_map(function (TypeNode $node) use ($reflector) {
126
                    return $this->resolveTypeFromTypeNode($node, $reflector);
127
                }, $type->genericTypes);
128
129
                return 'array<int, ' . implode(',', $resolvedTypes) . '>';
130
            }
131
132
            throw new \InvalidArgumentException(sprintf("Can't use non-array generic type %s for collection in %s:%s", (string) $type->type, $reflector->getDeclaringClass()->getName(), $reflector->getName()));
133
        }
134
135
        // Primitives and class names: Collection | \Foo\Bar\Product | string
136
        return $this->resolveTypeFromTypeNode($type, $reflector);
137
    }
138
139
    /**
140
     * Returns a flat list of types of the given var tags. Union types are flattened as well.
141
     *
142
     * @param ReturnTagValueNode[]|VarTagValueNode[] $tagValues
143
     *
144
     * @return TypeNode[]
145
     */
146
    private function flattenTagValueTypes(array $tagValues): array
147
    {
148
        if ([] === $tagValues) {
149
            return [];
150
        }
151
152
        return array_merge(...array_map(static function ($node) {
153
            if ($node->type instanceof UnionTypeNode) {
154
                return $node->type->types;
155
            }
156
157
            return [$node->type];
158
        }, $tagValues));
159
    }
160
161
    /**
162
     * Returns a flat list of types of the given param tags. Union types are flattened as well.
163
     *
164
     * @param ParamTagValueNode[] $varTagValues
165
     *
166
     * @return TypeNode[]
167
     */
168
    private function flattenParamTagValueTypes(string $parameterName, array $varTagValues): array
169
    {
170
        if ([] === $varTagValues) {
171
            return [];
172
        }
173
174
        $parameterName = sprintf('$%s', $parameterName);
175
        $types = [];
176
        foreach ($varTagValues as $node) {
177
            if ($parameterName !== $node->parameterName) {
178
                continue;
179
            }
180
181
            $types[] = $node->type;
182
        }
183
184
        return $types;
185
    }
186
187
    /**
188
     * Filters the null type from the given types array. If no null type is found, the array is returned unchanged.
189
     *
190
     * @param TypeNode[] $types
191
     *
192
     * @return TypeNode[]
193
     */
194
    private function filterNullFromTypes(array $types): array
195
    {
196
        return array_values(array_filter(array_map(function (TypeNode $node) {
197
            return $this->isNullType($node) ? null : $node;
198
        }, $types)));
199
    }
200
201
    /**
202
     * Determines if the given type is a null type.
203
     *
204
     * @param TypeNode $typeNode
205
     *
206
     * @return bool
207
     */
208
    private function isNullType(TypeNode $typeNode): bool
209
    {
210
        return $this->isSimpleType($typeNode, 'null');
211
    }
212
213
    /**
214
     * Determines if the given node represents a simple type.
215
     *
216
     * @param TypeNode $typeNode
217
     * @param string $simpleType
218
     *
219
     * @return bool
220
     */
221
    private function isSimpleType(TypeNode $typeNode, string $simpleType): bool
222
    {
223
        return $typeNode instanceof IdentifierTypeNode && $typeNode->name === $simpleType;
224
    }
225
226
    /**
227
     * Attempts to resolve the fully qualified type from the given node. If the node is not suitable for type
228
     * retrieval, an exception is thrown.
229
     *
230
     * @param TypeNode $typeNode
231
     * @param \ReflectionMethod|\ReflectionProperty $reflector
232
     *
233
     * @return string
234
     *
235
     * @throws \InvalidArgumentException
236
     */
237
    private function resolveTypeFromTypeNode(TypeNode $typeNode, $reflector): string
238
    {
239
        if (!($typeNode instanceof IdentifierTypeNode)) {
240
            throw new \InvalidArgumentException(sprintf("Can't use unsupported type %s for collection in %s:%s", (string) $typeNode, $reflector->getDeclaringClass()->getName(), $reflector->getName()));
241
        }
242
243
        return $this->resolveType($typeNode->name, $reflector);
244
    }
245
246
    /**
247
     * @param \ReflectionMethod|\ReflectionProperty $reflector
248
     */
249
    private function expandClassNameUsingUseStatements(string $typeHint, \ReflectionClass $declaringClass, $reflector): string
250
    {
251
        if ($this->isClassOrInterface($typeHint)) {
252
            return $typeHint;
253
        }
254
255
        $expandedClassName = $declaringClass->getNamespaceName() . '\\' . $typeHint;
256
        if ($this->isClassOrInterface($expandedClassName)) {
257
            return $expandedClassName;
258
        }
259
260
        $classContents = file_get_contents($declaringClass->getFileName());
261
        $foundUseStatements = $this->gatherGroupUseStatements($classContents);
262
        $foundUseStatements = array_merge($this->gatherSingleUseStatements($classContents), $foundUseStatements);
263
264
        foreach ($foundUseStatements as $statementClassName) {
265
            if ($alias = explode('as', $statementClassName)) {
266
                if (array_key_exists(1, $alias) && trim($alias[1]) === $typeHint) {
267
                    return trim($alias[0]);
268
                }
269
            }
270
271
            if ($this->endsWith($statementClassName, $typeHint)) {
272
                return $statementClassName;
273
            }
274
        }
275
276
        if ($declaringClass->getDocComment()) {
277
            $phpstanArrayType = $this->getPhpstanType($declaringClass, $typeHint, $reflector);
278
279
            if ($phpstanArrayType) {
280
                return $phpstanArrayType;
281
            }
282
        }
283
284
        throw new \InvalidArgumentException(sprintf("Can't use incorrect type %s for collection in %s:%s", $typeHint, $declaringClass->getName(), $reflector->getName()));
285
    }
286
287
    private function endsWith(string $statementClassToCheck, string $typeHintToSearchFor): bool
288
    {
289
        $typeHintToSearchFor = '\\' . $typeHintToSearchFor;
290
291
        return substr($statementClassToCheck, -strlen($typeHintToSearchFor)) === $typeHintToSearchFor;
292
    }
293
294
    private function isPrimitiveType(string $type): bool
295
    {
296
        return in_array($type, ['int', 'float', 'bool', 'string']);
297
    }
298
299
    private function hasGlobalNamespacePrefix(string $typeHint): bool
300
    {
301
        return self::GLOBAL_NAMESPACE_PREFIX === $typeHint[0];
302
    }
303
304
    private function gatherGroupUseStatements(string $classContents): array
305
    {
306
        $foundUseStatements = [];
307
        preg_match_all(self::GROUP_USE_STATEMENTS_REGEX, $classContents, $foundGroupUseStatements);
308
        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...
309
            foreach (explode(',', $foundGroupUseStatements[2][$useStatementIndex]) as $singleUseStatement) {
310
                $foundUseStatements[] = trim($foundGroupUseStatements[1][$useStatementIndex]) . trim($singleUseStatement);
311
            }
312
        }
313
314
        return $foundUseStatements;
315
    }
316
317
    private function gatherSingleUseStatements(string $classContents): array
318
    {
319
        $foundUseStatements = [];
320
        preg_match_all(self::SINGLE_USE_STATEMENTS_REGEX, $classContents, $foundSingleUseStatements);
321
        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...
322
            $foundUseStatements[] = trim($foundSingleUseStatements[1][$useStatementIndex]);
323
        }
324
325
        return $foundUseStatements;
326
    }
327
328
    /**
329
     * @param \ReflectionMethod|\ReflectionProperty $reflector
330
     */
331
    private function getDeclaringClassOrTrait($reflector): \ReflectionClass
332
    {
333
        foreach ($reflector->getDeclaringClass()->getTraits() as $trait) {
334
            foreach ($trait->getProperties() as $traitProperty) {
335
                if ($traitProperty->getName() === $reflector->getName()) {
336
                    return $this->getDeclaringClassOrTrait($traitProperty);
337
                }
338
            }
339
        }
340
341
        return $reflector->getDeclaringClass();
342
    }
343
344
    /**
345
     * @param \ReflectionMethod|\ReflectionProperty $reflector
346
     */
347
    private function resolveType(string $typeHint, $reflector): string
348
    {
349
        if (!$this->hasGlobalNamespacePrefix($typeHint) && !$this->isPrimitiveType($typeHint)) {
350
            $typeHint = $this->expandClassNameUsingUseStatements($typeHint, $this->getDeclaringClassOrTrait($reflector), $reflector);
351
        }
352
353
        return ltrim($typeHint, '\\');
354
    }
355
356
    private function isClassOrInterface(string $typeHint): bool
357
    {
358
        return class_exists($typeHint) || interface_exists($typeHint);
359
    }
360
361
    /**
362
     * @param \ReflectionMethod|\ReflectionProperty $reflector
363
     */
364
    private function resolveTypeFromDocblock($reflector): array
365
    {
366
        $docComment = $reflector->getDocComment();
367
        if (!$docComment && PHP_VERSION_ID >= 80000 && $reflector instanceof \ReflectionProperty && $reflector->isPromoted()) {
368
            $constructor = $reflector->getDeclaringClass()->getConstructor();
369
            if (!$constructor) {
370
                return [];
371
            }
372
373
            $docComment = $constructor->getDocComment();
374
375
            if (!$docComment) {
376
                return [];
377
            }
378
379
            $tokens = $this->lexer->tokenize($docComment);
380
            $phpDocNode = $this->phpDocParser->parse(new TokenIterator($tokens));
381
382
            return $this->flattenParamTagValueTypes($reflector->getName(), $phpDocNode->getParamTagValues());
383
        }
384
385
        if (!$docComment) {
386
            return [];
387
        }
388
389
        // First we tokenize the PhpDoc comment and parse the tokens into a PhpDocNode.
390
        $tokens = $this->lexer->tokenize($docComment);
391
        $phpDocNode = $this->phpDocParser->parse(new TokenIterator($tokens));
392
393
        if ($reflector instanceof \ReflectionProperty) {
394
            // Then we retrieve a flattened list of annotated types excluding null.
395
            $tagValues = $phpDocNode->getVarTagValues();
396
        } else {
397
            // Then we retrieve a flattened list of annotated types including null.
398
            $tagValues = $phpDocNode->getReturnTagValues();
399
        }
400
401
        $types = $this->flattenTagValueTypes($tagValues);
402
403
        return $this->filterNullFromTypes($types);
404
    }
405
406
    /**
407
     * @param \ReflectionMethod|\ReflectionProperty $reflector
408
     */
409
    private function getPhpstanType(\ReflectionClass $declaringClass, string $typeHint, $reflector): ?string
410
    {
411
        $tokens = $this->lexer->tokenize($declaringClass->getDocComment());
412
        $phpDocNode = $this->phpDocParser->parse(new TokenIterator($tokens));
413
        $self = $this;
414
415
        foreach ($phpDocNode->children as $node) {
416
            if ($node instanceof PhpDocTagNode && '@phpstan-type' === $node->name) {
417
                $phpstanType = (string) $node->value;
418
                preg_match_all(self::PHPSTAN_ARRAY_SHAPE, $phpstanType, $foundPhpstanArray);
419
                if (isset($foundPhpstanArray[1][0]) && $foundPhpstanArray[1][0] === $typeHint) {
420
                    return 'array';
421
                }
422
423
                preg_match_all(self::PHPSTAN_ARRAY_TYPE, $phpstanType, $foundPhpstanArray);
424
                if (isset($foundPhpstanArray[2][0]) && $foundPhpstanArray[1][0] === $typeHint) {
425
                    $types = explode(',', $foundPhpstanArray[2][0]);
426
427
                    return sprintf('array<%s>', implode(
428
                        ',',
429
                        array_map(static function (string $type) use ($reflector, $self) {
430
                            return $self->resolveType(trim($type), $reflector);
431
                        }, $types)
432
                    ));
433
                }
434
            }
435
        }
436
437
        return null;
438
    }
439
}
440