TypeResolver   F
last analyzed

Complexity

Total Complexity 76

Size/Duplication

Total Lines 497
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 11

Test Coverage

Coverage 86.5%

Importance

Changes 0
Metric Value
wmc 76
lcom 1
cbo 11
dl 0
loc 497
ccs 141
cts 163
cp 0.865
rs 2.32
c 0
b 0
f 0

13 Methods

Rating   Name   Duplication   Size   Complexity  
A resolve() 0 28 4
A isKeyword() 0 4 1
A isPartialStructuralElementName() 0 4 2
A isFqsen() 0 4 1
A __construct() 0 4 2
F parseTypes() 0 141 31
A resolveSingleType() 0 20 4
A addKeyword() 0 17 3
A resolveKeyword() 0 6 1
A resolveTypedObject() 0 4 1
A resolveClassString() 0 27 5
D resolveCollection() 0 79 20
A makeCollectionFromObject() 0 4 1

How to fix   Complexity   

Complex Class

Complex classes like TypeResolver often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use TypeResolver, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * This file is part of phpDocumentor.
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 *
11
 * @link      http://phpdoc.org
12
 */
13
14
namespace phpDocumentor\Reflection;
15
16
use ArrayIterator;
17
use InvalidArgumentException;
18
use phpDocumentor\Reflection\Types\Array_;
19
use phpDocumentor\Reflection\Types\ClassString;
20
use phpDocumentor\Reflection\Types\Collection;
21
use phpDocumentor\Reflection\Types\Compound;
22
use phpDocumentor\Reflection\Types\Context;
23
use phpDocumentor\Reflection\Types\Expression;
24
use phpDocumentor\Reflection\Types\Integer;
25
use phpDocumentor\Reflection\Types\Intersection;
26
use phpDocumentor\Reflection\Types\Iterable_;
27
use phpDocumentor\Reflection\Types\Nullable;
28
use phpDocumentor\Reflection\Types\Object_;
29
use phpDocumentor\Reflection\Types\String_;
30
use RuntimeException;
31
use function array_key_exists;
32
use function array_pop;
33
use function array_values;
34
use function class_exists;
35
use function class_implements;
36
use function count;
37
use function end;
38
use function in_array;
39
use function key;
40
use function preg_split;
41
use function strpos;
42
use function strtolower;
43
use function trim;
44
use const PREG_SPLIT_DELIM_CAPTURE;
45
use const PREG_SPLIT_NO_EMPTY;
46
47
final class TypeResolver
48
{
49
    /** @var string Definition of the ARRAY operator for types */
50
    private const OPERATOR_ARRAY = '[]';
51
52
    /** @var string Definition of the NAMESPACE operator in PHP */
53
    private const OPERATOR_NAMESPACE = '\\';
54
55
    /** @var int the iterator parser is inside a compound context */
56
    private const PARSER_IN_COMPOUND = 0;
57
58
    /** @var int the iterator parser is inside a nullable expression context */
59
    private const PARSER_IN_NULLABLE = 1;
60
61
    /** @var int the iterator parser is inside an array expression context */
62
    private const PARSER_IN_ARRAY_EXPRESSION = 2;
63
64
    /** @var int the iterator parser is inside a collection expression context */
65
    private const PARSER_IN_COLLECTION_EXPRESSION = 3;
66
67
    /**
68
     * @var array<string, string> List of recognized keywords and unto which Value Object they map
69
     * @psalm-var array<string, class-string<Type>>
70
     */
71
    private $keywords = [
72
        'string' => Types\String_::class,
73
        'class-string' => Types\ClassString::class,
74
        'int' => Types\Integer::class,
75
        'integer' => Types\Integer::class,
76
        'bool' => Types\Boolean::class,
77
        'boolean' => Types\Boolean::class,
78
        'real' => Types\Float_::class,
79
        'float' => Types\Float_::class,
80
        'double' => Types\Float_::class,
81
        'object' => Object_::class,
82
        'mixed' => Types\Mixed_::class,
83
        'array' => Array_::class,
84
        'resource' => Types\Resource_::class,
85
        'void' => Types\Void_::class,
86
        'null' => Types\Null_::class,
87
        'scalar' => Types\Scalar::class,
88
        'callback' => Types\Callable_::class,
89
        'callable' => Types\Callable_::class,
90
        'false' => Types\Boolean::class,
91
        'true' => Types\Boolean::class,
92
        'self' => Types\Self_::class,
93
        '$this' => Types\This::class,
94
        'static' => Types\Static_::class,
95
        'parent' => Types\Parent_::class,
96
        'iterable' => Iterable_::class,
97
    ];
98
99
    /**
100 54
     * @var FqsenResolver
101
     * @psalm-readonly
102 54
     */
103 54
    private $fqsenResolver;
104
105
    /**
106
     * Initializes this TypeResolver with the means to create and resolve Fqsen objects.
107
     */
108
    public function __construct(?FqsenResolver $fqsenResolver = null)
109
    {
110
        $this->fqsenResolver = $fqsenResolver ?: new FqsenResolver();
111
    }
112
113
    /**
114
     * Analyzes the given type and returns the FQCN variant.
115
     *
116
     * When a type is provided this method checks whether it is not a keyword or
117
     * Fully Qualified Class Name. If so it will use the given namespace and
118
     * aliases to expand the type to a FQCN representation.
119
     *
120
     * This method only works as expected if the namespace and aliases are set;
121 51
     * no dynamic reflection is being performed here.
122
     *
123 51
     * @uses Context::getNamespaceAliases() to check whether the first part of the relative type name should not be
124 51
     * replaced with another namespace.
125 1
     * @uses Context::getNamespace()        to determine with what to prefix the type name.
126
     *
127
     * @param string $type The relative or absolute type.
128 50
     */
129 1
    public function resolve(string $type, ?Context $context = null) : Type
130
    {
131
        $type = trim($type);
132
        if (!$type) {
133 50
            throw new InvalidArgumentException('Attempted to resolve "' . $type . '" but it appears to be empty');
134 50
        }
135 50
136 50
        if ($context === null) {
137 50
            $context = new Context('');
138
        }
139
140 50
        // split the type string into tokens `|`, `?`, `<`, `>`, `,`, `(`, `)`, `[]`, '<', '>' and type names
141
        $tokens = preg_split(
142
            '/(\\||\\?|<|>|&|, ?|\\(|\\)|\\[\\]+)/',
143
            $type,
144 50
            -1,
145
            PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE
146 50
        );
147
148
        if ($tokens === false) {
149
            throw new InvalidArgumentException('Unable to split the type string "' . $type . '" into tokens');
150
        }
151
152
        /** @var ArrayIterator<int, string|null> $tokenIterator */
153
        $tokenIterator = new ArrayIterator($tokens);
154
155
        return $this->parseTypes($tokenIterator, $context, self::PARSER_IN_COMPOUND);
156 50
    }
157
158 50
    /**
159 50
     * Analyse each tokens and creates types
160 50
     *
161 50
     * @param ArrayIterator<int, string|null> $tokens        the iterator on tokens
162
     * @param int                        $parserContext on of self::PARSER_* constants, indicating
163 50
     * the context where we are in the parsing
164 14
     */
165
    private function parseTypes(ArrayIterator $tokens, Context $context, int $parserContext) : Type
166
    {
167
        $types = [];
168
        $token = '';
169
        $compoundToken = '|';
170 14
        while ($tokens->valid()) {
171 14
            $token = $tokens->current();
172 14
            if ($token === null) {
173
                throw new RuntimeException(
174
                    'Unexpected nullable character'
175
                );
176
            }
177
178
            if ($token === '|' || $token === '&') {
179 14
                if (count($types) === 0) {
180 50
                    throw new RuntimeException(
181 2
                        'A type is missing before a type separator'
182 2
                    );
183 2
                }
184
185
                if (!in_array($parserContext, [
186
                    self::PARSER_IN_COMPOUND,
187
                    self::PARSER_IN_ARRAY_EXPRESSION,
188
                    self::PARSER_IN_COLLECTION_EXPRESSION,
189
                ], true)
190 2
                ) {
191 2
                    throw new RuntimeException(
192 2
                        'Unexpected type separator'
193 50
                    );
194 5
                }
195 5
196
                $compoundToken = $token;
197 5
                $tokens->next();
198
            } elseif ($token === '?') {
199 5
                if (!in_array($parserContext, [
200
                    self::PARSER_IN_COMPOUND,
201 5
                    self::PARSER_IN_ARRAY_EXPRESSION,
202 1
                    self::PARSER_IN_COLLECTION_EXPRESSION,
203
                ], true)
204
                ) {
205
                    throw new RuntimeException(
206 4
                        'Unexpected nullable character'
207 4
                    );
208 1
                }
209
210
                $tokens->next();
211 4
                $type    = $this->parseTypes($tokens, $context, self::PARSER_IN_NULLABLE);
212 4
                $types[] = new Nullable($type);
213 50
            } elseif ($token === '(') {
214 4
                $tokens->next();
215 50
                $type = $this->parseTypes($tokens, $context, self::PARSER_IN_ARRAY_EXPRESSION);
216 13
217 1
                $token = $tokens->current();
218 1
                if ($token === null) { // Someone did not properly close their array expression ..
219
                    break;
220
                }
221
222 12
                $tokens->next();
223 12
224 12
                $resolvedType = new Expression($type);
225
226
                $types[] = $resolvedType;
227 8
            } elseif ($parserContext === self::PARSER_IN_ARRAY_EXPRESSION && $token[0] === ')') {
228 49
                break;
229 49
            } elseif ($token === '<') {
230
                if (count($types) === 0) {
231 10
                    throw new RuntimeException(
232
                        'Unexpected collection operator "<", class name is missing'
233 49
                    );
234 49
                }
235 49
236 2
                $classType = array_pop($types);
237
                if ($classType !== null) {
238
                    if ((string) $classType === 'class-string') {
239 48
                        $types[] = $this->resolveClassString($tokens, $context);
240
                    } else {
241
                        $types[] = $this->resolveCollection($tokens, $classType, $context);
242
                    }
243 48
                }
244
245
                $tokens->next();
246
            } elseif ($parserContext === self::PARSER_IN_COLLECTION_EXPRESSION
247
                && ($token === '>' || trim($token) === ',')
248
            ) {
249 48
                break;
250 1
            } elseif ($token === self::OPERATOR_ARRAY) {
251
                end($types);
252
                $last = key($types);
253
                $lastItem = $types[$last];
254
                if ($lastItem instanceof Expression) {
255
                    $lastItem = $lastItem->getValueType();
256 1
                }
257
258
                $types[$last] = new Array_($lastItem);
259
260
                $tokens->next();
261
            } else {
262 1
                $type = $this->resolveSingleType($token, $context);
263
                $tokens->next();
264 1
                if ($parserContext === self::PARSER_IN_NULLABLE) {
265
                    return $type;
266
                }
267 48
268 41
                $types[] = $type;
269
            }
270
        }
271 14
272
        if ($token === '|' || $token === '&') {
273
            throw new RuntimeException(
274
                'A type is missing after a type separator'
275
            );
276
        }
277
278
        if (count($types) === 0) {
279
            if ($parserContext === self::PARSER_IN_NULLABLE) {
280
                throw new RuntimeException(
281 49
                    'A type is missing after a nullable character'
282
                );
283
            }
284 49
285 43
            if ($parserContext === self::PARSER_IN_ARRAY_EXPRESSION) {
286 20
                throw new RuntimeException(
287 5
                    'A type is missing in an array expression'
288 17
                );
289 9
            }
290 10
291 10
            if ($parserContext === self::PARSER_IN_COLLECTION_EXPRESSION) {
292
                throw new RuntimeException(
293
                    'A type is missing in a collection expression'
294
                );
295
            }
296
        } elseif (count($types) === 1) {
297
            return $types[0];
298
        }
299
300
        if ($compoundToken === '|') {
301
            return new Compound(array_values($types));
302
        }
303
304
        return new Intersection(array_values($types));
305
    }
306 3
307
    /**
308 3
     * resolve the given type into a type object
309 1
     *
310 1
     * @param string $type the type string, representing a single type
311 1
     *
312
     * @return Type|Array_|Object_
313
     *
314
     * @psalm-pure
315 2
     */
316 1
    private function resolveSingleType(string $type, Context $context) : object
317 1
    {
318
        switch (true) {
319
            case $this->isKeyword($type):
320
                return $this->resolveKeyword($type);
321 1
            case $this->isFqsen($type):
322 1
                return $this->resolveTypedObject($type);
323
            case $this->isPartialStructuralElementName($type):
324
                return $this->resolveTypedObject($type, $context);
325
326
            // @codeCoverageIgnoreStart
327
            default:
328
                // I haven't got the foggiest how the logic would come here but added this as a defense.
329 20
                throw new RuntimeException(
330
                    'Unable to resolve type "' . $type . '", there is no known method to resolve it'
331 20
                );
332
        }
333
334
        // @codeCoverageIgnoreEnd
335
    }
336
337
    /**
338
     * Adds a keyword to the list of Keywords and associates it with a specific Value Object.
339 49
     *
340
     * @psalm-param class-string<Type> $typeClassName
341 49
     */
342
    public function addKeyword(string $keyword, string $typeClassName) : void
343
    {
344
        if (!class_exists($typeClassName)) {
345
            throw new InvalidArgumentException(
346
                'The Value Object that needs to be created with a keyword "' . $keyword . '" must be an existing class'
347
                . ' but we could not find the class ' . $typeClassName
348
            );
349 10
        }
350
351 10
        if (!in_array(Type::class, class_implements($typeClassName), true)) {
352
            throw new InvalidArgumentException(
353
                'The class "' . $typeClassName . '" must implement the interface "phpDocumentor\Reflection\Type"'
354
            );
355
        }
356
357 17
        $this->keywords[$keyword] = $typeClassName;
358
    }
359 17
360
    /**
361
     * Detects whether the given type represents a PHPDoc keyword.
362
     *
363
     * @param string $type A relative or absolute type as defined in the phpDocumentor documentation.
364
     *
365 5
     * @psalm-pure
366
     */
367 5
    private function isKeyword(string $type) : bool
368
    {
369
        return array_key_exists(strtolower($type), $this->keywords);
370
    }
371
372
    /**
373 43
     * Detects whether the given type represents a relative structural element name.
374
     *
375 43
     * @param string $type A relative or absolute type as defined in the phpDocumentor documentation.
376 43
     *
377
     * @psalm-pure
378
     */
379
    private function isPartialStructuralElementName(string $type) : bool
380
    {
381
        return ($type[0] !== self::OPERATOR_NAMESPACE) && !$this->isKeyword($type);
382 17
    }
383
384 17
    /**
385
     * Tests whether the given type is a Fully Qualified Structural Element Name.
386
     *
387
     * @psalm-pure
388
     */
389
    private function isFqsen(string $type) : bool
390
    {
391
        return strpos($type, self::OPERATOR_NAMESPACE) === 0;
392 12
    }
393
394 12
    /**
395 12
     * Resolves the given keyword (such as `string`) into a Type object representing that keyword.
396
     *
397
     * @psalm-pure
398 12
     */
399 12
    private function resolveKeyword(string $type) : Type
400 1
    {
401 1
        $className = $this->keywords[strtolower($type)];
402
403
        return new $className();
404
    }
405 11
406
    /**
407 11
     * Resolves the given FQSEN string into an FQSEN object.
408 11
     *
409
     * @psalm-pure
410 11
     */
411
    private function resolveTypedObject(string $type, ?Context $context = null) : Object_
412 7
    {
413 7
        return new Object_($this->fqsenResolver->resolve($type, $context));
414
    }
415
416 5
    /**
417 5
     * Resolves class string
418 5
     *
419
     * @param ArrayIterator<int, (string|null)> $tokens
420 1
     */
421 1
    private function resolveClassString(ArrayIterator $tokens, Context $context) : Type
422
    {
423
        $tokens->next();
424
425 4
        $classType = $this->parseTypes($tokens, $context, self::PARSER_IN_COLLECTION_EXPRESSION);
426
427
        if (!$classType instanceof Object_ || $classType->getFqsen() === null) {
428
            throw new RuntimeException(
429
                $classType . ' is not a class string'
430
            );
431
        }
432
433
        $token = $tokens->current();
434
        if ($token !== '>') {
435
            if (empty($token)) {
436
                throw new RuntimeException(
437
                    'class-string: ">" is missing'
438 6
                );
439
            }
440 6
441
            throw new RuntimeException(
442
                'Unexpected character "' . $token . '", ">" is missing'
443 9
            );
444 1
        }
445 1
446 1
        return new ClassString($classType->getFqsen());
447
    }
448
449
    /**
450
     * Resolves the collection values and keys
451
     *
452
     * @param ArrayIterator<int, (string|null)> $tokens
453
     *
454
     * @return Array_|Iterable_|Collection
455 8
     */
456 4
    private function resolveCollection(ArrayIterator $tokens, Type $classType, Context $context) : Type
457
    {
458
        $isArray    = ((string) $classType === 'array');
459 4
        $isIterable = ((string) $classType === 'iterable');
460 1
461
        // allow only "array", "iterable" or class name before "<"
462
        if (!$isArray && !$isIterable
463
            && (!$classType instanceof Object_ || $classType->getFqsen() === null)) {
464 3
            throw new RuntimeException(
465 3
                $classType . ' is not a collection'
466
            );
467
        }
468
469
        $tokens->next();
470
471 3
        $valueType = $this->parseTypes($tokens, $context, self::PARSER_IN_COLLECTION_EXPRESSION);
472
        $keyType   = null;
473 3
474
        $token = $tokens->current();
475
        if ($token !== null && trim($token) === ',') {
476
            // if we have a comma, then we just parsed the key type, not the value type
477
            $keyType = $valueType;
478
            if ($isArray) {
479
                // check the key type for an "array" collection. We allow only
480
                // strings or integers.
481
                if (!$keyType instanceof String_ &&
482
                    !$keyType instanceof Integer &&
483
                    !$keyType instanceof Compound
484
                ) {
485
                    throw new RuntimeException(
486
                        'An array can have only integers or strings as keys'
487
                    );
488
                }
489
490
                if ($keyType instanceof Compound) {
491
                    foreach ($keyType->getIterator() as $item) {
492
                        if (!$item instanceof String_ &&
493
                            !$item instanceof Integer
494
                        ) {
495
                            throw new RuntimeException(
496
                                'An array can have only integers or strings as keys'
497
                            );
498
                        }
499
                    }
500
                }
501
            }
502
503
            $tokens->next();
504
            // now let's parse the value type
505
            $valueType = $this->parseTypes($tokens, $context, self::PARSER_IN_COLLECTION_EXPRESSION);
506
        }
507
508
        $token = $tokens->current();
509
        if ($token !== '>') {
510
            if (empty($token)) {
511
                throw new RuntimeException(
512
                    'Collection: ">" is missing'
513
                );
514
            }
515
516
            throw new RuntimeException(
517
                'Unexpected character "' . $token . '", ">" is missing'
518
            );
519
        }
520
521
        if ($isArray) {
522
            return new Array_($valueType, $keyType);
523
        }
524
525
        if ($isIterable) {
526
            return new Iterable_($valueType, $keyType);
527
        }
528
529
        if ($classType instanceof Object_) {
530
            return $this->makeCollectionFromObject($classType, $valueType, $keyType);
531
        }
532
533
        throw new RuntimeException('Invalid $classType provided');
534
    }
535
536
    /**
537
     * @psalm-pure
538
     */
539
    private function makeCollectionFromObject(Object_ $object, Type $valueType, ?Type $keyType = null) : Collection
540
    {
541
        return new Collection($object->getFqsen(), $valueType, $keyType);
542
    }
543
}
544