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\Types;
15
16use ArrayIterator;
17use InvalidArgumentException;
18use ReflectionClass;
19use ReflectionClassConstant;
20use ReflectionMethod;
21use ReflectionParameter;
22use ReflectionProperty;
23use Reflector;
24use RuntimeException;
25use UnexpectedValueException;
26use function define;
27use function defined;
28use function file_exists;
29use function file_get_contents;
30use function get_class;
31use function in_array;
32use function is_string;
33use function strrpos;
34use function substr;
35use function token_get_all;
36use function trim;
37use const T_AS;
38use const T_CLASS;
39use const T_CURLY_OPEN;
40use const T_DOLLAR_OPEN_CURLY_BRACES;
41use const T_NAMESPACE;
42use const T_NS_SEPARATOR;
43use const T_STRING;
44use const T_USE;
45
46if (!defined('T_NAME_QUALIFIED')) {
47    define('T_NAME_QUALIFIED', 'T_NAME_QUALIFIED');
48}
49
50if (!defined('T_NAME_FULLY_QUALIFIED')) {
51    define('T_NAME_FULLY_QUALIFIED', 'T_NAME_FULLY_QUALIFIED');
52}
53
54/**
55 * Convenience class to create a Context for DocBlocks when not using the Reflection Component of phpDocumentor.
56 *
57 * For a DocBlock to be able to resolve types that use partial namespace names or rely on namespace imports we need to
58 * provide a bit of context so that the DocBlock can read that and based on it decide how to resolve the types to
59 * Fully Qualified names.
60 *
61 * @see Context for more information.
62 */
63final class ContextFactory
64{
65    /** The literal used at the end of a use statement. */
66    private const T_LITERAL_END_OF_USE = ';';
67
68    /** The literal used between sets of use statements */
69    private const T_LITERAL_USE_SEPARATOR = ',';
70
71    /**
72     * Build a Context given a Class Reflection.
73     *
74     * @see Context for more information on Contexts.
75     */
76    public function createFromReflector(Reflector $reflector) : Context
77    {
78        if ($reflector instanceof ReflectionClass) {
79            //phpcs:ignore SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.MissingVariable
80            /** @var ReflectionClass<object> $reflector */
81
82            return $this->createFromReflectionClass($reflector);
83        }
84
85        if ($reflector instanceof ReflectionParameter) {
86            return $this->createFromReflectionParameter($reflector);
87        }
88
89        if ($reflector instanceof ReflectionMethod) {
90            return $this->createFromReflectionMethod($reflector);
91        }
92
93        if ($reflector instanceof ReflectionProperty) {
94            return $this->createFromReflectionProperty($reflector);
95        }
96
97        if ($reflector instanceof ReflectionClassConstant) {
98            return $this->createFromReflectionClassConstant($reflector);
99        }
100
101        throw new UnexpectedValueException('Unhandled \Reflector instance given:  ' . get_class($reflector));
102    }
103
104    private function createFromReflectionParameter(ReflectionParameter $parameter) : Context
105    {
106        $class = $parameter->getDeclaringClass();
107        if (!$class) {
108            throw new InvalidArgumentException('Unable to get class of ' . $parameter->getName());
109        }
110
111        //phpcs:ignore SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.MissingVariable
112        /** @var ReflectionClass<object> $class */
113
114        return $this->createFromReflectionClass($class);
115    }
116
117    private function createFromReflectionMethod(ReflectionMethod $method) : Context
118    {
119        //phpcs:ignore SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.MissingVariable
120        /** @var ReflectionClass<object> $class */
121        $class = $method->getDeclaringClass();
122
123        return $this->createFromReflectionClass($class);
124    }
125
126    private function createFromReflectionProperty(ReflectionProperty $property) : Context
127    {
128        //phpcs:ignore SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.MissingVariable
129        /** @var ReflectionClass<object> $class */
130        $class = $property->getDeclaringClass();
131
132        return $this->createFromReflectionClass($class);
133    }
134
135    private function createFromReflectionClassConstant(ReflectionClassConstant $constant) : Context
136    {
137        //phpcs:ignore SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.MissingVariable
138        /** @var ReflectionClass<object> $class */
139        $class = $constant->getDeclaringClass();
140
141        return $this->createFromReflectionClass($class);
142    }
143
144    /**
145     * @param ReflectionClass<object> $class
146     */
147    private function createFromReflectionClass(ReflectionClass $class) : Context
148    {
149        $fileName  = $class->getFileName();
150        $namespace = $class->getNamespaceName();
151
152        if (is_string($fileName) && file_exists($fileName)) {
153            $contents = file_get_contents($fileName);
154            if ($contents === false) {
155                throw new RuntimeException('Unable to read file "' . $fileName . '"');
156            }
157
158            return $this->createForNamespace($namespace, $contents);
159        }
160
161        return new Context($namespace, []);
162    }
163
164    /**
165     * Build a Context for a namespace in the provided file contents.
166     *
167     * @see Context for more information on Contexts.
168     *
169     * @param string $namespace    It does not matter if a `\` precedes the namespace name,
170     * this method first normalizes.
171     * @param string $fileContents The file's contents to retrieve the aliases from with the given namespace.
172     */
173    public function createForNamespace(string $namespace, string $fileContents) : Context
174    {
175        $namespace        = trim($namespace, '\\');
176        $useStatements    = [];
177        $currentNamespace = '';
178        $tokens           = new ArrayIterator(token_get_all($fileContents));
179
180        while ($tokens->valid()) {
181            $currentToken = $tokens->current();
182            switch ($currentToken[0]) {
183                case T_NAMESPACE:
184                    $currentNamespace = $this->parseNamespace($tokens);
185                    break;
186                case T_CLASS:
187                    // Fast-forward the iterator through the class so that any
188                    // T_USE tokens found within are skipped - these are not
189                    // valid namespace use statements so should be ignored.
190                    $braceLevel      = 0;
191                    $firstBraceFound = false;
192                    while ($tokens->valid() && ($braceLevel > 0 || !$firstBraceFound)) {
193                        $currentToken = $tokens->current();
194                        if ($currentToken === '{'
195                            || in_array($currentToken[0], [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES], true)) {
196                            if (!$firstBraceFound) {
197                                $firstBraceFound = true;
198                            }
199
200                            ++$braceLevel;
201                        }
202
203                        if ($currentToken === '}') {
204                            --$braceLevel;
205                        }
206
207                        $tokens->next();
208                    }
209
210                    break;
211                case T_USE:
212                    if ($currentNamespace === $namespace) {
213                        $useStatements += $this->parseUseStatement($tokens);
214                    }
215
216                    break;
217            }
218
219            $tokens->next();
220        }
221
222        return new Context($namespace, $useStatements);
223    }
224
225    /**
226     * Deduce the name from tokens when we are at the T_NAMESPACE token.
227     *
228     * @param ArrayIterator<int, string|array{0:int,1:string,2:int}> $tokens
229     */
230    private function parseNamespace(ArrayIterator $tokens) : string
231    {
232        // skip to the first string or namespace separator
233        $this->skipToNextStringOrNamespaceSeparator($tokens);
234
235        $name = '';
236        $acceptedTokens = [T_STRING, T_NS_SEPARATOR, T_NAME_QUALIFIED];
237        while ($tokens->valid() && in_array($tokens->current()[0], $acceptedTokens, true)) {
238            $name .= $tokens->current()[1];
239            $tokens->next();
240        }
241
242        return $name;
243    }
244
245    /**
246     * Deduce the names of all imports when we are at the T_USE token.
247     *
248     * @param ArrayIterator<int, string|array{0:int,1:string,2:int}> $tokens
249     *
250     * @return string[]
251     *
252     * @psalm-return array<string, string>
253     */
254    private function parseUseStatement(ArrayIterator $tokens) : array
255    {
256        $uses = [];
257
258        while ($tokens->valid()) {
259            $this->skipToNextStringOrNamespaceSeparator($tokens);
260
261            $uses += $this->extractUseStatements($tokens);
262            $currentToken = $tokens->current();
263            if ($currentToken[0] === self::T_LITERAL_END_OF_USE) {
264                return $uses;
265            }
266        }
267
268        return $uses;
269    }
270
271    /**
272     * Fast-forwards the iterator as longs as we don't encounter a T_STRING or T_NS_SEPARATOR token.
273     *
274     * @param ArrayIterator<int, string|array{0:int,1:string,2:int}> $tokens
275     */
276    private function skipToNextStringOrNamespaceSeparator(ArrayIterator $tokens) : void
277    {
278        while ($tokens->valid()) {
279            $currentToken = $tokens->current();
280            if (in_array($currentToken[0], [T_STRING, T_NS_SEPARATOR], true)) {
281                break;
282            }
283
284            if ($currentToken[0] === T_NAME_QUALIFIED) {
285                break;
286            }
287
288            if (defined('T_NAME_FULLY_QUALIFIED') && $currentToken[0] === T_NAME_FULLY_QUALIFIED) {
289                break;
290            }
291
292            $tokens->next();
293        }
294    }
295
296    /**
297     * Deduce the namespace name and alias of an import when we are at the T_USE token or have not reached the end of
298     * a USE statement yet. This will return a key/value array of the alias => namespace.
299     *
300     * @param ArrayIterator<int, string|array{0:int,1:string,2:int}> $tokens
301     *
302     * @return string[]
303     *
304     * @psalm-suppress TypeDoesNotContainType
305     *
306     * @psalm-return array<string, string>
307     */
308    private function extractUseStatements(ArrayIterator $tokens) : array
309    {
310        $extractedUseStatements = [];
311        $groupedNs              = '';
312        $currentNs              = '';
313        $currentAlias           = '';
314        $state                  = 'start';
315
316        while ($tokens->valid()) {
317            $currentToken = $tokens->current();
318            $tokenId      = is_string($currentToken) ? $currentToken : $currentToken[0];
319            $tokenValue   = is_string($currentToken) ? null : $currentToken[1];
320            switch ($state) {
321                case 'start':
322                    switch ($tokenId) {
323                        case T_STRING:
324                        case T_NS_SEPARATOR:
325                            $currentNs   .= (string) $tokenValue;
326                            $currentAlias =  $tokenValue;
327                            break;
328                        case T_NAME_QUALIFIED:
329                        case T_NAME_FULLY_QUALIFIED:
330                            $currentNs   .= (string) $tokenValue;
331                            $currentAlias = substr(
332                                (string) $tokenValue,
333                                (int) (strrpos((string) $tokenValue, '\\')) + 1
334                            );
335                            break;
336                        case T_CURLY_OPEN:
337                        case '{':
338                            $state     = 'grouped';
339                            $groupedNs = $currentNs;
340                            break;
341                        case T_AS:
342                            $state = 'start-alias';
343                            break;
344                        case self::T_LITERAL_USE_SEPARATOR:
345                        case self::T_LITERAL_END_OF_USE:
346                            $state = 'end';
347                            break;
348                        default:
349                            break;
350                    }
351
352                    break;
353                case 'start-alias':
354                    switch ($tokenId) {
355                        case T_STRING:
356                            $currentAlias = $tokenValue;
357                            break;
358                        case self::T_LITERAL_USE_SEPARATOR:
359                        case self::T_LITERAL_END_OF_USE:
360                            $state = 'end';
361                            break;
362                        default:
363                            break;
364                    }
365
366                    break;
367                case 'grouped':
368                    switch ($tokenId) {
369                        case T_STRING:
370                        case T_NS_SEPARATOR:
371                            $currentNs   .= (string) $tokenValue;
372                            $currentAlias = $tokenValue;
373                            break;
374                        case T_AS:
375                            $state = 'grouped-alias';
376                            break;
377                        case self::T_LITERAL_USE_SEPARATOR:
378                            $state                                          = 'grouped';
379                            $extractedUseStatements[(string) $currentAlias] = $currentNs;
380                            $currentNs                                      = $groupedNs;
381                            $currentAlias                                   = '';
382                            break;
383                        case self::T_LITERAL_END_OF_USE:
384                            $state = 'end';
385                            break;
386                        default:
387                            break;
388                    }
389
390                    break;
391                case 'grouped-alias':
392                    switch ($tokenId) {
393                        case T_STRING:
394                            $currentAlias = $tokenValue;
395                            break;
396                        case self::T_LITERAL_USE_SEPARATOR:
397                            $state                                          = 'grouped';
398                            $extractedUseStatements[(string) $currentAlias] = $currentNs;
399                            $currentNs                                      = $groupedNs;
400                            $currentAlias                                   = '';
401                            break;
402                        case self::T_LITERAL_END_OF_USE:
403                            $state = 'end';
404                            break;
405                        default:
406                            break;
407                    }
408            }
409
410            if ($state === 'end') {
411                break;
412            }
413
414            $tokens->next();
415        }
416
417        if ($groupedNs !== $currentNs) {
418            $extractedUseStatements[(string) $currentAlias] = $currentNs;
419        }
420
421        return $extractedUseStatements;
422    }
423}
424