Passed
Push — master ( 062750...481b43 )
by Marwan
02:32
created

Dumper::dump()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 29
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 3

Importance

Changes 9
Bugs 0 Features 1
Metric Value
cc 3
eloc 18
c 9
b 0
f 1
nc 4
nop 1
dl 0
loc 29
ccs 13
cts 13
cp 1
crap 3
rs 9.6666
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
use MAKS\Velox\Helper\Misc;
17
18
/**
19
 * A class that dumps variables and exception in a nice formatting.
20
 *
21
 * @package Velox\Helper
22
 * @since 1.0.0
23
 */
24
class Dumper
25
{
26
    /**
27
     * Accent color of exceptions page and dump block.
28
     *
29
     * @var string
30
     */
31
    public static string $accentColor = '#ff3a60';
32
33
    /**
34
     * Contrast color of exceptions page and dump block.
35
     *
36
     * @var string
37
     */
38
    public static string $contrastColor = '#030035';
39
40
    /**
41
     * Dumper CSS styles.
42
     * The array contains styles for:
43
     * - `exceptionPage`
44
     * - `traceBlock`
45
     * - `dumpBlock`
46
     * - `timeBlock`
47
     *
48
     * Currently set dumper colors can be inject in CSS using the `%accentColor%` and `%contrastColor%` placeholders.
49
     *
50
     * @var array
51
     *
52
     * @since 1.5.2
53
     */
54
    public static array $styles = [
55
        'exceptionPage' => ":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}",
56
        'traceBlock'    => "background:#fff;color:%accentColor%;font-family:-apple-system,'Fira Sans',Ubuntu,Helvetica,Arial,sans-serif;font-size:12px;padding:4px 8px;margin-bottom:18px;",
57
        'dumpBlock'     => "display:table;background:%contrastColor%;color:#fff;font-family:'Fira Code','Ubuntu Mono',Courier,monospace;font-size:18px;padding:18px;margin-bottom:8px;",
58
        'timeBlock'     => "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;",
59
    ];
60
61
    /**
62
     * Colors of syntax tokens.
63
     *
64
     * @var array
65
     */
66
    public static array $syntaxHighlightColors = [
67
        'comment' => '#aeaeae',
68
        'keyword' => '#00bfff',
69
        'string'  => '#e4ba80',
70
        'default' => '#e8703a',
71
        'html'    => '#ab8703',
72
    ];
73
74
    /**
75
     * Additional CSS styling of syntax tokens.
76
     *
77
     * @var array
78
     */
79
    public static array $syntaxHighlightStyles = [
80
        'comment' => 'font-weight: lighter;',
81
        'keyword' => 'font-weight: bold;',
82
        'string'  => '',
83
        'default' => '',
84
        'html'    => '',
85
    ];
86
87
    /**
88
     * PHP highlighting syntax tokens.
89
     *
90
     * @var string[]
91
     */
92
    private static array $syntaxHighlightTokens = ['comment', 'keyword', 'string', 'default', 'html'];
93
94
95
    /**
96
     * Dumps a variable and dies.
97
     *
98
     * @param mixed ...$variable
99
     *
100
     * @return void The result will simply get echoed.
101
     *
102
     * @codeCoverageIgnore
103
     */
104
    public static function dd(...$variable): void
105
    {
106
        self::dump(...$variable);
107
108
        App::terminate();
109
    }
110
111
    /**
112
     * Dumps a variable in a nice HTML block with syntax highlighting.
113
     *
114
     * @param mixed ...$variable
115
     *
116
     * @return void The result will simply get echoed.
117
     */
118 2
    public static function dump(...$variable): void
119
    {
120 2
        $trace  = self::getValidCallerTrace();
121 2
        $blocks = self::getDumpingBlocks();
122
123 2
        $dump = '';
124
125 2
        foreach ($variable as $var) {
126 2
            $trace = sprintf($blocks['traceBlock'], $trace);
0 ignored issues
show
Bug introduced by
It seems like $blocks['traceBlock'] can also be of type MAKS\Velox\Frontend\HTML; however, parameter $format of sprintf() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

126
            $trace = sprintf(/** @scrutinizer ignore-type */ $blocks['traceBlock'], $trace);
Loading history...
127 2
            $highlightedDump = self::exportExpressionWithSyntaxHighlighting($var, $trace);
128 2
            $dump .= sprintf($blocks['dumpBlock'], $highlightedDump);
129
        }
130
131 2
        $time = (microtime(true) - START_TIME) * 1000;
132 2
        $dump .= sprintf($blocks['timeBlock'], $time);
133
134 2
        if (self::isCli()) {
135 2
            echo $dump;
136
137 2
            return;
138
        }
139
140
        // @codeCoverageIgnoreStart
141
        (new HTML(false))
142
            ->open('div', ['id' => 'dump'])
143
                ->style('.dump * {background:transparent;padding:0;}')
144
                ->div($dump)
145
            ->close()
146
        ->echo();
147
        // @codeCoverageIgnoreEnd
148
    }
149
150
    /**
151
     * Dumps an exception in a nice HTML page or as string and exits the script.
152
     *
153
     * @param \Throwable $exception
154
     *
155
     * @return void The result will be echoed as HTML page or a string representation of the exception if the interface is CLI.
156
     *
157
     * @codeCoverageIgnore
158
     */
159
    public static function dumpException(\Throwable $exception): void
160
    {
161
        if (self::isCli()) {
162
            echo $exception;
163
164
            App::terminate();
165
        }
166
167
        self::setSyntaxHighlighting();
168
169
        $file        = $exception->getFile();
170
        $line        = $exception->getLine();
171
        $message     = $exception->getMessage();
172
        $trace       = $exception->getTrace();
173
        $traceString = $exception->getTraceAsString();
174
        $name        = get_class($exception);
175
        $filename    = basename($file);
176
        $lines       = file_exists($file) ? file($file) : null;
177
178
        $style = Misc::interpolate(
179
            static::$styles['exceptionPage'],
180
            [
181
                'accentColor'   => static::$accentColor,
182
                'contrastColor' => static::$contrastColor
183
            ],
184
            '%%'
185
        );
186
187
        (new HTML(false))
188
            ->node('<!DOCTYPE html>')
189
            ->open('html', ['lang' => 'en'])
190
                ->open('head')
191
                    ->title('Oops, something went wrong')
192
                    ->style($style)
193
                ->close()
194
                ->open('body')
195
                    ->open('div', ['class' => 'container'])
196
                        ->h1("Uncaught {$name}")
197
                        ->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.")
198
                        ->open('div', ['class' => 'message'])
199
                            ->h3($name)
200
                            ->p((string)htmlspecialchars($message, ENT_QUOTES, 'UTF-8'))
201
                        ->close()
202
                        ->h2('Thrown in:')
203
                        ->execute(function (HTML $html) use ($file, $line, $lines) {
204
                            if (!isset($lines)) {
205
                                return;
206
                            }
207
208
                            $html
209
                                ->open('p')
210
                                    ->node("File: <code><b>{$file}</b></code>")
211
                                    ->entity('nbsp')
212
                                    ->entity('nbsp')
213
                                    ->entity('nbsp')
214
                                    ->a('Open in <b>VS Code</b>', [
215
                                        'href'  => sprintf('vscode://file/%s:%d', $file, $line),
216
                                        'class' => 'external-link',
217
                                    ])
218
                                ->close();
219
220
                            $html->open('ul', ['class' => 'code']);
221
                            for ($i = $line - 3; $i < $line + 4; $i++) {
222
                                if ($i > 0 && $i < count($lines)) {
223
                                    $highlightedCode = highlight_string('<?php ' . $lines[$i], true);
224
                                    $highlightedCode = preg_replace(
225
                                        ['/\n/', '/<br ?\/?>/', '/&lt;\?php&nbsp;/'],
226
                                        ['', '', ''],
227
                                        $highlightedCode
228
                                    );
229
                                    if ($i == $line - 1) {
230
                                        $arrow = str_pad('>', strlen("{$i}"), '=', STR_PAD_LEFT);
231
                                        $html
232
                                            ->open('li', ['class' => 'exception-line'])
233
                                                ->span($arrow, ['class' => 'line'])
234
                                                ->node($highlightedCode)
235
                                            ->close();
236
                                    } else {
237
                                        $number = strval($i + 1);
238
                                        $html
239
                                            ->open('li')
240
                                                ->span($number, ['class' => 'line'])
241
                                                ->node($highlightedCode)
242
                                            ->close();
243
                                    }
244
                                }
245
                            }
246
                            $html->close();
247
                        })
248
249
                        ->h2('Stack trace:')
250
                        ->execute(function (HTML $html) use ($trace, $traceString) {
251
                            if (!count($trace)) {
252
                                $html->pre($traceString);
253
254
                                return;
255
                            }
256
257
                            $html->open('p')
258
                                ->i('Hover on fields with * to reveal more info.')
259
                            ->close();
260
261
                            $html->open('div', ['class' => 'table-container'])
262
                                ->open('table')
263
                                    ->open('thead')
264
                                        ->open('tr')
265
                                            ->th('No.')
266
                                            ->th('File&nbsp;*')
267
                                            ->th('Line')
268
                                            ->th('Class')
269
                                            ->th('Function')
270
                                            ->th('Arguments&nbsp;*')
271
                                        ->close()
272
                                    ->close()
273
                                    ->open('tbody')
274
                                    ->execute(function (HTML $html) use ($trace) {
275
                                        foreach ($trace as $i => $trace) {
276
                                            $count = (int)$i + 1;
277
                                            $html
278
                                            ->open('tr', ['class' => $count % 2 == 0 ? 'even' : 'odd'])
279
                                                ->td(strval($count), ['class' => 'number'])
280
                                                ->td(isset($trace['file']) ? basename($trace['file']) : '', ['class' => 'file', 'title' => $trace['file'] ?? false])
281
                                                ->td(strval($trace['line'] ?? ''), ['class' => 'line'])
282
                                                ->td(strval($trace['class'] ?? ''), ['class' => 'class'])
283
                                                ->td(strval($trace['function'] ?? ''), ['class' => 'function'])
284
                                                ->open('td', ['class' => 'arguments'])
285
                                                ->execute(function (HTML $html) use ($trace) {
286
                                                    if (!isset($trace['args'])) {
287
                                                        $html->node('NULL');
288
289
                                                        return;
290
                                                    }
291
292
                                                    foreach ($trace['args'] as $argument) {
293
                                                        $html->span(gettype($argument), [
294
                                                            'title' => htmlspecialchars(
295
                                                                Dumper::exportExpression($argument),
296
                                                                ENT_QUOTES,
297
                                                                'UTF-8'
298
                                                            )
299
                                                        ]);
300
                                                    }
301
                                                })
302
                                                ->close()
303
                                            ->close();
304
                                        }
305
                                    })
306
                                    ->close()
307
                                ->close()
308
                            ->close();
309
                        })
310
                    ->close()
311
                ->close()
312
            ->close()
313
        ->echo();
314
315
        App::terminate();
316
    }
317
318
    /**
319
     * Dumps an expression using `var_export()` or `print_r()`.
320
     *
321
     * @param mixed $expression
322
     *
323
     * @return string
324
     */
325 2
    private static function exportExpression($expression): string
326
    {
327 2
        $export = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $export is dead and can be removed.
Loading history...
328
329
        try {
330 2
            $export = var_export($expression, true);
331 1
        } catch (\Throwable $e) {
332 1
            $class = self::class;
333 1
            $line1 = "// {$class} failed to dump the variable. Reason: {$e->getMessage()}. " . PHP_EOL;
334 1
            $line2 = "// here is a dump of the variable using print_r()" . PHP_EOL . PHP_EOL . PHP_EOL;
335
336 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

336
            return $line1 . $line2 . /** @scrutinizer ignore-type */ print_r($expression, true);
Loading history...
337
        }
338
339
        // convert array construct to square brackets
340
        $acToSbPatterns = [
341 1
            '/(\()array\(/'                         => '$1[',
342
            '/\)(\))/'                              => ']$1',
343
            '/array \(/'                            => '[',
344
            '/\(object\) array\(/'                  => '(object)[',
345
            '/^([ ]*)\)(,?)$/m'                     => '$1]$2',
346
            '/\[\n\]/'                              => '[]',
347
            '/\[[ ]?\n[ ]+\]/'                      => '[]',
348
            '/=>[ ]?\n[ ]+(\[|\()/'                 => '=> $1',
349
            '/=>[ ]?\n[ ]+([a-zA-Z0-9_\x7f-\xff])/' => '=> $1',
350
            '/(\n)([ ]*)\]\)/'                      => '$1$2  ])',
351
            '/([ ]*)(\'[^\']+\') => ([\[\'])/'      => '$1$2 => $3',
352
        ];
353
354 1
        return preg_replace(
355 1
            array_keys($acToSbPatterns),
356 1
            array_values($acToSbPatterns),
357
            $export
358
        );
359
    }
360
361
    /**
362
     * Dumps an expression using `var_export()` or `print_r()` with syntax highlighting.
363
     *
364
     * @param mixed $expression
365
     * @param string|null $phpReplacement `<?php` replacement.
366
     *
367
     * @return string
368
     */
369 2
    private static function exportExpressionWithSyntaxHighlighting($expression, ?string $phpReplacement = ''): string
370
    {
371 2
        self::setSyntaxHighlighting();
372
373 2
        $export = self::exportExpression($expression);
374
375 2
        $code = highlight_string('<?php ' . $export, true);
376 2
        $html = preg_replace(
377 2
            '/&lt;\?php&nbsp;/',
378 2
            $phpReplacement ?? '',
379
            $code
380
        );
381
382 2
        if (!self::isCli()) {
383
            // @codeCoverageIgnoreStart
384
            return $html;
385
            // @codeCoverageIgnoreEnd
386
        }
387
388 2
        $mixed = preg_replace_callback(
389 2
            '/@CLR\((#\w+)\)/',
390 2
            fn ($matches) => self::getAnsiCodeFromHexColor($matches[1]),
391 2
            preg_replace(
392 2
                ['/<\w+\s+style="color:\s*(#[a-z0-9]+)">(.*?)<\/\w+>/im', '/<br ?\/?>/', '/&nbsp;/'],
393 2
                ["\e[@CLR($1)m$2\e[0m", "\n", " "],
394
                $html
395
            )
396
        );
397
398 2
        $ansi = trim(html_entity_decode(strip_tags($mixed)));
399
400 2
        return $ansi;
401
    }
402
403
    /**
404
     * Returns an array containing HTML/ANSI wrapping blocks.
405
     * Available blocks are: `traceBlock`, `dumpBlock`, and `timeBlock`.
406
     * All this blocks will contain a placeholder for a `*printf()` function to inject content.
407
     *
408
     * @return void
409
     */
410 2
    private static function getDumpingBlocks(): array
411
    {
412 2
        $isCli = self::isCli();
413
414
        $colors = [
415 2
            'accentColor'   => static::$accentColor,
416 2
            'contrastColor' => static::$contrastColor,
417
        ];
418
419
        return [
420 2
            'traceBlock' => $isCli ? "\n// \e[33;1mTRACE:\e[0m \e[34;46m[%s]\e[0m \n\n" : HTML::div('%s', [
421 2
                'style' => Misc::interpolate(static::$styles['traceBlock'], $colors, '%%')
422
            ]),
423 2
            'dumpBlock' => $isCli ? '%s' : HTML::div('%s', [
424 2
                'style' => Misc::interpolate(static::$styles['dumpBlock'], $colors, '%%')
425
            ]),
426 2
            'timeBlock' => $isCli ? "\n\n// \e[36mSTART_TIME\e[0m + \e[35m%.2f\e[0mms \n\n\n" : HTML::div('START_TIME + %.2fms', [
427 2
                'style' => Misc::interpolate(static::$styles['timeBlock'], $colors, '%%')
428
            ]),
429
        ];
430
    }
431
432
    /**
433
     * Returns the last caller trace before `dd()` or `dump()` if the format of `file:line`.
434
     *
435
     * @return string
436
     */
437 2
    private static function getValidCallerTrace(): string
438
    {
439 2
        $trace = 'Trace: N/A';
440
441 2
        array_filter(array_reverse(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)), function ($backtrace) use (&$trace) {
442 2
            static $hasFound = false;
443 2
            if (!$hasFound && in_array($backtrace['function'], ['dump', 'dd'])) {
444 2
                $trace = $backtrace['file'] . ':' . $backtrace['line'];
445 2
                $hasFound = true;
446
447 2
                return true;
448
            }
449
450 2
            return false;
451 2
        });
452
453 2
        return $trace;
454
    }
