Passed
Push — master ( 813f0f...a309d2 )
by butschster
09:30 queued 12s
created

ReflectionFile::getSource()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

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