Test Failed
Pull Request — master (#895)
by butschster
08:40
created

ReflectionFile::activeDeclaration()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 12
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 5

Importance

Changes 0
Metric Value
eloc 5
c 0
b 0
f 0
dl 0
loc 12
ccs 5
cts 5
cp 1
rs 9.6111
cc 5
nc 4
nop 1
crap 5
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Spiral\Tokenizer\Reflection;
6
7
use Spiral\Tokenizer\Tokenizer;
8
9
/**
10
 * File reflections can fetch information about classes, interfaces, functions and traits declared
11
 * in file. In addition file reflection provides ability to fetch and describe every method/function
12
 * call.
13
 */
14
final class ReflectionFile
15
{
16
    /**
17
     * Namespace separator.
18
     */
19
    public const NS_SEPARATOR = '\\';
20
21
    /**
22
     * Constants for convenience.
23
     */
24
    public const TOKEN_TYPE = Tokenizer::TYPE;
25
    public const TOKEN_CODE = Tokenizer::CODE;
26
    public const TOKEN_LINE = Tokenizer::LINE;
27
28
    /**
29
     * Opening and closing token ids.
30
     */
31
    public const O_TOKEN = 0;
32
    public const C_TOKEN = 1;
33
34
    /**
35
     * Namespace uses.
36
     */
37
    public const N_USES = 2;
38
39
    /**
40
     * Set of tokens required to detect classes, traits, interfaces and function declarations. We
41
     * don't need any other token for that.
42
     */
43
    private static array $processTokens = [
44
        '{',
45
        '}',
46
        ';',
47
        T_PAAMAYIM_NEKUDOTAYIM,
48
        T_NAMESPACE,
49
        T_STRING,
50
        T_CLASS,
51
        T_INTERFACE,
52
        T_TRAIT,
53
        T_FUNCTION,
54
        T_NS_SEPARATOR,
55
        T_INCLUDE,
56
        T_INCLUDE_ONCE,
57
        T_REQUIRE,
58
        T_REQUIRE_ONCE,
59
        T_USE,
60
        T_AS,
61
    ];
62
63
    /**
64
     * Parsed tokens array.
65
     *
66
     * @internal
67
     */
68
    private array $tokens = [];
69
70
    /**
71
     * Total tokens count.
72
     *
73
     * @internal
74
     */
75
    private int $countTokens = 0;
76
77
    /**
78
     * Indicator that file has external includes.
79
     *
80
     * @internal
81
     */
82
    private bool $hasIncludes = false;
83
84
    /**
85
     * Namespaces used in file and their token positions.
86
     *
87
     * @internal
88
     */
89
    private array $namespaces = [];
90
91
    /**
92
     * Declarations of classes, interfaces and traits.
93
     *
94
     * @internal
95
     */
96
    private array $declarations = [];
97
98
    /**
99
     * Declarations of new functions.
100
     *
101
     * @internal
102
     */
103
    private array $functions = [];
104
105
    /**
106
     * Every found method/function invocation.
107
     *
108
     * @internal
109
     * @var ReflectionInvocation[]
110
     */
111
    private array $invocations = [];
112
113 345
    public function __construct(
114
        private readonly string $filename
115
    ) {
116 345
        $this->tokens = Tokenizer::getTokens($filename);
117 345
        $this->countTokens = \count($this->tokens);
118
119
        //Looking for declarations
120 345
        $this->locateDeclarations();
121
    }
122
123
    /**
124
     * Filename.
125
     */
126
    public function getFilename(): string
127
    {
128
        return $this->filename;
129
    }
130
131
    /**
132
     * List of declared function names
133
     */
134 1
    public function getFunctions(): array
135
    {
136 1
        return \array_keys($this->functions);
137
    }
138
139
    /**
140
     * List of declared class names
141
     */
142 332
    public function getClasses(): array
143
    {
144 332
        if (!isset($this->declarations['T_CLASS'])) {
145 324
            return [];
146
        }
147
148 332
        return \array_keys($this->declarations['T_CLASS']);
149
    }
150
151
    /**
152
     * List of declared trait names
153
     */
154 1
    public function getTraits(): array
155
    {
156 1
        if (!isset($this->declarations['T_TRAIT'])) {
157
            return [];
158
        }
159
160 1
        return \array_keys($this->declarations['T_TRAIT']);
161
    }
162
163
    /**
164
     * List of declared interface names
165
     */
166 1
    public function getInterfaces(): array
167
    {
168 1
        if (!isset($this->declarations['T_INTERFACE'])) {
169
            return [];
170
        }
171
172 1
        return \array_keys($this->declarations['T_INTERFACE']);
173
    }
174
175
    /**
176
     * Get list of tokens associated with given file.
177
     */
178 345
    public function getTokens(): array
179
    {
180 345
        return $this->tokens;
181
    }
182
183
    /**
184
     * Indication that file contains require/include statements
185
     */
186 344
    public function hasIncludes(): bool
187
    {
188 344
        return $this->hasIncludes;
189
    }
190
191
    /**
192
     * Locate and return list of every method or function call in specified file. Only static and
193
     * $this calls will be indexed
194
     *
195
     * @return ReflectionInvocation[]
196
     */
197 26
    public function getInvocations(): array
198
    {
199 26
        if (empty($this->invocations)) {
200 26
            $this->locateInvocations($this->getTokens());
201
        }
202
203 26
        return $this->invocations;
204
    }
205
206
    /**
207
     * Export found declaration as array for caching purposes.
208
     */
209
    public function exportSchema(): array
210
    {
211
        return [$this->hasIncludes, $this->declarations, $this->functions, $this->namespaces];
212
    }
213
214
    /**
215
     * Import cached reflection schema.
216
     */
217
    protected function importSchema(array $cache)
218
    {
219
        [$this->hasIncludes, $this->declarations, $this->functions, $this->namespaces] = $cache;
220
    }
221
222
    /**
223
     * Locate every class, interface, trait or function definition.
224
     */
225 345
    protected function locateDeclarations()
226
    {
227 345
        foreach ($this->getTokens() as $tokenID => $token) {
228 345
            if (!\in_array($token[self::TOKEN_TYPE], self::$processTokens)) {
229 345
                continue;
230
            }
231
232 345
            switch ($token[self::TOKEN_TYPE]) {
233 345
                case T_NAMESPACE:
234 345
                    $this->registerNamespace($tokenID);
235 345
                    break;
236
237 345
                case T_USE:
238 345
                    $this->registerUse($tokenID);
239 345
                    break;
240
241 345
                case T_FUNCTION:
242 344
                    $this->registerFunction($tokenID);
243 344
                    break;
244
245 345
                case T_CLASS:
246 345
                case T_TRAIT:
247 345
                case T_INTERFACE:
248 345
                    if ($this->isClassNameConst($tokenID)) {
249
                        // PHP5.5 ClassName::class constant
250 344
                        continue 2;
251
                    }
252
253 345
                    if ($this->isAnonymousClass($tokenID)) {
254 345
                        // PHP7.0 Anonymous classes new class ('foo', 'bar')
255
                        continue 2;
256 345
                    }
257 345
258 345
                    if (!$this->isCorrectDeclaration($tokenID)) {
259 345
                        // PHP8.0 Named parameters ->foo(class: 'bar')
260 20
                        continue 2;
261
                    }
262
263
                    $this->registerDeclaration($tokenID, $token[self::TOKEN_TYPE]);
264
                    break;
265 345
266 315
                case T_INCLUDE:
267 315
                case T_INCLUDE_ONCE:
268
                case T_REQUIRE:
269
                case T_REQUIRE_ONCE:
270
                    $this->hasIncludes = true;
271
            }
272
        }
273
274 345
        //Dropping empty namespace
275
        if (isset($this->namespaces[''])) {
276 345
            $this->namespaces['\\'] = $this->namespaces[''];
277 345
            unset($this->namespaces['']);
278
        }
279
    }
280 345
281 345
    /**
282
     * Handle namespace declaration.
283
     */
284
    private function registerNamespace(int $tokenID): void
285 345
    {
286
        $namespace = '';
287 345
        $localID = $tokenID + 1;
288 345
289 345
        do {
290
            $token = $this->tokens[$localID++];
291
            if ($token[self::TOKEN_CODE] === '{') {
292
                break;
293 345
            }
294
295 345
            $namespace .= $token[self::TOKEN_CODE];
296 345
        } while (
297
            isset($this->tokens[$localID])
298
            && $this->tokens[$localID][self::TOKEN_CODE] !== '{'
299
            && $this->tokens[$localID][self::TOKEN_CODE] !== ';'
300 345
        );
301 345
302
        //Whitespaces
303
        $namespace = \trim($namespace);
304
305
        $uses = [];
306 345
        if (isset($this->namespaces[$namespace])) {
307 345
            $uses = $this->namespaces[$namespace];
308 345
        }
309 345
310 345
        if ($this->tokens[$localID][self::TOKEN_CODE] === ';') {
311
            $endingID = \count($this->tokens) - 1;
312
        } else {
313
            $endingID = $this->endingToken($tokenID);
314
        }
315
316 345
        $this->namespaces[$namespace] = [
317
            self::O_TOKEN => $tokenID,
318 345
            self::C_TOKEN => $endingID,
319
            self::N_USES  => $uses,
320 345
        ];
321 345
    }
322 345
323 345
    /**
324 292
     * Handle use (import class from another namespace).
325 292
     */
326
    private function registerUse(int $tokenID): void
327
    {
328 345
        $namespace = \rtrim($this->activeNamespace($tokenID), '\\');
329 345
330
        $class = '';
331 292
        $localAlias = null;
332
        for ($localID = $tokenID + 1; $this->tokens[$localID][self::TOKEN_CODE] !== ';'; ++$localID) {
333
            if ($this->tokens[$localID][self::TOKEN_TYPE] == T_AS) {
334
                $localAlias = '';
335 345
                continue;
336 345
            }
337 345
338
            if ($localAlias === null) {
339
                $class .= $this->tokens[$localID][self::TOKEN_CODE];
340 345
            } else {
341
                $localAlias .= $this->tokens[$localID][self::TOKEN_CODE];
342
            }
343
        }
344
345
        if (empty($localAlias)) {
346 344
            $names = explode('\\', $class);
347
            $localAlias = end($names);
348 344
        }
349 344
350 344
        $this->namespaces[$namespace][self::N_USES][\trim($localAlias)] = \trim($class);
351
    }
352 344
353
    /**
354
     * Handle function declaration (function creation).
355
     */
356
    private function registerFunction(int $tokenID): void
357 313
    {
358 313
        foreach ($this->declarations as $declarations) {
359
            foreach ($declarations as $location) {
360 313
                if ($tokenID >= $location[self::O_TOKEN] && $tokenID <= $location[self::C_TOKEN]) {
361
                    //We are inside class, function is method
362
                    return;
363 313
                }
364 313
            }
365 21
        }
366
367
        $localID = $tokenID + 1;
368 313
        while ($this->tokens[$localID][self::TOKEN_TYPE] !== T_STRING) {
369 313
            //Fetching function name
370 313
            ++$localID;
371 313
        }
372
373
        $name = $this->tokens[$localID][self::TOKEN_CODE];
374
        if (!empty($namespace = $this->activeNamespace($tokenID))) {
375
            $name = $namespace . self::NS_SEPARATOR . $name;
376
        }
377
378 345
        $this->functions[$name] = [
379
            self::O_TOKEN => $tokenID,
380 345
            self::C_TOKEN => $this->endingToken($tokenID),
381 345
        ];
382 345
    }
383
384
    /**
385 345
     * Handle declaration of class, trait of interface. Declaration will be stored under it's token
386 345
     * type in declarations array.
387 345
     */
388
    private function registerDeclaration(int $tokenID, int $tokenType): void
389
    {
390 345
        $localID = $tokenID + 1;
391 345
        while ($this->tokens[$localID][self::TOKEN_TYPE] !== T_STRING) {
392 345
            ++$localID;
393 345
        }
394
395
        $name = $this->tokens[$localID][self::TOKEN_CODE];
396
        if (!empty($namespace = $this->activeNamespace($tokenID))) {
397
            $name = $namespace . self::NS_SEPARATOR . $name;
398
        }
399
400
        $this->declarations[\token_name($tokenType)][$name] = [
401 345
            self::O_TOKEN => $tokenID,
402
            self::C_TOKEN => $this->endingToken($tokenID),
403 345
        ];
404 345
    }
405 345
406
    /**
407
     * Check if token ID represents `ClassName::class` constant statement.
408
     */
409
    private function isClassNameConst(int $tokenID): bool
410
    {
411
        return $this->tokens[$tokenID][self::TOKEN_TYPE] === T_CLASS
412
            && isset($this->tokens[$tokenID - 1])
413
            && $this->tokens[$tokenID - 1][self::TOKEN_TYPE] === T_PAAMAYIM_NEKUDOTAYIM;
414
    }
415
416 26
    /**
417
     * Check if token ID represents anonymous class creation, e.g. `new class ('foo', 'bar')`.
418
     */
419 26
    private function isAnonymousClass(int|string $tokenID): bool
420
    {
421
        return $this->tokens[$tokenID][self::TOKEN_TYPE] === T_CLASS
422 26
            && isset($this->tokens[$tokenID - 2])
423
            && $this->tokens[$tokenID - 2][self::TOKEN_TYPE] === T_NEW;
424
    }
425 26
426
    /**
427
     * Check if token ID represents named parameter with name `class`, e.g. `foo(class: SomeClass::name)`.
428 26
     */
429 26
    private function isCorrectDeclaration(int|string $tokenID): bool
430
    {
431
        return \in_array($this->tokens[$tokenID][self::TOKEN_TYPE], [T_CLASS, T_TRAIT, T_INTERFACE], true)
432 26
            && isset($this->tokens[$tokenID + 2])
433 26
            && $this->tokens[$tokenID + 1][self::TOKEN_TYPE] === T_WHITESPACE
434 26
            && $this->tokens[$tokenID + 2][self::TOKEN_TYPE] === T_STRING;
435
    }
436
437 26
    /**
438
     * Locate every function or static method call (including $this calls).
439 26
     *
440
     * This is pretty old code, potentially to be improved using AST.
441 26
     *
442 26
     * @param array<int, mixed> $tokens
443
     * @param int   $invocationLevel
444
     */
445
    private function locateInvocations(array $tokens, int $invocationLevel = 0): void
446 26
    {
447 26
        //Multiple "(" and ")" statements nested.
448
        $level = 0;
449 26
450 26
        //Skip all tokens until next function
451
        $ignore = false;
452 26
453
        //Were function was found
454 26
        $invocationTID = 0;
455
456
        //Parsed arguments and their first token id
457
        $arguments = [];
458 26
        $argumentsTID = false;
459 26
460 26
        //Tokens used to re-enable token detection
461
        $stopTokens = [T_STRING, T_WHITESPACE, T_DOUBLE_COLON, T_OBJECT_OPERATOR, T_NS_SEPARATOR];
462
        foreach ($tokens as $tokenID => $token) {
463 26
            $tokenType = $token[self::TOKEN_TYPE];
464 26
465
            //We are not indexing function declarations or functions called from $objects.
466 26
            if (\in_array($tokenType, [T_FUNCTION, T_OBJECT_OPERATOR, T_NEW])) {
467
                if (
468
                    empty($argumentsTID)
469 26
                    && (
470
                        empty($invocationTID)
471
                        || $this->getSource($invocationTID, $tokenID - 1) !== '$this'
472
                    )
473 26
                ) {
474 26
                    //Not a call, function declaration, or object method
475 26
                    $ignore = true;
476 25
                    continue;
477 25
                }
478 25
            } elseif ($ignore) {
479
                if (!\in_array($tokenType, $stopTokens)) {
480
                    //Returning to search
481
                    $ignore = false;
482 26
                }
483 26
                continue;
484 26
            }
485 26
486 26
            //We are inside function, and there is "(", indexing arguments.
487 26
            if (!empty($invocationTID) && ($tokenType === '(' || $tokenType === '[')) {
488 26
                if (empty($argumentsTID)) {
489 26
                    $argumentsTID = $tokenID;
490
                }
491
492 26
                ++$level;
493 26
                if ($level != 1) {
494
                    //Not arguments beginning, but arguments part
495
                    $arguments[$tokenID] = $token;
496 26
                }
497
498
                continue;
499 26
            }
500
501
            //We are inside function arguments and ")" met.
502
            if (!empty($invocationTID) && ($tokenType === ')' || $tokenType === ']')) {
503 26
                --$level;
504 26
                if ($level == -1) {
505 26
                    $invocationTID = false;
506
                    $level = 0;
507
                    continue;
508
                }
509 26
510 26
                //Function fully indexed, we can process it now.
511
                if ($level == 0) {
512
                    $this->registerInvocation(
513
                        $invocationTID,
514
                        $argumentsTID,
515 26
                        $tokenID,
516 26
                        $arguments,
517 26
                        $invocationLevel
518 26
                    );
519
520 26
                    //Closing search
521 26
                    $arguments = [];
522
                    $argumentsTID = $invocationTID = false;
523 26
                } else {
524 26
                    //Not arguments beginning, but arguments part
525
                    $arguments[$tokenID] = $token;
526
                }
527
528 26
                continue;
529 26
            }
530
531
            //Still inside arguments.
532
            if (!empty($invocationTID) && !empty($level)) {
533
                $arguments[$tokenID] = $token;
534
                continue;
535
            }
536 26
537
            //Nothing valuable to remember, will be parsed later.
538
            if (!empty($invocationTID) && \in_array($tokenType, $stopTokens)) {
539
                continue;
540
            }
541
542
            //Seems like we found function/method call
543
            if (
544 26
                $tokenType == T_STRING
545
                || $tokenType == T_STATIC
546 26
                || $tokenType == T_NS_SEPARATOR
547
                || ($tokenType == T_VARIABLE && $token[self::TOKEN_CODE] === '$this')
548 26
            ) {
549
                $invocationTID = $tokenID;
550
                $level = 0;
551
552
                $argumentsTID = false;
553 26
                continue;
554 26
            }
555 26
556 26
            //Returning to search
557 26
            $invocationTID = false;
558 26
            $arguments = [];
559 26
        }
560 26
    }
561 26
562 26
    /**
563
     * Registering invocation.
564
     */
565
    private function registerInvocation(
566
        int $invocationID,
567
        int $argumentsID,
568 26
        int $endID,
569
        array $arguments,
570 26
        int $invocationLevel
571 26
    ): void {
572
        //Nested invocations
573
        $this->locateInvocations($arguments, $invocationLevel + 1);
574 26
575 26
        [$class, $operator, $name] = $this->fetchContext($invocationID, $argumentsID);
576 26
577 25
        if (!empty($operator) && empty($class)) {
578
            //Non detectable
579
            return;
580 26
        }
581 26
582
        $this->invocations[] = new ReflectionInvocation(
583
            $this->filename,
584 26
            $this->lineNumber($invocationID),
585 26
            $class,
586
            $operator,
587
            $name,
588
            ReflectionArgument::locateArguments($arguments),
589 26
            $this->getSource($invocationID, $endID),
590
            $invocationLevel
591
        );
592
    }
593
594
    /**
595 26
     * Fetching invocation context.
596
     */
597 26
    private function fetchContext(int $invocationTID, int $argumentsTID): array
598 26
    {
599 26
        $class = $operator = '';
600 26
        $name = \trim($this->getSource($invocationTID, $argumentsTID), '( ');
601
602
        //Let's try to fetch all information we need
603
        if (\str_contains($name, '->')) {
604
            $operator = '->';
605
        } elseif (\str_contains($name, '::')) {
606
            $operator = '::';
607
        }
608
609
        if (!empty($operator)) {
610
            [$class, $name] = \explode($operator, $name);
611
612 345
            //We now have to clarify class name
613
            if (\in_array($class, ['self', 'static', '$this'])) {
614 345
                $class = $this->activeDeclaration($invocationTID);
615 345
            }
616 345
        }
617
618
        return [$class, $operator, $name];
619
    }
620
621 315
    /**
622 315
     * Get declaration which is active in given token position.
623 315
     */
624 315
    private function activeDeclaration(int $tokenID): string
625 315
    {
626
        foreach ($this->declarations as $declarations) {
627 315
            foreach ($declarations as $name => $position) {
628
                if ($tokenID >= $position[self::O_TOKEN] && $tokenID <= $position[self::C_TOKEN]) {
629
                    return $name;
630
                }
631
            }
632
        }
633 345
634
        //Can not be detected
635 345
        return '';
636 345
    }
637 345
638 345
    /**
639 345
     * Get namespace name active at specified token position.
640 345
     */
641
    private function activeNamespace(int $tokenID): string
642
    {
643 345
        foreach ($this->namespaces as $namespace => $position) {
644 345
            if ($tokenID >= $position[self::O_TOKEN] && $tokenID <= $position[self::C_TOKEN]) {
645
                return $namespace;
646
            }
647 345
        }
648 345
649
        //Seems like no namespace declaration
650
        $this->namespaces[''] = [
651
            self::O_TOKEN => 0,
652 345
            self::C_TOKEN => \count($this->tokens),
653
            self::N_USES  => [],
654
        ];
655
656
        return '';
657
    }
658 26
659
    /**
660 26
     * Find token ID of ending brace.
661
     */
662
    private function endingToken(int $tokenID): int
663
    {
664 26
        $level = null;
665
        for ($localID = $tokenID; $localID < $this->countTokens; ++$localID) {
666
            $token = $this->tokens[$localID];
667
            if ($token[self::TOKEN_CODE] === '{') {
668
                ++$level;
669
                continue;
670 26
            }
671
672 26
            if ($token[self::TOKEN_CODE] === '}') {
673 26
                --$level;
674
            }
675 26
676
            if ($level === 0) {
677
                break;
678 26
            }
679
        }
680
681
        return $localID;
682
    }
683
684
    /**
685
     * Get line number associated with token.
686
     */
687
    private function lineNumber(int $tokenID): int
688
    {
689
        while (empty($this->tokens[$tokenID][self::TOKEN_LINE])) {
690
            --$tokenID;
691
        }
692
693
        return $this->tokens[$tokenID][self::TOKEN_LINE];
694
    }
695
696
    /**
697
     * Get src located between two tokens.
698
     */
699
    private function getSource(int $startID, int $endID): string
700
    {
701
        $result = '';
702
        for ($tokenID = $startID; $tokenID <= $endID; ++$tokenID) {
703
            //Collecting function usage src
704
            $result .= $this->tokens[$tokenID][self::TOKEN_CODE];
705
        }
706
707
        return $result;
708
    }
709
}
710