455
456
    /**
457
     * Converts a hex color to the closest standard ANSI color code.
458
     * Standard ANSI colors include: black, red, green, yellow, blue, magenta, cyan and white.
459
     *
460
     * @return int
461
     */
462 2
    private static function getAnsiCodeFromHexColor(string $color): int
463
    {
464
        $colors = [
465 2
            'black'   => ['ansi' => 30, 'rgb' => [0, 0, 0]],
466
            'red'     => ['ansi' => 31, 'rgb' => [255, 0, 0]],
467
            'green'   => ['ansi' => 32, 'rgb' => [0, 128, 0]],
468
            'yellow'  => ['ansi' => 33, 'rgb' => [255, 255, 0]],
469
            'blue'    => ['ansi' => 34, 'rgb' => [0, 0, 255]],
470
            'magenta' => ['ansi' => 35, 'rgb' => [255, 0, 255]],
471
            'cyan'    => ['ansi' => 36, 'rgb' => [0, 255, 255]],
472
            'white'   => ['ansi' => 37, 'rgb' => [255, 255, 255]],
473
            'default' => ['ansi' => 39, 'rgb' => [128, 128, 128]],
474
        ];
475
476 2
        $hexClr = ltrim($color, '#');
477 2
        $hexNum = strval(strlen($hexClr));
478
        $hexPos = [
479 2
            '3' => [0, 0, 1, 1, 2, 2],
480
            '6' => [0, 1, 2, 3, 4, 5],
481
        ];
482
483
        [$r, $g, $b] = [
484 2
            $hexClr[$hexPos[$hexNum][0]] . $hexClr[$hexPos[$hexNum][1]],
485 2
            $hexClr[$hexPos[$hexNum][2]] . $hexClr[$hexPos[$hexNum][3]],
486 2
            $hexClr[$hexPos[$hexNum][4]] . $hexClr[$hexPos[$hexNum][5]],
487
        ];
488
489 2
        $color = [hexdec($r), hexdec($g), hexdec($b)];
490
491 2
        $distances = [];
492 2
        foreach ($colors as $name => $values) {
493 2
            $distances[$name] = sqrt(
494 2
                pow($values['rgb'][0] - $color[0], 2) +
495 2
                pow($values['rgb'][1] - $color[1], 2) +
496 2
                pow($values['rgb'][2] - $color[2], 2)
497
            );
498
        }
499
500 2
        $colorName = '';
501 2
        $minDistance = pow(2, 30);
502 2
        foreach ($distances as $key => $value) {
503 2
            if ($value < $minDistance) {
504 2
                $minDistance = $value;
505 2
                $colorName   = $key;
506
            }
507
        }
508
509 2
        return $colors[$colorName]['ansi'];
510
    }
511
512
    /**
513
     * Sets PHP syntax highlighting colors according to current class state.
514
     *
515
     * @return void
516
     *
517
     * @codeCoverageIgnore
518
     */
519
    private static function setSyntaxHighlighting(): void
520
    {
521
        if (self::isCli()) {
522
            // use default entries for better contrast.
523
            return;
524
        }
525
526
        $tokens = self::$syntaxHighlightTokens;
527
528
        foreach ($tokens as $token) {
529
            $color = self::$syntaxHighlightColors[$token] ?? ini_get("highlight.{$token}");
530
            $style = self::$syntaxHighlightStyles[$token] ?? chr(8);
531
532
            $highlighting = sprintf('%s;%s', $color, $style);
533
534
            ini_set("highlight.{$token}", $highlighting);
535
        }
536
    }
537
538
    /**
539
     * Checks whether the script is currently running in CLI mode or not.
540
     *
541
     * @return bool
542
     */
543 2
    private static function isCli(): bool
544
    {
545 2
        return PHP_SAPI === 'cli';
546
    }
547
}
548