Passed
Push — master ( e0e43e...d478f0 )
by Dmitriy
03:11 queued 02:35
created

ClosureExporter::formatClosure()   B

Complexity

Conditions 8
Paths 16

Size

Total Lines 22
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 8.0189

Importance

Changes 0
Metric Value
cc 8
eloc 14
nc 16
nop 2
dl 0
loc 22
ccs 14
cts 15
cp 0.9333
crap 8.0189
rs 8.4444
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\VarDumper;
6
7
use Closure;
8
use ReflectionException;
9
use ReflectionFunction;
10
11
use function array_filter;
12
use function array_pop;
13
use function array_shift;
14
use function array_slice;
15
use function explode;
16
use function file;
17
use function implode;
18
use function in_array;
19
use function is_array;
20
use function is_string;
21
use function mb_strlen;
22
use function mb_substr;
23
use function strpos;
24
use function token_get_all;
25
use function trim;
26
27
/**
28
 * ClosureExporter exports PHP {@see \Closure} as a string containing PHP code.
29
 *
30
 * The string is a valid PHP expression that can be evaluated by PHP parser
31
 * and the evaluation result will give back the closure instance.
32
 */
33
final class ClosureExporter
34
{
35
    private UseStatementParser $useStatementParser;
36
37 10
    public function __construct()
38
    {
39 10
        $this->useStatementParser = new UseStatementParser();
40
    }
41
42
    /**
43
     * Export closure as a string containing PHP code.
44
     *
45
     * @param Closure $closure Closure to export.
46
     * @param int $level Level for padding.
47
     *
48
     * @throws ReflectionException
49
     *
50
     * @return string String containing PHP code.
51
     */
52 65
    public function export(Closure $closure, int $level = 0): string
53
    {
54 65
        $reflection = new ReflectionFunction($closure);
55
56 65
        $fileName = $reflection->getFileName();
57 65
        $start = $reflection->getStartLine();
58 65
        $end = $reflection->getEndLine();
59
60 65
        if ($fileName === false || $start === false || $end === false || ($fileContent = file($fileName)) === false) {
61
            return 'function () {/* Error: unable to determine Closure source */}';
62
        }
63
64 65
        --$start;
65 65
        $uses = $this->useStatementParser->fromFile($fileName);
66 65
        $tokens = token_get_all('<?php ' . implode('', array_slice($fileContent, $start, $end - $start)));
67 65
        array_shift($tokens);
68
69 65
        $bufferUse = '';
70 65
        $closureTokens = [];
71 65
        $pendingParenthesisCount = 0;
72
73
        /** @var int<1, max> $i */
74 65
        foreach ($tokens as $i => $token) {
75 65
            if (in_array($token[0], [T_FUNCTION, T_FN, T_STATIC], true)) {
76 65
                $closureTokens[] = $token[1];
77 65
                continue;
78
            }
79
80 65
            if ($closureTokens === []) {
81 65
                continue;
82
            }
83
84 65
            $readableToken = is_array($token) ? $token[1] : $token;
85
86 65
            if ($this->useStatementParser->isTokenIsPartOfUse($token)) {
87 40
                if ($this->isUseConsistingOfMultipleParts($readableToken)) {
88 8
                    $readableToken = $this->processFullUse($readableToken, $uses);
89 8
                    $bufferUse = '';
90 40
                } elseif (isset($uses[$readableToken])) {
91 25
                    if (isset($tokens[$i + 1]) && $this->useStatementParser->isTokenIsPartOfUse($tokens[$i + 1])) {
92
                        $bufferUse .= $uses[$readableToken];
93
                        continue;
94
                    }
95 25
                    $readableToken = $uses[$readableToken];
96 17
                } elseif ($readableToken === '\\' && isset($tokens[$i - 1][1]) && $tokens[$i - 1][1] === '\\') {
97
                    continue;
98 17
                } elseif (isset($tokens[$i + 1]) && $this->useStatementParser->isTokenIsPartOfUse($tokens[$i + 1])) {
99
                    $bufferUse .= $readableToken;
100
                    continue;
101
                }
102 40
                if (!empty($bufferUse)) {
103
                    if ($bufferUse !== $readableToken && strpos($readableToken, $bufferUse) === false) {
104
                        $readableToken = $bufferUse . $readableToken;
105
                    }
106
                    $bufferUse = '';
107
                }
108
            }
109
110 65
            if (is_string($token)) {
111 65
                if ($this->isOpenParenthesis($token)) {
112 65
                    $pendingParenthesisCount++;
113 65
                } elseif ($this->isCloseParenthesis($token)) {
114 65
                    if ($pendingParenthesisCount === 0) {
115 14
                        break;
116
                    }
117 65
                    --$pendingParenthesisCount;
118 56
                } elseif ($token === ',' || $token === ';') {
119 53
                    if ($pendingParenthesisCount === 0) {
120 51
                        break;
121
                    }
122
                }
123
            }
124
125 65
            $closureTokens[] = $readableToken;
126
        }
127
128 65
        return $this->formatClosure(implode('', $closureTokens), $level);
129
    }
130
131 65
    private function formatClosure(string $code, int $level): string
132
    {
133 65
        $code = explode("\n", $code);
134 65
        $fistLine = array_shift($code);
135 65
        $minimumIndent = null;
136 65
        for ($i = 1; $i <= count($code) - 1; $i++) {
137 13
            $indent = strspn($code[$i], ' ');
138 13
            if ($indent === mb_strlen($code[$i])) {
139
                continue;
140
            }
141 13
            if ($minimumIndent === null || $indent < $minimumIndent) {
142 13
                $minimumIndent = $indent;
143
            }
144
        }
145 65
        if ($minimumIndent > 0) {
146 13
            foreach ($code as $index => $line) {
147 13
                $code[$index] = mb_substr($line, $minimumIndent);
0 ignored issues
show
Bug introduced by
$minimumIndent of type null is incompatible with the type integer expected by parameter $start of mb_substr(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

147
                $code[$index] = mb_substr($line, /** @scrutinizer ignore-type */ $minimumIndent);
Loading history...
148
            }
149
        }
150
151 65
        $spaces = $level <= 1 ? '' : str_repeat(' ', ($level - 1) * 4);
152 65
        return implode("\n" . $spaces, [$fistLine, ...$code]);
153
    }
154
155
    /**
156
     * Returns the last part from the use statement data.
157
     *
158
     * @param string $use The full use statement data.
159
     *
160
     * @return string The last part from the use statement data.
161
     */
162 8
    private function getUseLastPart(string $use): string
163
    {
164 8
        $parts = array_filter(explode('\\', $use));
165 8
        return (string) array_pop($parts);
166
    }
167
168
    /**
169
     * Processes and returns the full use statement data.
170
     *
171
     * @param string $use The use statement data to process.
172
     * @param array<string, string> $uses The use statement data.
173
     *
174
         * @return string The processed full use statement.
175
     */
176 8
    private function processFullUse(string $use, array $uses): string
177
    {
178 8
        $lastPart = $this->getUseLastPart($use);
179
180 8
        if (isset($uses[$lastPart])) {
181 6
            return $uses[$lastPart];
182
        }
183
184 2
        $result = '';
185
186
        do {
187 2
            $lastPart = $this->getUseLastPart($use);
188 2
            $use = mb_substr($use, 0, -mb_strlen("\\{$lastPart}"));
189 2
            $result = ($uses[$lastPart] ?? $lastPart) . '\\' . $result;
190 2
        } while (!empty($lastPart) && !isset($uses[$lastPart]));
191
192 2
        return '\\' . trim($result, '\\');
193
    }
194
195
    /**
196
     * Checks whether the use statement data consists of multiple parts.
197
     *
198
     * @param string $use The use statement data.
199
     *
200
     * @return bool Whether the use statement data consists of multiple parts.
201
     */
202 40
    private function isUseConsistingOfMultipleParts(string $use): bool
203
    {
204 40
        return $use !== '\\' && strpos($use, '\\') !== false;
205
    }
206
207
    /**
208
     * Checks whether the value of the token is an opening parenthesis.
209
     *
210
     * @param string $value The token value.
211
     *
212
     * @return bool Whether the value of the token is an opening parenthesis.
213
     */
214 65
    private function isOpenParenthesis(string $value): bool
215
    {
216 65
        return in_array($value, ['{', '[', '(']);
217
    }
218
219
    /**
220
     * Checks whether the value of the token is a closing parenthesis.
221
     *
222
     * @param string $value The token value.
223
     *
224
     * @return bool Whether the value of the token is a closing parenthesis.
225
     */
226 65
    private function isCloseParenthesis(string $value): bool
227
    {
228 65
        return in_array($value, ['}', ']', ')']);
229
    }
230
}
231