Passed
Push — master ( fab1ff...bbc3bd )
by Marwan
09:15
created

Dumper::exportExpressionWithSyntaxHighlighting()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 30
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 2
eloc 17
c 1
b 0
f 1
nc 2
nop 2
dl 0
loc 30
ccs 15
cts 15
cp 1
crap 2
rs 9.7
1
<?php
2
3
/**
4
 * @author Marwan Al-Soltany <[email protected]>
5
 * @copyright Marwan Al-Soltany 2021
6
 * For the full copyright and license information, please view
7
 * the LICENSE file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace MAKS\Velox\Helper;
13
14
use MAKS\Velox\Frontend\HTML;
15
16
/**
17
 * A class that dumps variables and exception in a nice formatting.
18
 */
19
class Dumper
20
{
21
    /**
22
     * Accent color of exceptions page and dump block.
23
     */
24
    public static string $accentColor = '#ff3a60';
25
26
    /**
27
     * Contrast color of exceptions page and dump block.
28
     */
29
    public static string $contrastColor = '#030035';
30
31
    /**
32
     * Colors of syntax tokens.
33
     *
34
     * @var string[]
35
     */
36
    public static array $syntaxHighlightColors = [
37
        'comment' => '#aeaeae',
38
        'keyword' => '#00bfff',
39
        'string'  => '#e4ba80',
40
        'default' => '#e8703a',
41
        'html'    => '#ab8703',
42
    ];
43
44
    /**
45
     * Additional CSS styling of syntax tokens.
46
     *
47
     * @var string[]
48
     */
49
    public static array $syntaxHighlightStyles = [
50
        'comment' => 'font-weight: lighter;',
51
        'keyword' => 'font-weight: bold;',
52
        'string'  => '',
53
        'default' => '',
54
        'html'    => '',
55
    ];
56
57
    private static array $syntaxHighlightTokens = ['comment', 'keyword', 'string', 'default', 'html'];
58
59
60
    /**
61
     * Dumps a variable and dies.
62
     *
63
     * @param mixed ...$variable
64
     *
65
     * @return void The result will simply get echoed.
66
     *
67
     * @codeCoverageIgnore
68
     */
69
    public static function dd(...$variable): void
70
    {
71
        self::dump(...$variable);
72
        die;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
73
    }
74
75
    /**
76
     * Dumps a variable in a nice HTML block with syntax highlighting.
77
     *
78
     * @param mixed ...$variable
79
     *
80
     * @return void The result will simply get echoed.
81
     */
82 2
    public static function dump(...$variable): void
83
    {
84 2
        self::setSyntaxHighlighting();
85 2
        $accentColor   = self::$accentColor;
86 2
        $contrastColor = self::$contrastColor;
87
88 2
        $isCli = self::isCli();
89 2
        $trace = self::getValidCallerTrace();
90
91
        $blocks = [
92 2
            'traceBlock' => $isCli ? "\n// \e[33;1mTRACE:\e[0m \e[34;46m[{$trace}]\e[0m \n\n" : HTML::div($trace, [
0 ignored issues
show
Bug Best Practice introduced by
The method MAKS\Velox\Frontend\HTML::div() is not static, but was called statically. ( Ignorable by Annotation )

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

92
            'traceBlock' => $isCli ? "\n// \e[33;1mTRACE:\e[0m \e[34;46m[{$trace}]\e[0m \n\n" : HTML::/** @scrutinizer ignore-call */ div($trace, [
Loading history...
93 2
                'style' => "background:#fff;color:{$accentColor};font-family:-apple-system,'Fira Sans',Ubuntu,Helvetica,Arial,sans-serif;font-size:12px;padding:4px 8px;margin-bottom:18px;"
94
            ]),
95 2
            'dumpBlock' => $isCli ? '%s' : HTML::div('%s', [
96 2
                'style' => "display:table;background:{$contrastColor};color:#fff;font-family:'Fira Code','Ubuntu Mono',Courier,monospace;font-size:18px;padding:18px;margin:8px;"
97
            ]),
98 2
            'timeBlock' => $isCli ? "\n\n// \e[36mSTART_TIME\e[0m + \e[35m%.2f\e[0mms \n\n\n" : HTML::div('START_TIME + %.2fms', [
99 2
                'style' => "display:table;background:{$accentColor};color:#fff;font-family:'Fira Code','Ubuntu Mono',Courier,monospace;font-size:12px;font-weight:bold;padding:12px;margin:8px;"
100
            ]),
101
        ];
102
103 2
        foreach ($variable as $dump) {
104 2
            $highlightedDump = self::exportExpressionWithSyntaxHighlighting($dump, $blocks['traceBlock']);
105 2
            printf($blocks['dumpBlock'], $highlightedDump);
106
        }
107
108 2
        $time = (microtime(true) - START_TIME) * 1000;
109 2
        printf($blocks['timeBlock'], $time);
110 2
    }
111
112
    /**
113
     * Dumps an exception in a nice HTML page or as string and exits the script.
114
     *
115
     * @param \Throwable $exception
116
     *
117
     * @return void The result will be echoed as HTML page or a string representation of the exception if the interface is CLI.
118
     *
119
     * @codeCoverageIgnore
120
     */
121
    public static function dumpException(\Throwable $exception): void
122
    {
123
        if (self::isCli()) {
124
            echo $exception;
125
            exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
126
        }
127
128
        self::setSyntaxHighlighting();
129
130
        $file        = $exception->getFile();
131
        $line        = $exception->getLine();
132
        $message     = $exception->getMessage();
133
        $trace       = $exception->getTrace();
134
        $traceString = $exception->getTraceAsString();
135
        $name        = get_class($exception);
136
        $filename    = basename($file);
137
        $lines       = file_exists($file) ? file($file) : null;
138
139
        $accentColor   = self::$accentColor;
140
        $contrastColor = self::$contrastColor;
141
142
        $style = ":root{--accent-color:{$accentColor};--contrast-color:{$contrastColor}}*,::after,::before{box-sizing:border-box}body{background:#fff;font-family:-apple-system,'Fira Sans',Ubuntu,Helvetica,Arial,sans-serif;font-size:16px;line-height:1.5;margin:0}h1,h2,h3,h4,h5,h6{margin:0}h1,h2{color:var(--accent-color)}h1{font-size:32px}h2{font-size:28px}h3{color:#fff}.container{width:85vw;max-width:1200px;min-height:100vh;background:#fff;padding:7vh 3vw 10vh 3vw;margin:0 auto;overflow:hidden}.message{background:var(--accent-color);color:#fff;padding:2em 1em;margin:0 0 3em 0;}.code{overflow-y:scroll;font-family:'Fira Code','Ubuntu Mono',Courier,monospace;font-size:14px;margin:0 0 3em 0;-ms-overflow-style: none;scrollbar-width: none}.code::-webkit-scrollbar{display:none}pre{white-space:pre-wrap;white-space:-moz-pre-wrap;white-space:-o-pre-wrap;word-wrap:break-word}ul{padding:2em 1em;margin:1em 0;background:var(--contrast-color)}ul li{white-space:pre;list-style-type:none;font-family:monospace}ul li span.line{display:inline-block;color:#fff;text-align:right;padding:4px 8px;user-select:none}ul li.exception-line span.line{color:var(--accent-color);font-weight:bold}ul li.exception-line span.line+code>span>span:not(:first-child){padding-bottom:3px;border-bottom:2px solid var(--accent-color)}table{width:100%;border-collapse:collapse;border-spacing:0}table th{background:var(--contrast-color);color:#fff;text-align:left;padding-top:12px;padding-bottom:12px}table td,table th{border-bottom:1px solid rgba(0,0,0,0.15);padding:6px}table tr:nth-child(even){background-color:rgba(0,0,0,0.05)}table td.number{text-align:left}table td.line{text-align:left}table td.class,table td.function{font-family:'Fira Code','Ubuntu Mono',Courier,monospace;font-size:14px;font-weight:700}table td.arguments span{display:inline-block;background:rgba(0,0,0,.15);color:var(--accent-color);font-style:italic;padding:2px 4px;margin:0 4px 0 0;border-radius:4px}";
143
144
        (new HTML(false))
145
            ->node('<!DOCTYPE html>')
146
            ->open('html', ['lang' => 'en'])
147
                ->open('head')
148
                    ->title('Oops, something went wrong')
149
                    ->style($style)
150
                ->close()
151
                ->open('body')
152
                    ->open('div', ['class' => 'container'])
153
                        ->h1("Uncaught {$name}")
154
                        ->p("An <b>{$name}</b> was thrown on line {$line} of file {$filename} which prevented further execution of the code.")
155
                        ->open('div', ['class' => 'message'])
156
                            ->h3($name)
157
                            ->p($message)
158
                        ->close()
159
                        ->h2('Thrown in:')
160
                        ->execute(function (HTML $html) use ($file, $line, $lines) {
161
                            if (isset($lines)) {
162
                                $html->p($file);
163
                                $html->open('ul', ['class' => 'code']);
164
                                for ($i = $line - 3; $i < $line + 4; $i++) {
165
                                    if ($i > 0 && $i < count($lines)) {
166
                                        $highlightedCode = highlight_string('<?php ' . $lines[$i], true);
167
                                        $highlightedCode = preg_replace(
168
                                            ['/\n/', '/<br ?\/?>/', '/&lt;\?php&nbsp;/'],
169
                                            ['', '', ''],
170
                                            $highlightedCode
171
                                        );
172
                                        if ($i == $line - 1) {
173
                                            $arrow = str_pad('>', strlen("{$i}"), '=', STR_PAD_LEFT);
174
                                            $html
175
                                                ->open('li', ['class' => 'exception-line'])
176
                                                    ->span($arrow, ['class' => 'line'])
177
                                                    ->node($highlightedCode)
178
                                                ->close();
179
                                        } else {
180
                                            $number = strval($i + 1);
181
                                            $html
182
                                                ->open('li')
183
                                                    ->span($number, ['class' => 'line'])
184
                                                    ->node($highlightedCode)
185
                                                ->close();
186
                                        }
187
                                    }
188
                                }
189
                                $html->close();
190
                            }
191
                        })
192
                        ->h2('Stack trace:')
193
                        ->execute(function (HTML $html) use ($trace, $traceString) {
194
                            if (count($trace)) {
195
                                $html->p('<i>Hover on fields with * to reveal more info.</i>');
196
                                $html->open('table', ['class' => 'trace'])
197
                                    ->open('thead')
198
                                        ->open('tr')
199
                                            ->th('No.')
200
                                            ->th('File *')
201
                                            ->th('Line')
202
                                            ->th('Class')
203
                                            ->th('Function')
204
                                            ->th('Arguments *')
205
                                        ->close()
206
                                    ->close()
207
                                    ->open('tbody')
208
                                    ->execute(function (HTML $html) use ($trace) {
209
                                        foreach ($trace as $i => $trace) {
210
                                            $count = (int)$i + 1;
211
                                            $html
212
                                            ->open('tr', ['class' => $count % 2 == 0 ? 'even' : 'odd'])
213
                                                ->td(strval($count), ['class' => 'number'])
214
                                                ->td(isset($trace['file']) ? basename($trace['file']) : '', ['class' => 'file', 'title' => $trace['file'] ?? false])
215
                                                ->td(strval($trace['line'] ?? ''), ['class' => 'line'])
216
                                                ->td(strval($trace['class'] ?? ''), ['class' => 'class'])
217
                                                ->td(strval($trace['function'] ?? ''), ['class' => 'function'])
218
                                                ->open('td', ['class' => 'arguments'])
219
                                                ->execute(function (HTML $html) use ($trace) {
220
                                                    if (isset($trace['args'])) {
221
                                                        foreach ($trace['args'] as $i => $arg) {
222
                                                            $html->span(gettype($arg), ['title' => print_r($arg, true)]);
223
                                                        }
224
                                                    } else {
225
                                                        $html->node('NULL');
226
                                                    }
227
                                                })
228
                                                ->close()
229
                                            ->close();
230
                                        }
231
                                    })
232
                                    ->close()
233
                                ->close();
234
                            } else {
235
                                $html->pre($traceString);
236
                            }
237
                        })
238
                    ->close()
239
                ->close()
240
            ->close()
241
        ->echo();
242
243
        exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
244
    }
245
246
    /**
247
     * Dumps an expression using `var_export()` or `print_r()`.
248
     *
249
     * @param mixed $expression
250
     *
251
     * @return string
252
     */
253 2
    private static function exportExpression($expression): string
254
    {
255 2
        $export = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $export is dead and can be removed.
Loading history...
256
257
        try {
258 2
            $export = var_export($expression, true);
259 1
        } catch (\Throwable $e) {
260 1
            $class = self::class;
261 1
            $line1 = "// {$class} failed to dump the variable. Reason: {$e->getMessage()}. " . PHP_EOL;
262 1
            $line2 = "// here is a dump of the variable using print_r()" . PHP_EOL . PHP_EOL . PHP_EOL;
263
264 1
            return $line1 . $line2 . print_r($expression, true);
0 ignored issues
show
Bug introduced by
Are you sure print_r($expression, true) of type string|true can be used in concatenation? ( Ignorable by Annotation )

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

264
            return $line1 . $line2 . /** @scrutinizer ignore-type */ print_r($expression, true);
Loading history...
265
        }
266
267
        // convert array construct to square brackets
268
        $acToSbPatterns = [
269 1
            '/(\()array\(/'                         => '$1[',
270
            '/\)(\))/'                              => ']$1',
271
            '/array \(/'                            => '[',
272
            '/\(object\) array\(/'                  => '(object)[',
273
            '/^([ ]*)\)(,?)$/m'                     => '$1]$2',
274
            '/\[\n\]/'                              => '[]',
275
            '/\[[ ]?\n[ ]+\]/'                      => '[]',
276
            '/=>[ ]?\n[ ]+(\[|\()/'                 => '=> $1',
277
            '/=>[ ]?\n[ ]+([a-zA-Z0-9_\x7f-\xff])/' => '=> $1',
278
            '/(\n)([ ]*)\]\)/'                      => '$1$2  ])',
279
            '/([ ]*)(\'[^\']+\') => ([\[\'])/'      => '$1$2 => $3',
280
        ];
281
282 1
        return preg_replace(
283 1
            array_keys($acToSbPatterns),
284 1
            array_values($acToSbPatterns),
285
            $export
286
        );
287
    }
288
289
    /**
290
     * Dumps an expression using `var_export()` or `print_r()` with syntax highlighting.
291
     *
292
     * @param mixed $expression
293
     * @param string|null $phpReplacement `<?php` replacement.
294
     *
295
     * @return string
296
     */
297 2
    private static function exportExpressionWithSyntaxHighlighting($expression, ?string $phpReplacement = ''): string
298
    {
299 2
        $export = self::exportExpression($expression);
300
301 2
        $code = highlight_string('<?php ' . $export, true);
302 2
        $html = preg_replace(
303 2
            '/&lt;\?php&nbsp;/',
304 2
            $phpReplacement ?? '',
305
            $code
306
        );
307
308 2
        if (!self::isCli()) {
309
            // @codeCoverageIgnoreStart
310
            return $html;
311
            // @codeCoverageIgnoreEnd
312
        }
313
314 2
        $mixed = preg_replace_callback(
315 2
            '/@CLR\((#\w+)\)/',
316 2
            fn ($matches) => self::getAnsiCodeFromHexColor($matches[1]),
317 2
            preg_replace(
318 2
                ['/<\w+\s+style="color:\s*(#[a-z0-9]+)">(.*?)<\/\w+>/im', '/<br ?\/?>/', '/&nbsp;/'],
319 2
                ["\e[@CLR($1)m$2\e[0m", "\n", " "],
320
                $html
321
            )
322
        );
323
324 2
        $ansi = trim(html_entity_decode(strip_tags($mixed)));
325
326 2
        return $ansi;
327
    }
328
329 2
    private static function getValidCallerTrace(): string
330
    {
331 2
        $trace = 'Trace: N/A';
332
333 2
        array_filter(array_reverse(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)), function ($backtrace) use (&$trace) {
334 2
            static $hasFound = false;
335 2
            if (!$hasFound && in_array($backtrace['function'], ['dump', 'dd'])) {
336 2
                $trace = $backtrace['file'] . ':' . $backtrace['line'];
337 2
                $hasFound = true;
338
339 2
                return true;
340
            }
341
342 2
            return false;
343 2
        });
344
345 2
        return $trace;
346
    }
347
348 2
    private static function getAnsiCodeFromHexColor(string $color): int
349
    {
350
        $colors = [
351 2
            'black'   => ['ansi' => 30, 'rgb' => [0, 0, 0]],
352
            'red'     => ['ansi' => 31, 'rgb' => [255, 0, 0]],
353
            'green'   => ['ansi' => 32, 'rgb' => [0, 128, 0]],
354
            'yellow'  => ['ansi' => 33, 'rgb' => [255, 255, 0]],
355
            'blue'    => ['ansi' => 34, 'rgb' => [0, 0, 255]],
356
            'magenta' => ['ansi' => 35, 'rgb' => [255, 0, 255]],
357
            'cyan'    => ['ansi' => 36, 'rgb' => [0, 255, 255]],
358
            'white'   => ['ansi' => 37, 'rgb' => [255, 255, 255]],
359
            'default' => ['ansi' => 39, 'rgb' => [128, 128, 128]],
360
        ];
361
362 2
        $hexClr = ltrim($color, '#');
363 2
        $hexNum = strval(strlen($hexClr));
364
        $hexPos = [
365 2
            '3' => [0, 0, 1, 1, 2, 2],
366
            '6' => [0, 1, 2, 3, 4, 5],
367
        ];
368
369
        [$r, $g, $b] = [
370 2
            $hexClr[$hexPos[$hexNum][0]] . $hexClr[$hexPos[$hexNum][1]],
371 2
            $hexClr[$hexPos[$hexNum][2]] . $hexClr[$hexPos[$hexNum][3]],
372 2
            $hexClr[$hexPos[$hexNum][4]] . $hexClr[$hexPos[$hexNum][5]],
373
        ];
374
375 2
        $color = [hexdec($r), hexdec($g), hexdec($b)];
376
377 2
        $distances = [];
378 2
        foreach ($colors as $name => $values) {
379 2
            $distances[$name] = sqrt(
380 2
                pow($values['rgb'][0] - $color[0], 2) +
381 2
                pow($values['rgb'][1] - $color[1], 2) +
382 2
                pow($values['rgb'][2] - $color[2], 2)
383
            );
384
        }
385
386 2
        $colorName = '';
387 2
        $minDistance = pow(2, 30);
388 2
        foreach ($distances as $key => $value) {
389 2
            if ($value < $minDistance) {
390 2
                $minDistance = $value;
391 2
                $colorName   = $key;
392
            }
393
        }
394
395 2
        return $colors[$colorName]['ansi'];
396
    }
397
398
    /**
399
     * @codeCoverageIgnore
400
     */
401
    private static function setSyntaxHighlighting(): void
402
    {
403
        if (self::isCli()) {
404
            // use default entries for better contrast.
405
            return;
406
        }
407
408
        $tokens = self::$syntaxHighlightTokens;
409
410
        foreach ($tokens as $token) {
411
            $color = self::$syntaxHighlightColors[$token] ?? ini_get("highlight.{$token}");
412
            $style = self::$syntaxHighlightStyles[$token] ?? chr(8);
413
414
            $highlighting = sprintf('%s;%s', $color, $style);
415
416
            ini_set("highlight.{$token}", $highlighting);
417
        }
418
    }
419
420 2
    private static function isCli(): bool
421
    {
422 2
        return php_sapi_name() === 'cli';
423
    }
424
}
425