CallFinder::tokensToString()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 7
nc 3
nop 1
dl 0
loc 13
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * The MIT License (MIT)
7
 *
8
 * Copyright (c) 2013 Jonathan Vollebregt ([email protected]), Rokas Šleinius ([email protected])
9
 *
10
 * Permission is hereby granted, free of charge, to any person obtaining a copy of
11
 * this software and associated documentation files (the "Software"), to deal in
12
 * the Software without restriction, including without limitation the rights to
13
 * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
14
 * the Software, and to permit persons to whom the Software is furnished to do so,
15
 * subject to the following conditions:
16
 *
17
 * The above copyright notice and this permission notice shall be included in all
18
 * copies or substantial portions of the Software.
19
 *
20
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
22
 * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
23
 * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
24
 * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
25
 * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
26
 */
27
28
namespace Kint;
29
30
/**
31
 * @psalm-type PhpTokenArray = array{int, string, int}
32
 * @psalm-type PhpToken = string|PhpTokenArray
33
 * @psalm-type CallParameter = array{
34
 *   name: string,
35
 *   path: string,
36
 *   expression: bool,
37
 *   literal: bool,
38
 *   new_without_parens: bool,
39
 * }
40
 */
41
class CallFinder
42
{
43
    private static array $ignore = [
44
        T_CLOSE_TAG => true,
45
        T_COMMENT => true,
46
        T_DOC_COMMENT => true,
47
        T_INLINE_HTML => true,
48
        T_OPEN_TAG => true,
49
        T_OPEN_TAG_WITH_ECHO => true,
50
        T_WHITESPACE => true,
51
    ];
52
53
    /**
54
     * Things we need to do specially for operator tokens:
55
     * - Refuse to strip spaces around them
56
     * - Wrap the access path in parentheses if there
57
     *   are any of these in the final short parameter.
58
     */
59
    private static array $operator = [
60
        T_AND_EQUAL => true,
61
        T_BOOLEAN_AND => true,
62
        T_BOOLEAN_OR => true,
63
        T_ARRAY_CAST => true,
64
        T_BOOL_CAST => true,
65
        T_CLONE => true,
66
        T_CONCAT_EQUAL => true,
67
        T_DEC => true,
68
        T_DIV_EQUAL => true,
69
        T_DOUBLE_CAST => true,
70
        T_FUNCTION => true,
71
        T_INC => true,
72
        T_INCLUDE => true,
73
        T_INCLUDE_ONCE => true,
74
        T_INSTANCEOF => true,
75
        T_INT_CAST => true,
76
        T_IS_EQUAL => true,
77
        T_IS_GREATER_OR_EQUAL => true,
78
        T_IS_IDENTICAL => true,
79
        T_IS_NOT_EQUAL => true,
80
        T_IS_NOT_IDENTICAL => true,
81
        T_IS_SMALLER_OR_EQUAL => true,
82
        T_LOGICAL_AND => true,
83
        T_LOGICAL_OR => true,
84
        T_LOGICAL_XOR => true,
85
        T_MINUS_EQUAL => true,
86
        T_MOD_EQUAL => true,
87
        T_MUL_EQUAL => true,
88
        T_OBJECT_CAST => true,
89
        T_OR_EQUAL => true,
90
        T_PLUS_EQUAL => true,
91
        T_REQUIRE => true,
92
        T_REQUIRE_ONCE => true,
93
        T_SL => true,
94
        T_SL_EQUAL => true,
95
        T_SR => true,
96
        T_SR_EQUAL => true,
97
        T_STRING_CAST => true,
98
        T_UNSET_CAST => true,
99
        T_XOR_EQUAL => true,
100
        T_POW => true,
101
        T_POW_EQUAL => true,
102
        T_SPACESHIP => true,
103
        T_DOUBLE_ARROW => true,
104
        T_FN => true,
105
        T_COALESCE_EQUAL => true,
106
        '!' => true,
107
        '%' => true,
108
        '&' => true,
109
        '*' => true,
110
        '+' => true,
111
        '-' => true,
112
        '.' => true,
113
        '/' => true,
114
        ':' => true,
115
        '<' => true,
116
        '=' => true,
117
        '>' => true,
118
        '?' => true,
119
        '^' => true,
120
        '|' => true,
121
        '~' => true,
122
    ];
123
124
    private static array $preserve_spaces = [
125
        T_CLASS => true,
126
        T_NEW => true,
127
    ];
128
129
    private static array $strip = [
130
        '(' => true,
131
        ')' => true,
132
        '[' => true,
133
        ']' => true,
134
        '{' => true,
135
        '}' => true,
136
        T_OBJECT_OPERATOR => true,
137
        T_DOUBLE_COLON => true,
138
        T_NS_SEPARATOR => true,
139
    ];
140
141
    private static array $classcalls = [
142
        T_DOUBLE_COLON => true,
143
        T_OBJECT_OPERATOR => true,
144
    ];
145
146
    private static array $namespace = [
147
        T_STRING => true,
148
    ];
149
150
    /**
151
     * @psalm-param callable-array|callable-string $function
152
     *
153
     * @psalm-return list<array{parameters: list<CallParameter>, modifiers: list<PhpToken>}>
154
     *
155
     * @return array List of matching calls on the relevant line
156
     */
157
    public static function getFunctionCalls(string $source, int $line, $function): array
158
    {
159
        static $up = [
160
            '(' => true,
161
            '[' => true,
162
            '{' => true,
163
            T_CURLY_OPEN => true,
164
            T_DOLLAR_OPEN_CURLY_BRACES => true,
165
        ];
166
        static $down = [
167
            ')' => true,
168
            ']' => true,
169
            '}' => true,
170
        ];
171
        static $modifiers = [
172
            '!' => true,
173
            '@' => true,
174
            '~' => true,
175
            '+' => true,
176
            '-' => true,
177
        ];
178
        static $identifier = [
179
            T_DOUBLE_COLON => true,
180
            T_STRING => true,
181
            T_NS_SEPARATOR => true,
182
        ];
183
184
        if (KINT_PHP80) {
185
            $up[T_ATTRIBUTE] = true;
186
            self::$operator[T_MATCH] = true;
187
            self::$strip[T_NULLSAFE_OBJECT_OPERATOR] = true;
188
            self::$classcalls[T_NULLSAFE_OBJECT_OPERATOR] = true;
189
            self::$namespace[T_NAME_FULLY_QUALIFIED] = true;
190
            self::$namespace[T_NAME_QUALIFIED] = true;
191
            self::$namespace[T_NAME_RELATIVE] = true;
192
            $identifier[T_NAME_FULLY_QUALIFIED] = true;
193
            $identifier[T_NAME_QUALIFIED] = true;
194
            $identifier[T_NAME_RELATIVE] = true;
195
        }
196
197
        if (!KINT_PHP84) {
198
            self::$operator[T_NEW] = true; // @codeCoverageIgnore
199
        }
200
201
        /** @psalm-var list<PhpToken> */
202
        $tokens = \token_get_all($source);
203
        $function_calls = [];
204
205
        // Performance optimization preventing backwards loops
206
        /** @psalm-var array<PhpToken|null> */
207
        $prev_tokens = [null, null, null];
208
209
        if (\is_array($function)) {
210
            $class = \explode('\\', $function[0]);
211
            $class = \strtolower(\end($class));
212
            $function = \strtolower($function[1]);
213
        } else {
214
            $class = null;
215
            /**
216
             * @psalm-suppress RedundantFunctionCallGivenDocblockType
217
             * Psalm bug #11075
218
             */
219
            $function = \strtolower($function);
220
        }
221
222
        // Loop through tokens
223
        foreach ($tokens as $index => $token) {
224
            if (!\is_array($token)) {
225
                continue;
226
            }
227
228
            if ($token[2] > $line) {
229
                break;
230
            }
231
232
            // Store the last real tokens for later
233
            if (isset(self::$ignore[$token[0]])) {
234
                continue;
235
            }
236
237
            $prev_tokens = [$prev_tokens[1], $prev_tokens[2], $token];
238
239
            // The logic for 7.3 through 8.1 is far more complicated.
240
            // This should speed things up without making a lot more work for us
241
            if (KINT_PHP82 && $line !== $token[2]) {
242
                continue;
243
            }
244
245
            // Check if it's the right type to be the function we're looking for
246
            if (!isset(self::$namespace[$token[0]])) {
247
                continue;
248
            }
249
250
            $ns = \explode('\\', \strtolower($token[1]));
251
252
            if (\end($ns) !== $function) {
253
                continue;
254
            }
255
256
            // Check if it's a function call
257
            $nextReal = self::realTokenIndex($tokens, $index);
258
            if ('(' !== ($tokens[$nextReal] ?? null)) {
259
                continue;
260
            }
261
262
            // Check if it matches the signature
263
            if (null === $class) {
264
                if (null !== $prev_tokens[1] && isset(self::$classcalls[$prev_tokens[1][0]])) {
265
                    continue;
266
                }
267
            } else {
268
                if (null === $prev_tokens[1] || T_DOUBLE_COLON !== $prev_tokens[1][0]) {
269
                    continue;
270
                }
271
272
                if (null === $prev_tokens[0] || !isset(self::$namespace[$prev_tokens[0][0]])) {
273
                    continue;
274
                }
275
276
                // All self::$namespace tokens are T_ constants
277
                /**
278
                 * @psalm-var PhpTokenArray $prev_tokens[0]
279
                 * Psalm bug #746 (wontfix)
280
                 */
281
                $ns = \explode('\\', \strtolower($prev_tokens[0][1]));
282
283
                if (\end($ns) !== $class) {
284
                    continue;
285
                }
286
            }
287
288
            $last_line = $token[2];
289
            $depth = 1; // The depth respective to the function call
290
            $offset = $nextReal + 1; // The start of the function call
291
            $instring = false; // Whether we're in a string or not
292
            $realtokens = false; // Whether the current scope contains anything meaningful or not
293
            $paramrealtokens = false; // Whether the current parameter contains anything meaningful
294
            $params = []; // All our collected parameters
295
            $shortparam = []; // The short version of the parameter
296
            $param_start = $offset; // The distance to the start of the parameter
297
298
            // Loop through the following tokens until the function call ends
299
            while (isset($tokens[$offset])) {
300
                $token = $tokens[$offset];
301
302
                if (\is_array($token)) {
303
                    $last_line = $token[2];
304
                }
305
306
                if (!isset(self::$ignore[$token[0]]) && !isset($down[$token[0]])) {
307
                    $paramrealtokens = $realtokens = true;
308
                }
309
310
                // If it's a token that makes us to up a level, increase the depth
311
                if (isset($up[$token[0]])) {
312
                    if (1 === $depth) {
313
                        $shortparam[] = $token;
314
                        $realtokens = false;
315
                    }
316
317
                    ++$depth;
318
                } elseif (isset($down[$token[0]])) {
319
                    --$depth;
320
321
                    // If this brings us down to the parameter level, and we've had
322
                    // real tokens since going up, fill the $shortparam with an ellipsis
323
                    if (1 === $depth) {
324
                        if ($realtokens) {
325
                            $shortparam[] = '...';
326
                        }
327
                        $shortparam[] = $token;
328
                    }
329
                } elseif ('"' === $token || 'b"' === $token) {
330
                    // Strings use the same symbol for up and down, but we can
331
                    // only ever be inside one string, so just use a bool for that
332
                    if ($instring) {
333
                        --$depth;
334
                        if (1 === $depth) {
335
                            $shortparam[] = '...';
336
                        }
337
                    } else {
338
                        ++$depth;
339
                    }
340
341
                    $instring = !$instring;
0 ignored issues
show
introduced by
$instring is of type mixed, thus it always evaluated to false.
Loading history...
342
343
                    $shortparam[] = $token;
344
                } elseif (1 === $depth) {
345
                    if (',' === $token[0]) {
346
                        $params[] = [
347
                            'full' => \array_slice($tokens, $param_start, $offset - $param_start),
348
                            'short' => $shortparam,
349
                        ];
350
                        $shortparam = [];
351
                        $paramrealtokens = false;
352
                        $param_start = $offset + 1;
353
                    } elseif (T_CONSTANT_ENCAPSED_STRING === $token[0]) {
354
                        $quote = $token[1][0];
355
                        if ('b' === $quote) {
356
                            $quote = $token[1][1];
357
                            if (\strlen($token[1]) > 3) {
358
                                $token[1] = 'b'.$quote.'...'.$quote;
359
                            }
360
                        } else {
361
                            if (\strlen($token[1]) > 2) {
362
                                $token[1] = $quote.'...'.$quote;
363
                            }
364
                        }
365
                        $shortparam[] = $token;
366
                    } else {
367
                        $shortparam[] = $token;
368
                    }
369
                }
370
371
                // Depth has dropped to 0 (So we've hit the closing paren)
372
                if ($depth <= 0) {
373
                    if ($paramrealtokens) {
374
                        $params[] = [
375
                            'full' => \array_slice($tokens, $param_start, $offset - $param_start),
376
                            'short' => $shortparam,
377
                        ];
378
                    }
379
380
                    break;
381
                }
382
383
                ++$offset;
384
            }
385
386
            // If we're not passed (or at) the line at the end
387
            // of the function call, we're too early so skip it
388
            // Only applies to < 8.2 since we check line explicitly above that
389
            if (!KINT_PHP82 && $last_line < $line) {
390
                continue; // @codeCoverageIgnore
391
            }
392
393
            $formatted_parameters = [];
394
395
            // Format the final output parameters
396
            foreach ($params as $param) {
397
                $name = self::tokensFormatted($param['short']);
398
                $path = self::tokensToString(self::tokensTrim($param['full']));
399
                $expression = false;
400
                $literal = false;
401
                $new_without_parens = false;
402
403
                foreach ($name as $token) {
0 ignored issues
show
Comprehensibility Bug introduced by
$token is overwriting a variable from outer foreach loop.
Loading history...
404
                    if (self::tokenIsOperator($token)) {
405
                        $expression = true;
406
                        break;
407
                    }
408
                }
409
410
                // As of 8.4 new is only an expression when parentheses are
411
                // omitted. In that case we can cheat and add them ourselves.
412
                //
413
                // > PHP interprets the first expression after new as a class name
414
                // per https://wiki.php.net/rfc/new_without_parentheses
415
                if (KINT_PHP84 && !$expression && T_NEW === $name[0][0]) {
416
                    $had_name_token = false;
417
                    $new_without_parens = true;
418
419
                    foreach ($name as $token) {
420
                        if (T_NEW === $token[0]) {
421
                            continue;
422
                        }
423
424
                        if (isset(self::$ignore[$token[0]])) {
425
                            continue;
426
                        }
427
428
                        if (T_CLASS === $token[0]) {
429
                            $new_without_parens = false;
430
                            break;
431
                        }
432
433
                        if ('(' === $token && $had_name_token) {
434
                            $new_without_parens = false;
435
                            break;
436
                        }
437
438
                        $had_name_token = true;
439
                    }
440
                }
441
442
                if (!$expression && 1 === \count($name)) {
443
                    switch ($name[0][0]) {
444
                        case T_CONSTANT_ENCAPSED_STRING:
445
                        case T_LNUMBER:
446
                        case T_DNUMBER:
447
                            $literal = true;
448
                            break;
449
                        case T_STRING:
450
                            switch (\strtolower($name[0][1])) {
451
                                case 'null':
452
                                case 'true':
453
                                case 'false':
454
                                    $literal = true;
455
                            }
456
                    }
457
458
                    $name = self::tokensToString($name);
459
                } else {
460
                    $name = self::tokensToString($name);
461
462
                    if (!$expression) {
463
                        switch (\strtolower($name)) {
464
                            case 'array()':
465
                            case '[]':
466
                                $literal = true;
467
                                break;
468
                        }
469
                    }
470
                }
471
472
                $formatted_parameters[] = [
473
                    'name' => $name,
474
                    'path' => $path,
475
                    'expression' => $expression,
476
                    'literal' => $literal,
477
                    'new_without_parens' => $new_without_parens,
478
                ];
479
            }
480
481
            // Skip first-class callables
482
            if (KINT_PHP81 && 1 === \count($formatted_parameters) && '...' === \reset($formatted_parameters)['path']) {
483
                continue;
484
            }
485
486
            // Get the modifiers
487
            --$index;
488
489
            while (isset($tokens[$index])) {
490
                if (!isset(self::$ignore[$tokens[$index][0]]) && !isset($identifier[$tokens[$index][0]])) {
491
                    break;
492
                }
493
494
                --$index;
495
            }
496
497
            $mods = [];
498
499
            while (isset($tokens[$index])) {
500
                if (isset(self::$ignore[$tokens[$index][0]])) {
501
                    --$index;
502
                    continue;
503
                }
504
505
                if (isset($modifiers[$tokens[$index][0]])) {
506
                    $mods[] = $tokens[$index];
507
                    --$index;
508
                    continue;
509
                }
510
511
                break;
512
            }
513
514
            $function_calls[] = [
515
                'parameters' => $formatted_parameters,
516
                'modifiers' => $mods,
517
            ];
518
        }
519
520
        return $function_calls;
521
    }
522
523
    private static function realTokenIndex(array $tokens, int $index): ?int
524
    {
525
        ++$index;
526
527
        while (isset($tokens[$index])) {
528
            if (!isset(self::$ignore[$tokens[$index][0]])) {
529
                return $index;
530
            }
531
532
            ++$index;
533
        }
534
535
        return null;
536
    }
537
538
    /**
539
     * We need a separate method to check if tokens are operators because we
540
     * occasionally add "..." to short parameter versions. If we simply check
541
     * for `$token[0]` then "..." will incorrectly match the "." operator.
542
     *
543
     * @psalm-param PhpToken $token The token to check
544
     */
545
    private static function tokenIsOperator($token): bool
546
    {
547
        return '...' !== $token && isset(self::$operator[$token[0]]);
548
    }
549
550
    /**
551
     * @psalm-param PhpToken $token The token to check
552
     */
553
    private static function tokenPreserveWhitespace($token): bool
554
    {
555
        return self::tokenIsOperator($token) || isset(self::$preserve_spaces[$token[0]]);
556
    }
557
558
    private static function tokensToString(array $tokens): string
559
    {
560
        $out = '';
561
562
        foreach ($tokens as $token) {
563
            if (\is_string($token)) {
564
                $out .= $token;
565
            } else {
566
                $out .= $token[1];
567
            }
568
        }
569
570
        return $out;
571
    }
572
573
    private static function tokensTrim(array $tokens): array
574
    {
575
        foreach ($tokens as $index => $token) {
576
            if (isset(self::$ignore[$token[0]])) {
577
                unset($tokens[$index]);
578
            } else {
579
                break;
580
            }
581
        }
582
583
        $tokens = \array_reverse($tokens);
584
585
        foreach ($tokens as $index => $token) {
586
            if (isset(self::$ignore[$token[0]])) {
587
                unset($tokens[$index]);
588
            } else {
589
                break;
590
            }
591
        }
592
593
        return \array_reverse($tokens);
594
    }
595
596
    private static function tokensFormatted(array $tokens): array
597
    {
598
        $tokens = self::tokensTrim($tokens);
599
600
        $space = false;
601
        $attribute = false;
602
        // Keep space between "strip" symbols for different behavior for matches or closures
603
        // Normally we want to strip spaces between strip tokens: $x{...}[...]
604
        // However with closures and matches we don't: function (...) {...}
605
        $ignorestrip = false;
606
        $output = [];
607
        $last = null;
608
609
        if (T_FUNCTION === $tokens[0][0] ||
610
            T_FN === $tokens[0][0] ||
611
            (KINT_PHP80 && T_MATCH === $tokens[0][0])
612
        ) {
613
            $ignorestrip = true;
614
        }
615
616
        foreach ($tokens as $index => $token) {
617
            if (isset(self::$ignore[$token[0]])) {
618
                if ($space) {
619
                    continue;
620
                }
621
622
                $next = self::realTokenIndex($tokens, $index);
623
                if (null === $next) {
624
                    // This should be impossible, since we always call tokensTrim first
625
                    break; // @codeCoverageIgnore
626
                }
627
                $next = $tokens[$next];
628
629
                /**
630
                 * @psalm-var PhpToken $last
631
                 * Since we call tokensTrim we know we can't be here without a $last
632
                 */
633
                if ($attribute && ']' === $last[0]) {
634
                    $attribute = false;
635
                } elseif (!$ignorestrip && isset(self::$strip[$last[0]]) && !self::tokenPreserveWhitespace($next)) {
636
                    continue;
637
                }
638
639
                if (!$ignorestrip && isset(self::$strip[$next[0]]) && !self::tokenPreserveWhitespace($last)) {
640
                    continue;
641
                }
642
643
                $token[1] = ' ';
644
                $space = true;
645
            } else {
646
                if (KINT_PHP80 && null !== $last && T_ATTRIBUTE === $last[0]) {
647
                    $attribute = true;
648
                }
649
650
                $space = false;
651
                $last = $token;
652
            }
653
654
            $output[] = $token;
655
        }
656
657
        return $output;
658
    }
659
}
660