Passed
Push — master ( 57cfb9...a2aa4e )
by Marwan
10:09
created

Dumper::dump()   B

Complexity

Conditions 6
Paths 32

Size

Total Lines 35
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 6

Importance

Changes 8
Bugs 0 Features 1
Metric Value
eloc 21
c 8
b 0
f 1
nc 32
nop 1
dl 0
loc 35
ccs 21
cts 21
cp 1
cc 6
crap 6
rs 8.9617
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\App;
15
use MAKS\Velox\Frontend\HTML;
16
17
/**
18
 * A class that dumps variables and exception in a nice formatting.
19
 */
20
class Dumper
21
{
22
    /**
23
     * Accent color of exceptions page and dump block.
24
     */
25
    public static string $accentColor = '#ff3a60';
26
27
    /**
28
     * Contrast color of exceptions page and dump block.
29
     */
30
    public static string $contrastColor = '#030035';
31
32
    /**
33
     * Colors of syntax tokens.
34
     *
35
     * @var string[]
36
     */
37
    public static array $syntaxHighlightColors = [
38
        'comment' => '#aeaeae',
39
        'keyword' => '#00bfff',
40
        'string'  => '#e4ba80',
41
        'default' => '#e8703a',
42
        'html'    => '#ab8703',
43
    ];
44
45
    /**
46
     * Additional CSS styling of syntax tokens.
47
     *
48
     * @var string[]
49
     */
50
    public static array $syntaxHighlightStyles = [
51
        'comment' => 'font-weight: lighter;',
52
        'keyword' => 'font-weight: bold;',
53
        'string'  => '',
54
        'default' => '',
55
        'html'    => '',
56
    ];
57
58
    private static array $syntaxHighlightTokens = ['comment', 'keyword', 'string', 'default', 'html'];
59
60
61
    /**
62
     * Dumps a variable and dies.
63
     *
64
     * @param mixed ...$variable
65
     *
66
     * @return void The result will simply get echoed.
67
     *
68
     * @codeCoverageIgnore
69
     */
70
    public static function dd(...$variable): void
71
    {
72
        self::dump(...$variable);
73
74
        App::terminate();
75
    }
76
77
    /**
78
     * Dumps a variable in a nice HTML block with syntax highlighting.
79
     *
80
     * @param mixed ...$variable
81
     *
82
     * @return void The result will simply get echoed.
83
     */
84 2
    public static function dump(...$variable): void
85
    {
86 2
        self::setSyntaxHighlighting();
87 2
        $accentColor   = self::$accentColor;
88 2
        $contrastColor = self::$contrastColor;
89
90 2
        $isCli = self::isCli();
91 2
        $trace = self::getValidCallerTrace();
92
93
        $blocks = [
94 2
            'wrapper' => $isCli ? '%s' : HTML::div('<style>.dump * {background:transparent;padding:0;}</style><div class="dump">%s</div>', [
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

94
            'wrapper' => $isCli ? '%s' : HTML::/** @scrutinizer ignore-call */ div('<style>.dump * {background:transparent;padding:0;}</style><div class="dump">%s</div>', [
Loading history...
95 2
                'id' => 'dump'
96
            ]),
97 2
            'traceBlock' => $isCli ? "\n// \e[33;1mTRACE:\e[0m \e[34;46m[{$trace}]\e[0m \n\n" : HTML::div($trace, [
98 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;"
99
            ]),
100 2
            'dumpBlock' => $isCli ? '%s' : HTML::div('%s', [
101 2
                'style' => "display:table;background:{$contrastColor};color:#fff;font-family:'Fira Code','Ubuntu Mono',Courier,monospace;font-size:18px;padding:18px;margin-bottom:8px;"
102
            ]),
103 2
            'timeBlock' => $isCli ? "\n\n// \e[36mSTART_TIME\e[0m + \e[35m%.2f\e[0mms \n\n\n" : HTML::div('START_TIME + %.2fms', [
104 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-bottom:8px;"
105
            ]),
106
        ];
107
108 2
        $html = '';
109
110 2
        foreach ($variable as $dump) {
111 2
            $highlightedDump = self::exportExpressionWithSyntaxHighlighting($dump, $blocks['traceBlock']);
112 2
            $html .= sprintf($blocks['dumpBlock'], $highlightedDump);
113
        }
114
115 2
        $time = (microtime(true) - START_TIME) * 1000;
116 2
        $html .= sprintf($blocks['timeBlock'], $time);
117
118 2
        printf($blocks['wrapper'], $html);
119 2
    }
120
121
    /**
122
     * Dumps an exception in a nice HTML page or as string and exits the script.
123
     *
124
     * @param \Throwable $exception
125
     *
126
     * @return void The result will be echoed as HTML page or a string representation of the exception if the interface is CLI.
127
     *
128
     * @codeCoverageIgnore
129
     */
130
    public static function dumpException(\Throwable $exception): void
131
    {
132
        if (self::isCli()) {
133
            echo $exception;
134
135
            App::terminate();
136
        }
137
138
        self::setSyntaxHighlighting();
139
140
        $file        = $exception->getFile();
141
        $line        = $exception->getLine();
142
        $message     = $exception->getMessage();
143
        $trace       = $exception->getTrace();
144
        $traceString = $exception->getTraceAsString();
145
        $name        = get_class($exception);
146
        $filename    = basename($file);
147
        $lines       = file_exists($file) ? file($file) : null;
148
149
        $accentColor   = self::$accentColor;
150
        $contrastColor = self::$contrastColor;
151
152
        $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)}.external-link{color:var(--accent-color)}.table-container{overflow-x:scroll}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{white-space:nowrap}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}";
153
154
        (new HTML(false))
155
            ->node('<!DOCTYPE html>')
156
            ->open('html', ['lang' => 'en'])
157
                ->open('head')
158
                    ->title('Oops, something went wrong')
159
                    ->style($style)
160
                ->close()
161
                ->open('body')
162
                    ->open('div', ['class' => 'container'])
163
                        ->h1("Uncaught {$name}")
164
                        ->p("<code><b>{$name}</b></code> was thrown on line <code><b>{$line}</b></code> of file <code><b>{$filename}</b></code> which prevented further execution of the code.")
165
                        ->open('div', ['class' => 'message'])
166
                            ->h3($name)
167
                            ->p((string)htmlspecialchars($message, ENT_QUOTES, 'UTF-8'))
168
                        ->close()
169
                        ->h2('Thrown in:')
170
                        ->execute(function (HTML $html) use ($file, $line, $lines) {
171
                            if (!isset($lines)) {
172
                                return;
173
                            }
174
175
                            $html->open('p')
176
                                ->node("File: <code><b>{$file}</b></code>")
177
                                ->entity('nbsp')
178
                                ->entity('nbsp')
179
                                ->entity('nbsp')
180
                                ->a('Open in <b>VS Code</b>', [
181
                                    'href'  => sprintf('vscode://file/%s:%d', $file, $line),
182
                                    'class' => 'external-link',
183
                                ])
184
                            ->close();
185
                            $html->open('ul', ['class' => 'code']);
186
                            for ($i = $line - 3; $i < $line + 4; $i++) {
187
                                if ($i > 0 && $i < count($lines)) {
188
                                    $highlightedCode = highlight_string('<?php ' . $lines[$i], true);
189
                                    $highlightedCode = preg_replace(
190
                                        ['/\n/', '/<br ?\/?>/', '/&lt;\?php&nbsp;/'],
191
                                        ['', '', ''],
192
                                        $highlightedCode
193
                                    );
194
                                    if ($i == $line - 1) {
195
                                        $arrow = str_pad('>', strlen("{$i}"), '=', STR_PAD_LEFT);
196
                                        $html
197
                                            ->open('li', ['class' => 'exception-line'])
198
                                                ->span($arrow, ['class' => 'line'])
199
                                                ->node($highlightedCode)
200
                                            ->close();
201
                                    } else {
202
                                        $number = strval($i + 1);
203
                                        $html
204
                                            ->open('li')
205
                                                ->span($number, ['class' => 'line'])
206
                                                ->node($highlightedCode)
207
                                            ->close();
208
                                    }
209
                                }
210
                            }
211
                            $html->close();
212
                        })
213
                        ->h2('Stack trace:')
214
                        ->execute(function (HTML $html) use ($trace, $traceString) {
215
                            if (!count($trace)) {
216
                                $html->pre($traceString);
217
218
                                return;
219
                            }
220
221
                            $html->open('p')
222
                                ->i('Hover on fields with * to reveal more info.')
223
                            ->close()
224
                            ->open('div', ['class' => 'table-container'])
225
                                ->open('table')
226
                                    ->open('thead')
227
                                        ->open('tr')
228
                                            ->th('No.')
229
                                            ->th('File&nbsp;*')
230
                                            ->th('Line')
231
                                            ->th('Class')
232
                                            ->th('Function')
233
                                            ->th('Arguments&nbsp;*')
234
                                        ->close()
235
                                    ->close()
236
                                    ->open('tbody')
237
                                    ->execute(function (HTML $html) use ($trace) {
238
                                        foreach ($trace as $i => $trace) {
239
                                            $count = (int)$i + 1;
240
                                            $html
241
                                            ->open('tr', ['class' => $count % 2 == 0 ? 'even' : 'odd'])
242
                                                ->td(strval($count), ['class' => 'number'])
243
                                                ->td(isset($trace['file']) ? basename($trace['file']) : '', ['class' => 'file', 'title' => $trace['file'] ?? false])
244
                                                ->td(strval($trace['line'] ?? ''), ['class' => 'line'])
245
                                                ->td(strval($trace['class'] ?? ''), ['class' => 'class'])
246
                                                ->td(strval($trace['function'] ?? ''), ['class' => 'function'])
247
                                                ->open('td', ['class' => 'arguments'])
248
                                                ->execute(function (HTML $html) use ($trace) {
249
                                                    if (!isset($trace['args'])) {
250
                                                        $html->node('NULL');
251
252
                                                        return;
253
                                                    }
254
255
                                                    foreach ($trace['args'] as $argument) {
256
                                                        $html->span(gettype($argument), [
257
                                                            'title' => htmlspecialchars(
258
                                                                Dumper::exportExpression($argument),
259
                                                                ENT_QUOTES,
260
                                                                'UTF-8'
261
                                                            )
262
                                                        ]);
263
                                                    }
264
                                                })
265
                                                ->close()
266
                                            ->close();
267
                                        }
268
                                    })
269
                                    ->close()
270
                                ->close()
271
                            ->close();
272
                        })
273
                    ->close()
274
                ->close()
275
            ->close()
276
        ->echo();
277
278
        App::terminate();
279
    }
280
281
    /**
282
     * Dumps an expression using `var_export()` or `print_r()`.
283
     *
284
     * @param mixed $expression
285
     *
286
     * @return string
287
     */
288 2
    private static function exportExpression($expression): string
289
    {
290 2
        $export = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $export is dead and can be removed.
Loading history...
291
292
        try {
293 2
            $export = var_export($expression, true);
294 1
        } catch (\Throwable $e) {
295 1
            $class = self::class;
296 1
            $line1 = "// {$class} failed to dump the variable. Reason: {$e->getMessage()}. " . PHP_EOL;
297 1
            $line2 = "// here is a dump of the variable using print_r()" . PHP_EOL . PHP_EOL . PHP_EOL;
298
299 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

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