1<?php
2
3declare(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
14namespace phpDocumentor\Reflection;
15
16use ArrayIterator;
17use InvalidArgumentException;
18use phpDocumentor\Reflection\Types\Array_;
19use phpDocumentor\Reflection\Types\ClassString;
20use phpDocumentor\Reflection\Types\Collection;
21use phpDocumentor\Reflection\Types\Compound;
22use phpDocumentor\Reflection\Types\Context;
23use phpDocumentor\Reflection\Types\Expression;
24use phpDocumentor\Reflection\Types\Integer;
25use phpDocumentor\Reflection\Types\Intersection;
26use phpDocumentor\Reflection\Types\Iterable_;
27use phpDocumentor\Reflection\Types\Nullable;
28use phpDocumentor\Reflection\Types\Object_;
29use phpDocumentor\Reflection\Types\String_;
30use RuntimeException;
31use function array_key_exists;
32use function array_pop;
33use function array_values;
34use function class_exists;
35use function class_implements;
36use function count;
37use function end;
38use function in_array;
39use function key;
40use function preg_split;
41use function strpos;
42use function strtolower;
43use function trim;
44use const PREG_SPLIT_DELIM_CAPTURE;
45use const PREG_SPLIT_NO_EMPTY;
46
47final 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' => PseudoTypes\False_::class,
91        'true' => PseudoTypes\True_::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     * @var FqsenResolver
101     * @psalm-readonly
102     */
103    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     * no dynamic reflection is being performed here.
122     *
123     * @uses Context::getNamespaceAliases() to check whether the first part of the relative type name should not be
124     * replaced with another namespace.
125     * @uses Context::getNamespace()        to determine with what to prefix the type name.
126     *
127     * @param string $type The relative or absolute type.
128     */
129    public function resolve(string $type, ?Context $context = null) : Type
130    {
131        $type = trim($type);
132        if (!$type) {
133            throw new InvalidArgumentException('Attempted to resolve "' . $type . '" but it appears to be empty');
134        }
135
136        if ($context === null) {
137            $context = new Context('');
138        }
139
140        // split the type string into tokens `|`, `?`, `<`, `>`, `,`, `(`, `)`, `[]`, '<', '>' and type names
141        $tokens = preg_split(
142            '/(\\||\\?|<|>|&|, ?|\\(|\\)|\\[\\]+)/',
143            $type,
144            -1,
145            PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE
146        );
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    }
157
158    /**
159     * Analyse each tokens and creates types
160     *
161     * @param ArrayIterator<int, string|null> $tokens        the iterator on tokens
162     * @param int                        $parserContext on of self::PARSER_* constants, indicating
163     * the context where we are in the parsing
164     */
165    private function parseTypes(ArrayIterator $tokens, Context $context, int $parserContext) : Type
166    {
167        $types = [];
168        $token = '';
169        $compoundToken = '|';
170        while ($tokens->valid()) {
171            $token = $tokens->current();
172            if ($token === null) {
173                throw new RuntimeException(
174                    'Unexpected nullable character'
175                );
176            }
177
178            if ($token === '|' || $token === '&') {
179                if (count($types) === 0) {
180                    throw new RuntimeException(
181                        'A type is missing before a type separator'
182                    );
183                }
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                ) {
191                    throw new RuntimeException(
192                        'Unexpected type separator'
193                    );
194                }
195
196                $compoundToken = $token;
197                $tokens->next();
198            } elseif ($token === '?') {
199                if (!in_array($parserContext, [
200                    self::PARSER_IN_COMPOUND,
201                    self::PARSER_IN_ARRAY_EXPRESSION,
202                    self::PARSER_IN_COLLECTION_EXPRESSION,
203                ], true)
204                ) {
205                    throw new RuntimeException(
206                        'Unexpected nullable character'
207                    );
208                }
209
210                $tokens->next();
211                $type    = $this->parseTypes($tokens, $context, self::PARSER_IN_NULLABLE);
212                $types[] = new Nullable($type);
213            } elseif ($token === '(') {
214                $tokens->next();
215                $type = $this->parseTypes($tokens, $context, self::PARSER_IN_ARRAY_EXPRESSION);
216
217                $token = $tokens->current();
218                if ($token === null) { // Someone did not properly close their array expression ..
219                    break;
220                }
221
222                $tokens->next();
223
224                $resolvedType = new Expression($type);
225
226                $types[] = $resolvedType;
227            } elseif ($parserContext === self::PARSER_IN_ARRAY_EXPRESSION && $token[0] === ')') {
228                break;
229            } elseif ($token === '<') {
230                if (count($types) === 0) {
231                    throw new RuntimeException(
232                        'Unexpected collection operator "<", class name is missing'
233                    );
234                }
235
236                $classType = array_pop($types);
237                if ($classType !== null) {
238                    if ((string) $classType === 'class-string') {
239                        $types[] = $this->resolveClassString($tokens, $context);
240                    } else {
241                        $types[] = $this->resolveCollection($tokens, $classType, $context);
242                    }
243                }
244
245                $tokens->next();
246            } elseif ($parserContext === self::PARSER_IN_COLLECTION_EXPRESSION
247                && ($token === '>' || trim($token) === ',')
248            ) {
249                break;
250            } 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                }
257
258                $types[$last] = new Array_($lastItem);
259
260                $tokens->next();
261            } else {
262                $type = $this->resolveSingleType($token, $context);
263                $tokens->next();
264                if ($parserContext === self::PARSER_IN_NULLABLE) {
265                    return $type;
266                }
267
268                $types[] = $type;
269            }
270        }
271
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                    'A type is missing after a nullable character'
282                );
283            }
284
285            if ($parserContext === self::PARSER_IN_ARRAY_EXPRESSION) {
286                throw new RuntimeException(
287                    'A type is missing in an array expression'
288                );
289            }
290
291            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
307    /**
308     * resolve the given type into a type object
309     *
310     * @param string $type the type string, representing a single type
311     *
312     * @return Type|Array_|Object_
313     *
314     * @psalm-mutation-free
315     */
316    private function resolveSingleType(string $type, Context $context) : object
317    {
318        switch (true) {
319            case $this->isKeyword($type):
320                return $this->resolveKeyword($type);
321            case $this->isFqsen($type):
322                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                throw new RuntimeException(
330                    'Unable to resolve type "' . $type . '", there is no known method to resolve it'
331                );
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     *
340     * @psalm-param class-string<Type> $typeClassName
341     */
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        }
350
351        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        $this->keywords[$keyword] = $typeClassName;
358    }
359
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     * @psalm-mutation-free
366     */
367    private function isKeyword(string $type) : bool
368    {
369        return array_key_exists(strtolower($type), $this->keywords);
370    }
371
372    /**
373     * Detects whether the given type represents a relative structural element name.
374     *
375     * @param string $type A relative or absolute type as defined in the phpDocumentor documentation.
376     *
377     * @psalm-mutation-free
378     */
379    private function isPartialStructuralElementName(string $type) : bool
380    {
381        return ($type[0] !== self::OPERATOR_NAMESPACE) && !$this->isKeyword($type);
382    }
383
384    /**
385     * Tests whether the given type is a Fully Qualified Structural Element Name.
386     *
387     * @psalm-mutation-free
388     */
389    private function isFqsen(string $type) : bool
390    {
391        return strpos($type, self::OPERATOR_NAMESPACE) === 0;
392    }
393
394    /**
395     * Resolves the given keyword (such as `string`) into a Type object representing that keyword.
396     *
397     * @psalm-mutation-free
398     */
399    private function resolveKeyword(string $type) : Type
400    {
401        $className = $this->keywords[strtolower($type)];
402
403        return new $className();
404    }
405
406    /**
407     * Resolves the given FQSEN string into an FQSEN object.
408     *
409     * @psalm-mutation-free
410     */
411    private function resolveTypedObject(string $type, ?Context $context = null) : Object_
412    {
413        return new Object_($this->fqsenResolver->resolve($type, $context));
414    }
415
416    /**
417     * Resolves class string
418     *
419     * @param ArrayIterator<int, (string|null)> $tokens
420     */
421    private function resolveClassString(ArrayIterator $tokens, Context $context) : Type
422    {
423        $tokens->next();
424
425        $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                );
439            }
440
441            throw new RuntimeException(
442                'Unexpected character "' . $token . '", ">" is missing'
443            );
444        }
445
446        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     */
456    private function resolveCollection(ArrayIterator $tokens, Type $classType, Context $context) : Type
457    {
458        $isArray    = ((string) $classType === 'array');
459        $isIterable = ((string) $classType === 'iterable');
460
461        // allow only "array", "iterable" or class name before "<"
462        if (!$isArray && !$isIterable
463            && (!$classType instanceof Object_ || $classType->getFqsen() === null)) {
464            throw new RuntimeException(
465                $classType . ' is not a collection'
466            );
467        }
468
469        $tokens->next();
470
471        $valueType = $this->parseTypes($tokens, $context, self::PARSER_IN_COLLECTION_EXPRESSION);
472        $keyType   = null;
473
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