Passed
Pull Request — master (#1552)
by
unknown
02:44
created

DocBlockTypeResolver::getMethodDocblockTypeHint()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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