Passed
Pull Request — master (#1462)
by
unknown
02:24
created

DocBlockTypeResolver::resolveTypeFromDocblock()   B

Complexity

Conditions 7
Paths 5

Size

Total Lines 34
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

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