Completed
Push — master ( c6e1c9...723241 )
by Asmir
14s queued 13s
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\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)) {
0 ignored issues
show
Bug introduced by
It seems like $types can also be of type null; however, parameter $value of count() does only seem to accept Countable|array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

66
        if (0 === count(/** @scrutinizer ignore-type */ $types)) {
Loading history...
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
            $docComment = $reflectionProperty->getDeclaringClass()->getConstructor()->getDocComment();
333
334
            $tokens = $this->lexer->tokenize($docComment);
335
            $phpDocNode = $this->phpDocParser->parse(new TokenIterator($tokens));
336
337
            return $this->flattenParamTagValueTypes($reflectionProperty->getName(), $phpDocNode->getParamTagValues());
338
        }
339
340
        if (!$docComment) {
341
            return null;
342
        }
343
344
        // First we tokenize the PhpDoc comment and parse the tokens into a PhpDocNode.
345
        $tokens = $this->lexer->tokenize($docComment);
346
        $phpDocNode = $this->phpDocParser->parse(new TokenIterator($tokens));
347
348
        // Then we retrieve a flattened list of annotated types excluding null.
349
        $varTagValues = $phpDocNode->getVarTagValues();
350
        $types = $this->flattenVarTagValueTypes($varTagValues);
351
352
        return $this->filterNullFromTypes($types);
353
    }
354
355
    private function getPhpstanType(\ReflectionClass $declaringClass, string $typeHint, \ReflectionProperty $reflectionProperty): ?string
356
    {
357
        $tokens = $this->lexer->tokenize($declaringClass->getDocComment());
358
        $phpDocNode = $this->phpDocParser->parse(new TokenIterator($tokens));
359
        $self = $this;
360
361
        foreach ($phpDocNode->children as $node) {
362
            if ($node instanceof PhpDocTagNode && '@phpstan-type' === $node->name) {
363
                $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...
364
                preg_match_all(self::PHPSTAN_ARRAY_SHAPE, $phpstanType, $foundPhpstanArray);
365
                if (isset($foundPhpstanArray[1][0]) && $foundPhpstanArray[1][0] === $typeHint) {
366
                    return 'array';
367
                }
368
369
                preg_match_all(self::PHPSTAN_ARRAY_TYPE, $phpstanType, $foundPhpstanArray);
370
                if (isset($foundPhpstanArray[2][0]) && $foundPhpstanArray[1][0] === $typeHint) {
371
                    $types = explode(',', $foundPhpstanArray[2][0]);
372
373
                    return sprintf('array<%s>', implode(
374
                        ',',
375
                        array_map(static function (string $type) use ($reflectionProperty, $self) {
376
                            return $self->resolveType(trim($type), $reflectionProperty);
377
                        }, $types)
378
                    ));
379
                }
380
            }
381
        }
382
383
        return null;
384
    }
385
}
386