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 | * Regular expressions to transform `var_export()` result |
||||
28 | * from array construct (`array()`) to valid square brackets array (`[]`). |
||||
29 | * |
||||
30 | * @var array |
||||
31 | * |
||||
32 | * @since 1.5.6 |
||||
33 | */ |
||||
34 | protected const VAR_EXPORT_CONVERSIONS = [ |
||||
35 | // replace array construct opening alone |
||||
36 | '/array \(/' => '[', |
||||
37 | // replace array construct opening inside a function call |
||||
38 | '/(\()array\(/' => '$1[', |
||||
39 | // replace array construct opening for stdClass |
||||
40 | '/\(object\) array\(/' => '(object)[', |
||||
41 | // replace array construct closing not part of a string |
||||
42 | '/\)(\))(?=([^\']*\'[^\']*\')*[^\']*$)/' => ']$1', |
||||
43 | // replace array construct closing alone |
||||
44 | '/^([ ]*)\)(,?)$/m' => '$1]$2', |
||||
45 | // replace array construct closing inside a function call |
||||
46 | '/(\n)([ ]*)\]\)/' => '$1$2])', |
||||
47 | // replace array key with nested array |
||||
48 | '/([ ]*)(\'[^\']+\') => ([\[\'])/' => '$1$2 => $3', |
||||
49 | // replace array construct/bracket opening after arrow with newline and spaces |
||||
50 | '/=>[ ]?\n[ ]+(\[|\()/' => '=> $1', |
||||
51 | // replace any valid php after arrow with a newline and spaces |
||||
52 | '/=>[ ]?\n[ ]+([a-zA-Z0-9_\x7f-\xff])/' => '=> $1', |
||||
53 | // replace empty array brackets array with a newline and spaces |
||||
54 | '/\[[ ]?\n[ ]*\]/' => '[]', |
||||
55 | // replace NULL with null |
||||
56 | '/NULL/' => 'null', |
||||
57 | ]; |
||||
58 | |||||
59 | /** |
||||
60 | * Regular expressions to transform `var_dump()` result |
||||
61 | * from var dump syntax to a valid square brackets array (`[]`). |
||||
62 | * |
||||
63 | * @var array |
||||
64 | * |
||||
65 | * @since 1.5.6 |
||||
66 | */ |
||||
67 | protected const VAR_DUMP_CONVERSIONS = [ |
||||
68 | // replace unnecessary line breaks after arrow with spaces only |
||||
69 | '/(=>)\s*(.+)/' => ' $1 $2', |
||||
70 | // replace opening curly brace with opening square bracket |
||||
71 | '/{\n/' => "[\n", |
||||
72 | // replace closing curly brace with closing square bracket |
||||
73 | '/}\n/' => "]\n", |
||||
74 | // replace multiline empty square brackets with single line square brackets |
||||
75 | '/\[\n\s*\]/' => "[]", |
||||
76 | // add comma to all line endings except the ones wrapped in double quotes and the ones preceded by opening brackets |
||||
77 | '/(?<!\[)\n(?=([^"]*["][^"]*["])*[^"]*$)/' => ",\n", |
||||
78 | // add object type info as comment after array opening bracket |
||||
79 | '/&?(object\(.+\))(#\d+) \(\d+\) (\[)/' => '/* $1 [SPL-ID: $2] */ $3', |
||||
80 | // add resource type info as comment in a single line |
||||
81 | '/&?(resource\(\d+\) ([\w ]+) \((\w+)\))(,)*/' => '/* $1 */ "$3"$4', |
||||
82 | // remove the type hint and variable length for strings, and arrays at the beginning of line |
||||
83 | '/^&?(?:string|array|\w+)(\(.+\)) /m' => '', |
||||
84 | // remove the type hint and variable length for strings, and arrays after arrow |
||||
85 | '/(=>) &?(?:string|array|\w+)(\(.+\)) ([\["])/' => '$1 $3', |
||||
86 | // replace bool($var), int($var), float($var), enum($var) with $var |
||||
87 | '/&?(?:bool|int|float|enum)\((.+?)\)/' => '$1', |
||||
88 | // replace uninitialized($var) with empty __NONE__ and add type info as comment |
||||
89 | '/(uninitialized\(.+\))/' => '/* $1 */ __NONE__', |
||||
90 | // replace NULL with null |
||||
91 | '/NULL/' => 'null', |
||||
92 | // replace all backslashes with escaped backslashes |
||||
93 | '/(\\\\)/' => '\\\\$1', |
||||
94 | // replace all single quotes with an escaped single quotes |
||||
95 | '/(\')/' => '\\\\$1', |
||||
96 | // replace private visibility with a better formatted one |
||||
97 | '/\["(.+?)":"(.+)":(private)\]/' => '["$1":$3($2)]', |
||||
98 | // replace key with visibility in double quotes in square brackets with key in single quotes and add visibility as comment |
||||
99 | '/\["(.+?)":(.+?)\] (=>) (.+)/' => "'$1' $3 /* $2 */ $4", |
||||
100 | // replace key in double quotes in square brackets with key in single quotes |
||||
101 | '/\["(.*)"\] (=>)/' => "'$1' $2", |
||||
102 | // replace numeric key in square brackets with key |
||||
103 | '/\[(-?\d+)\] (=>)/' => '$1 $2', |
||||
104 | // replace string opening double quotes with single quotes |
||||
105 | '/(=>)([ ]\/\*.*\*\/)? "/' => "$1$2 '", |
||||
106 | // replace string closing double quotes with single quotes |
||||
107 | '/(.+)"(,)( \/\/.*)?\n/' => "$1'$2$3\n", |
||||
108 | // replace double quotes at the beginning of line with single quotes |
||||
109 | '/^"/m' => "'", |
||||
110 | // combine consequent comments with semicolon |
||||
111 | '/[ ]\*\/ \/\*[ ]/' => '; ', |
||||
112 | // replace *RECURSION* with __RECURSION__ |
||||
113 | '/\*(RECURSION)\*/' => '__$1__', |
||||
114 | ]; |
||||
115 | |||||
116 | /** |
||||
117 | * Whether or not to use `var_dump()` instead of `var_export()` to dump the variables. |
||||
118 | * |
||||
119 | * NOTE: The dumper will always fall back to `var_dump()` if `var_export()` fails. |
||||
120 | * |
||||
121 | * @var bool |
||||
122 | */ |
||||
123 | public static bool $useVarDump = false; |
||||
124 | |||||
125 | /** |
||||
126 | * Accent color of exceptions page and dump block. |
||||
127 | * |
||||
128 | * @var string |
||||
129 | */ |
||||
130 | public static string $accentColor = '#ff3a60'; |
||||
131 | |||||
132 | /** |
||||
133 | * Contrast color of exceptions page and dump block. |
||||
134 | * |
||||
135 | * @var string |
||||
136 | */ |
||||
137 | public static string $contrastColor = '#030035'; |
||||
138 | |||||
139 | /** |
||||
140 | * Dumper CSS styles. |
||||
141 | * The array contains styles for: |
||||
142 | * - `exceptionPage` |
||||
143 | * - `traceBlock` |
||||
144 | * - `dumpBlock` |
||||
145 | * - `timeBlock` |
||||
146 | * - `detailsBlock` |
||||
147 | * |
||||
148 | * Currently set dumper colors can be inject in CSS using the `%accentColor%` and `%contrastColor%` placeholders. |
||||
149 | * |
||||
150 | * @var array |
||||
151 | * |
||||
152 | * @since 1.5.2 |
||||
153 | */ |
||||
154 | public static array $styles = [ |
||||
155 | 'exceptionPage' => ":root{--light:#fff;--dark:#000;--accent-color:%accentColor%;--contrast-color:%contrastColor%;--font-normal:-apple-system,'Fira Sans',Ubuntu,Helvetica,Arial,sans-serif;--font-mono:'Fira Code','Ubuntu Mono',Courier,monospace;--font-base-size:16px;--container-width:85vw;--container-max-width:1364px}@media (max-width:992px){:root{--font-base-size:14px;--container-width:100%;--container-max-width:100vw}}*,::after,::before{box-sizing:border-box;scrollbar-width:thin;scrollbar-color:var(--accent-color) rgba(0,0,0,.15)}::-webkit-scrollbar{width:8px;height:8px;opacity:1;-webkit-appearance:none}::-webkit-scrollbar-thumb{background:var(--accent-color);border-radius:4px}::-webkit-scrollbar-track,::selection{background:rgba(0,0,0,.15)}body{background:var(--light);color:var(--dark);font-family:var(--font-normal);font-size:var(--font-base-size);line-height:1.5;margin:0}h1,h2,h3,h4,h5,h6{margin:0}h1{color:var(--accent-color);font-size:2rem}h2{color:var(--accent-color);font-size:1.75rem}h3{color:var(--light)}p{font-size:1rem;margin:1rem 0}a{color:var(--accent-color)}a:hover{text-decoration:underline}ul{padding:1.5rem 1rem;margin:1rem 0}li{white-space:pre;list-style-type:none}pre{white-space:pre-wrap;white-space:-moz-pre-wrap;white-space:-o-pre-wrap;word-wrap:break-word}.monospace,code{font-family:var(--font-mono);word-wrap:break-word;word-break:break-all}.container{width:var(--container-width);max-width:var(--container-max-width);min-height:100vh;background:var(--light);padding:7vh calc((var(--container-max-width) * .03)) 10vh;margin:0 auto;overflow:hidden}.capture-section,.info-section,.trace-section{margin-bottom:3rem}.message{background:var(--accent-color);color:var(--light);padding:2rem 1rem 1rem 1rem}.scrollable{overflow-x:scroll}.code{display:block;width:max-content;min-width:100%;background:var(--contrast-color);font-family:var(--font-mono);font-size:.875rem;margin:0;overflow-y:scroll;-ms-overflow-style:none;scrollbar-width:none;cursor:initial}.code::-webkit-scrollbar{display:none}.code *{background:0 0}.code-line{display:inline-block;width:calc(3ch + (2 * .75ch));background:rgba(255,255,255,.25);color:var(--light);text-align:right;padding:.25rem .75ch;margin:0 1.5ch 0 0;user-select:none}.code-line.exception-line{color:var(--accent-color);font-weight:700}.code-line.exception-line+code>span>span:not(:first-child){padding-bottom:3px;border-bottom:2px solid var(--accent-color)}.button{display:inline-block;vertical-align:baseline;background:var(--accent-color);color:var(--light);font-size:1rem;text-decoration:none;padding:.5rem 1rem;margin:0 0 1rem 0;border:none;border-radius:2.5rem;cursor:pointer}.button:hover{background:var(--contrast-color);text-decoration:inherit}.button:last-child{margin-bottom:0}.table{width:100%;border-collapse:collapse;border-spacing:0}.table .table-cell{padding:.75rem}.table .table-head .table-cell{background:var(--contrast-color);color:var(--light);text-align:left;padding-top:.75rem;padding-bottom:.75rem}.table-cell.compact{width:1%}.table-row{background:var(--light);border-top:1px solid rgba(0,0,0,.15)}.table .table-row:hover{background:rgba(0,0,0,.065)!important}.table .table-row.additional .table-cell{padding:0}.table .table-row.odd,.table .table-row.odd+.additional{background:var(--light)}.table .table-row.even,.table .table-row.even+.additional{background:rgba(0,0,0,.035)}.table .table-row.even+.additional,.table .table-row.odd+.additional{border-top:none}.pop-up{cursor:help}.line,.number{text-align:center}.class,.function{font-size:.875rem;font-weight:700}.arguments{white-space:nowrap}.argument{display:inline-block;background:rgba(0,0,0,.125);color:var(--accent-color);font-size:.875rem;font-style:italic;padding:.125rem .5rem;margin:0 .25rem 0 0;border-radius:2.5rem}.argument:hover{background:var(--accent-color);color:var(--contrast-color)}.accordion{cursor:pointer;position:relative}.accordion-summary{width:1.5rem;height:1.5rem;background:var(--accent-color);color:var(--light);line-height:1.5rem;text-align:center;list-style:none;border-radius:50%;position:absolute;top:-2.2925rem;left:1.425rem;user-select:none;cursor:pointer}.accordion-summary:hover{background:var(--contrast-color)}.accordion-details{padding:0}", |
||||
156 | '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;", |
||||
157 | 'dumpBlock' => "display:table;background:%contrastColor%;color:#fff;font-family:'Fira Code','Ubuntu Mono',Courier,monospace;font-size:18px;padding:18px;margin-bottom:8px;", |
||||
158 | '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;", |
||||
159 | 'detailsBlock' => "background:%accentColor%;color:#fff;font-family:-apple-system,'Fira Sans',Ubuntu,Helvetica,Arial,sans-serif;font-size:12px;font-weight:bold;padding:12px;margin-bottom:8px;cursor:pointer;user-select:none;", |
||||
160 | ]; |
||||
161 | |||||
162 | /** |
||||
163 | * Colors of syntax tokens. |
||||
164 | * |
||||
165 | * @var array |
||||
166 | */ |
||||
167 | public static array $syntaxHighlightColors = [ |
||||
168 | 'comment' => '#aeaeae', |
||||
169 | 'keyword' => '#00bfff', |
||||
170 | 'string' => '#e4ba80', |
||||
171 | 'default' => '#e8703a', |
||||
172 | 'html' => '#ab8703', |
||||
173 | ]; |
||||
174 | |||||
175 | /** |
||||
176 | * Additional CSS styling of syntax tokens. |
||||
177 | * |
||||
178 | * @var array |
||||
179 | */ |
||||
180 | public static array $syntaxHighlightStyles = [ |
||||
181 | 'comment' => 'font-weight: lighter;', |
||||
182 | 'keyword' => 'font-weight: bold;', |
||||
183 | 'string' => '', |
||||
184 | 'default' => '', |
||||
185 | 'html' => '', |
||||
186 | ]; |
||||
187 | |||||
188 | /** |
||||
189 | * PHP highlighting syntax tokens. |
||||
190 | * |
||||
191 | * @var string[] |
||||
192 | */ |
||||
193 | private static array $syntaxHighlightTokens = ['comment', 'keyword', 'string', 'default', 'html']; |
||||
194 | |||||
195 | |||||
196 | /** |
||||
197 | * Dumps a variable and dies. |
||||
198 | * |
||||
199 | * @param mixed ...$variable |
||||
200 | * |
||||
201 | * @return void The result will simply get echoed. |
||||
202 | * |
||||
203 | * @codeCoverageIgnore |
||||
204 | */ |
||||
205 | public static function dd(...$variable): void |
||||
206 | { |
||||
207 | self::dump(...$variable); |
||||
208 | |||||
209 | App::terminate(); |
||||
210 | } |
||||
211 | |||||
212 | /** |
||||
213 | * Dumps a variable in a nice HTML block with syntax highlighting. |
||||
214 | * |
||||
215 | * @param mixed ...$variable |
||||
216 | * |
||||
217 | 3 | * @return void The result will simply get echoed. |
|||
218 | */ |
||||
219 | 3 | public static function dump(...$variable): void |
|||
220 | 3 | { |
|||
221 | $caller = self::getValidCallerTrace(); |
||||
222 | 3 | $blocks = self::getDumpingBlocks(); |
|||
223 | |||||
224 | 3 | $dump = ''; |
|||
225 | 3 | ||||
226 | 3 | foreach ($variable as $var) { |
|||
227 | 3 | $trace = sprintf($blocks['traceBlock'], $caller); |
|||
228 | $highlightedDump = self::exportExpressionWithSyntaxHighlighting($var, $trace); |
||||
229 | 3 | $block = sprintf($blocks['dumpBlock'], $highlightedDump); |
|||
230 | |||||
231 | $dump .= sprintf($blocks['detailsBlock'], $block); |
||||
232 | 3 | } |
|||
233 | 3 | ||||
234 | $time = (microtime(true) - START_TIME) * 1000; |
||||
235 | 3 | $dump .= sprintf($blocks['timeBlock'], $time); |
|||
236 | 3 | ||||
237 | if (self::isCli()) { |
||||
238 | 3 | echo $dump; |
|||
239 | |||||
240 | return; |
||||
241 | } |
||||
242 | |||||
243 | // @codeCoverageIgnoreStart |
||||
244 | (new HTML(false)) |
||||
245 | ->open('div', ['id' => $id = 'dump-' . uniqid()]) |
||||
246 | ->style("#{$id} * { background: transparent; padding: 0; }") |
||||
247 | ->div($dump) |
||||
248 | ->close() |
||||
249 | ->echo(); |
||||
250 | // @codeCoverageIgnoreEnd |
||||
251 | } |
||||
252 | |||||
253 | /** |
||||
254 | * Dumps an exception in a nice HTML page or as string and exits the script. |
||||
255 | * |
||||
256 | * @param \Throwable $exception |
||||
257 | * |
||||
258 | * @return void The result will be echoed as HTML page or a string representation of the exception if the interface is CLI. |
||||
259 | * |
||||
260 | * @codeCoverageIgnore |
||||
261 | */ |
||||
262 | public static function dumpException(\Throwable $exception): void |
||||
263 | { |
||||
264 | if (self::isCli()) { |
||||
265 | echo $exception; |
||||
266 | |||||
267 | App::terminate(); |
||||
268 | } |
||||
269 | |||||
270 | self::setSyntaxHighlighting(); |
||||
271 | |||||
272 | $reflection = new \ReflectionClass($exception); |
||||
273 | $file = $exception->getFile(); |
||||
274 | $line = $exception->getLine(); |
||||
275 | $message = $exception->getMessage(); |
||||
276 | $trace = $exception->getTrace(); |
||||
277 | $traceString = $exception->getTraceAsString(); |
||||
278 | $name = $reflection->getName(); |
||||
279 | $shortName = $reflection->getShortName(); |
||||
280 | $fileName = basename($file); |
||||
281 | |||||
282 | $style = Misc::interpolate( |
||||
283 | static::$styles['exceptionPage'], |
||||
284 | [ |
||||
285 | 'accentColor' => static::$accentColor, |
||||
286 | 'contrastColor' => static::$contrastColor |
||||
287 | ], |
||||
288 | '%%' |
||||
289 | ); |
||||
290 | $favicon = '<svg xmlns="http://www.w3.org/2000/svg" version="1.0" width="512" height="512"><circle cx="256" cy="256" r="256" fill="#F00" /></svg>'; |
||||
291 | |||||
292 | (new HTML(false)) |
||||
293 | ->node('<!DOCTYPE html>') |
||||
294 | ->open('html', ['lang' => 'en']) |
||||
295 | ->open('head') |
||||
296 | ->title('Oops! Something went wrong') |
||||
297 | ->link(null, ['rel' => 'icon', 'href' => 'data:image/svg+xml;base64,' . base64_encode($favicon)]) |
||||
298 | ->style($style, ['type' => 'text/css']) |
||||
299 | ->close() |
||||
300 | |||||
301 | ->open('body') |
||||
302 | ->open('div', ['class' => 'container']) |
||||
303 | ->open('section', ['class' => 'info-section']) |
||||
304 | ->h1('Uncaught "' . Misc::transform($shortName, 'title') . '"') |
||||
305 | ->p( |
||||
306 | "<code><b>{$shortName}</b></code> was thrown on line <code><b>{$line}</b></code> of file " . |
||||
307 | "<code><b>{$fileName}</b></code> which prevented further execution of the code." |
||||
308 | ) |
||||
309 | ->open('div', ['class' => 'message']) |
||||
310 | ->h3($name) |
||||
311 | // we need to decode and encode because some messages come escaped |
||||
312 | ->p(nl2br(htmlspecialchars(htmlspecialchars_decode((string)$message), ENT_QUOTES, 'UTF-8'))) |
||||
313 | ->close() |
||||
314 | ->close() |
||||
315 | |||||
316 | ->open('section', ['class' => 'capture-section']) |
||||
317 | ->h2('Thrown in:') |
||||
318 | ->execute(function (HTML $html) use ($file, $line) { |
||||
319 | if (!file_exists($file)) { |
||||
320 | return; |
||||
321 | } |
||||
322 | |||||
323 | $html |
||||
324 | ->open('p') |
||||
325 | ->node("File: <code><b>{$file}</b></code>") |
||||
326 | ->entity('nbsp') |
||||
327 | ->entity('nbsp') |
||||
328 | ->a('Open in <b>VS Code</b>', [ |
||||
329 | 'href' => sprintf('vscode://file/%s:%d', $file, $line), |
||||
330 | 'class' => 'button', |
||||
331 | ]) |
||||
332 | ->close(); |
||||
333 | |||||
334 | $html->div(Dumper::highlightFile($file, $line), ['class' => 'scrollable']); |
||||
335 | }) |
||||
336 | ->close() |
||||
337 | |||||
338 | ->open('section', ['class' => 'trace-section']) |
||||
339 | ->h2('Stack trace:') |
||||
340 | ->execute(function (HTML $html) use ($trace, $traceString) { |
||||
341 | if (!count($trace)) { |
||||
342 | $html->pre($traceString); |
||||
343 | |||||
344 | return; |
||||
345 | } |
||||
346 | |||||
347 | $html->node(Dumper::tabulateStacktrace($trace)); |
||||
348 | }) |
||||
349 | ->close() |
||||
350 | ->close() |
||||
351 | ->close() |
||||
352 | ->close() |
||||
353 | ->echo(); |
||||
354 | |||||
355 | App::terminate(); |
||||
356 | } |
||||
357 | |||||
358 | /** |
||||
359 | * Highlights the passed file with the possibility to focus a specific line. |
||||
360 | * |
||||
361 | * @param string $file The file to highlight. |
||||
362 | * @param int $line The line to focus. |
||||
363 | * |
||||
364 | * @return string The hightailed file as HTML. |
||||
365 | * |
||||
366 | * @since 1.5.5 |
||||
367 | * |
||||
368 | * @codeCoverageIgnore |
||||
369 | */ |
||||
370 | private static function highlightFile(string $file, ?int $line = null): string |
||||
371 | { |
||||
372 | return (new HTML(false)) |
||||
373 | ->open('div', ['class' => 'code-highlight']) |
||||
374 | ->open('ul', ['class' => 'code']) |
||||
375 | ->execute(function (HTML $html) use ($file, $line) { |
||||
376 | $file = (string)$file; |
||||
377 | $line = (int)$line; |
||||
378 | $lines = file_exists($file) ? file($file) : []; |
||||
379 | $count = count($lines); |
||||
380 | $offset = !$line ? $count : 5; |
||||
381 | |||||
382 | for ($i = $line - $offset; $i < $line + $offset; $i++) { |
||||
383 | if (!($i > 0 && $i < $count)) { |
||||
384 | continue; |
||||
385 | } |
||||
386 | |||||
387 | $highlightedCode = highlight_string('<?php ' . $lines[$i], true); |
||||
388 | $highlightedCode = preg_replace( |
||||
389 | ['/\n/', '/<br ?\/?>/', '/<\?php /'], |
||||
390 | ['', '', ''], |
||||
391 | $highlightedCode |
||||
392 | ); |
||||
393 | |||||
394 | $causer = $i === $line - 1; |
||||
395 | $number = strval($i + 1); |
||||
396 | |||||
397 | if ($causer) { |
||||
398 | $number = str_pad('>', strlen($number), '=', STR_PAD_LEFT); |
||||
399 | } |
||||
400 | |||||
401 | $html |
||||
402 | ->open('li') |
||||
403 | ->condition($causer === true) |
||||
404 | ->span($number, ['class' => 'code-line exception-line']) |
||||
405 | ->condition($causer === false) |
||||
406 | ->span($number, ['class' => 'code-line']) |
||||
407 | ->node($highlightedCode) |
||||
408 | ->close(); |
||||
409 | } |
||||
410 | }) |
||||
411 | ->close() |
||||
412 | ->close() |
||||
413 | ->return(); |
||||
414 | } |
||||
415 | |||||
416 | /** |
||||
417 | * Tabulates the passed stacktrace in an HTML table. |
||||
418 | * |
||||
419 | * @param array $trace Exception stacktrace array. |
||||
420 | * |
||||
421 | * @return string The tabulated trace as HTML. |
||||
422 | * |
||||
423 | * @since 1.5.5 |
||||
424 | * |
||||
425 | * @codeCoverageIgnore |
||||
426 | */ |
||||
427 | private static function tabulateStacktrace(array $trace): string |
||||
428 | { |
||||
429 | return (new HTML(false)) |
||||
430 | ->p('<i>Fields with * can reveal more info. * Hoverable. ** Clickable.</i>') |
||||
431 | ->open('div', ['class' => 'scrollable']) |
||||
432 | ->open('table', ['class' => 'table']) |
||||
433 | ->open('thead', ['class' => 'table-head']) |
||||
434 | ->open('tr', ['class' => 'table-row']) |
||||
435 | ->th('No. **', ['class' => 'table-cell compact']) |
||||
436 | ->th('File *', ['class' => 'table-cell']) |
||||
437 | ->th('Line', ['class' => 'table-cell compact']) |
||||
438 | ->th('Class', ['class' => 'table-cell']) |
||||
439 | ->th('Function', ['class' => 'table-cell']) |
||||
440 | ->th('Arguments *', ['class' => 'table-cell']) |
||||
441 | ->close() |
||||
442 | ->close() |
||||
443 | ->open('tbody', ['class' => 'table-body']) |
||||
444 | ->execute(function (HTML $html) use ($trace) { |
||||
445 | foreach ($trace as $i => $trace) { |
||||
446 | $count = (int)$i + 1; |
||||
447 | |||||
448 | $html |
||||
449 | ->open('tr', ['class' => 'table-row ' . ($count % 2 == 0 ? 'even' : 'odd')]) |
||||
450 | ->td(isset($trace['file']) ? '' : strval($count), ['class' => 'table-cell number']) |
||||
451 | ->td( |
||||
452 | isset($trace['file']) |
||||
453 | ? sprintf('<a href="vscode://file/%s:%d" title="Open in VS Code">%s</a>', $trace['file'], $trace['line'], basename($trace['file'])) |
||||
454 | : 'N/A', |
||||
455 | ['class' => 'table-cell file pop-up', 'title' => $trace['file'] ?? 'N/A'] |
||||
456 | ) |
||||
457 | ->td(strval($trace['line'] ?? 'N/A'), ['class' => 'table-cell line']) |
||||
458 | ->td(strval($trace['class'] ?? 'N/A'), ['class' => 'table-cell class monospace']) |
||||
459 | ->td(strval($trace['function'] ?? 'N/A'), ['class' => 'table-cell function monospace']) |
||||
460 | ->open('td', ['class' => 'table-cell arguments monospace']) |
||||
461 | ->execute(function (HTML $html) use ($trace) { |
||||
462 | if (!isset($trace['args'])) { |
||||
463 | $html->node('NULL'); |
||||
464 | |||||
465 | return; |
||||
466 | } |
||||
467 | |||||
468 | foreach ($trace['args'] as $argument) { |
||||
469 | $html->span(gettype($argument), [ |
||||
470 | 'class' => 'argument pop-up', |
||||
471 | 'title' => htmlspecialchars( |
||||
472 | Misc::callObjectMethod(Dumper::class, 'exportExpression', $argument), |
||||
473 | ENT_QUOTES, |
||||
474 | 'UTF-8' |
||||
475 | ), |
||||
476 | ]); |
||||
477 | } |
||||
478 | }) |
||||
479 | ->close() |
||||
480 | ->close() |
||||
481 | ->execute(function (HTML $html) use ($trace, $count) { |
||||
482 | isset($trace['file']) && $html |
||||
483 | ->open('tr', ['class' => 'table-row additional', 'id' => 'trace-' . $count]) |
||||
484 | ->open('td', ['class' => 'table-cell', 'colspan' => 6]) |
||||
485 | ->open('details', ['class' => 'accordion']) |
||||
486 | ->summary(strval($count), ['class' => 'accordion-summary']) |
||||
487 | ->div( |
||||
488 | Dumper::highlightFile($trace['file'] ?? '', $trace['line'] ?? null), |
||||
489 | ['class' => 'accordion-details'] |
||||
490 | ) |
||||
491 | ->close() |
||||
492 | ->close() |
||||
493 | ->close(); |
||||
494 | }); |
||||
495 | } |
||||
496 | }) |
||||
497 | ->close() |
||||
498 | ->close() |
||||
499 | ->close() |
||||
500 | ->return(); |
||||
501 | } |
||||
502 | |||||
503 | /** |
||||
504 | * Returns dump of the passed variable using `var_export()`. |
||||
505 | * |
||||
506 | * @param mixed $variable |
||||
507 | * |
||||
508 | * @return string |
||||
509 | * |
||||
510 | 1 | * @since 1.5.6 |
|||
511 | */ |
||||
512 | 1 | protected static function varExport($variable): string |
|||
513 | 1 | { |
|||
514 | 1 | $dump = var_export($variable, true); |
|||
515 | 1 | $dump = preg_replace( |
|||
516 | array_keys(static::VAR_EXPORT_CONVERSIONS), |
||||
517 | array_values(static::VAR_EXPORT_CONVERSIONS), |
||||
518 | 1 | $dump |
|||
519 | ); |
||||
520 | $dump = rtrim(trim(strval($dump)), ','); |
||||
521 | |||||
522 | // var_export() indents using 3 spaces and messes the indentation up |
||||
523 | 1 | // with odd numbers starting from number 3, this omits spaces |
|||
524 | 1 | // for odd numbers making it indents using 2 spaces instead of 3 |
|||
525 | 1 | $dump = preg_replace_callback('/([ ]{3,})/', function ($matches) { |
|||
526 | $indentation = strlen(strlen($matches[1]) % 2 === 0 ? $matches[1] : substr($matches[1], 0, -1)); |
||||
527 | return str_repeat(' ', $indentation); |
||||
528 | 1 | }, $dump); |
|||
529 | |||||
530 | return $dump; |
||||
531 | } |
||||
532 | |||||
533 | /** |
||||
534 | * Returns dump of the passed variable using `var_dump()`. |
||||
535 | * |
||||
536 | * @param mixed $variable |
||||
537 | * |
||||
538 | * @return string |
||||
539 | * |
||||
540 | 2 | * @since 1.5.6 |
|||
541 | */ |
||||
542 | 2 | protected static function varDump($variable): string |
|||
543 | 2 | { |
|||
544 | 2 | ob_start(); |
|||
545 | 2 | var_dump($variable); |
|||
0 ignored issues
–
show
Security
Debugging Code
introduced
by
Loading history...
|
|||||
546 | 2 | $dump = ob_get_clean(); |
|||
547 | 2 | $dump = preg_replace( |
|||
548 | array_keys(static::VAR_DUMP_CONVERSIONS), |
||||
549 | array_values(static::VAR_DUMP_CONVERSIONS), |
||||
550 | 2 | $dump |
|||
551 | ); |
||||
552 | 2 | $dump = rtrim(trim(strval($dump)), ','); |
|||
553 | |||||
554 | return $dump; |
||||
555 | } |
||||
556 | |||||
557 | /** |
||||
558 | * Dumps an expression using `var_export()` or `var_dump()`. |
||||
559 | * |
||||
560 | * @param mixed $expression |
||||
561 | * |
||||
562 | 3 | * @return string |
|||
563 | */ |
||||
564 | 3 | public static function exportExpression($expression): string |
|||
565 | { |
||||
566 | 3 | $recursive = strpos(print_r($expression, true), '*RECURSION*') !== false; |
|||
0 ignored issues
–
show
It seems like
print_r($expression, true) can also be of type true ; however, parameter $haystack of strpos() 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
Loading history...
|
|||||
567 | 2 | ||||
568 | 3 | $dump = static::$useVarDump == true || $recursive == true |
|||
0 ignored issues
–
show
|
|||||
569 | ? self::varDump($expression) |
||||
570 | 3 | : self::varExport($expression); |
|||
571 | |||||
572 | $info = static::$useVarDump == false && $recursive == true ? Misc::interpolate( |
||||
0 ignored issues
–
show
|
|||||
573 | '// {class} failed to dump the variable.{eol}' . |
||||
574 | 1 | '// Reason: var_export() does not handle circular references.{eol}' . |
|||
575 | 3 | '// Here is a dump of the variable using var_dump() formatted in a valid PHP array.{eol}{eol}', |
|||
576 | ['class' => static::class, 'eol' => PHP_EOL] |
||||
577 | 3 | ) : ''; |
|||
578 | |||||
579 | 3 | $dump = $info . $dump; |
|||
580 | |||||
581 | return $dump; |
||||
582 | } |
||||
583 | |||||
584 | /** |
||||
585 | * Dumps an expression using `var_export()` or `var_dump()` with syntax highlighting. |
||||
586 | * |
||||
587 | * @param mixed $expression |
||||
588 | * @param string|null $phpReplacement `<?php` replacement. |
||||
589 | * |
||||
590 | 3 | * @return string |
|||
591 | */ |
||||
592 | 3 | private static function exportExpressionWithSyntaxHighlighting($expression, ?string $phpReplacement = ''): string |
|||
593 | { |
||||
594 | 3 | self::setSyntaxHighlighting(); |
|||
595 | |||||
596 | 3 | $export = self::exportExpression($expression); |
|||
597 | 3 | ||||
598 | $code = highlight_string('<?php ' . $export, true); |
||||
599 | 3 | $html = preg_replace( |
|||
600 | '/<\?php /', |
||||
601 | $phpReplacement ?? '', |
||||
602 | $code, |
||||
603 | 1 |
||||
604 | 3 | ); |
|||
605 | |||||
606 | if (!self::isCli()) { |
||||
607 | // @codeCoverageIgnoreStart |
||||
608 | return $html; |
||||
609 | // @codeCoverageIgnoreEnd |
||||
610 | 3 | } |
|||
611 | |||||
612 | 3 | $mixed = preg_replace_callback( |
|||
613 | 3 | '/@CLR\((#\w+)\)/', |
|||
614 | 3 | fn ($matches) => self::getAnsiCodeFromHexColor($matches[1]), |
|||
615 | 3 | preg_replace( |
|||
616 | ['/<\w+\s+style="color:\s*(#[a-z0-9]+)">(.*?)<\/\w+>/im', '/<br ?\/?>/', '/ /'], |
||||
617 | ["\e[@CLR($1)m$2\e[0m", "\n", " "], |
||||
618 | $html |
||||
619 | ) |
||||
620 | 3 | ); |
|||
621 | |||||
622 | 3 | $ansi = trim(html_entity_decode(strip_tags($mixed))); |
|||
623 | |||||
624 | return $ansi; |
||||
625 | } |
||||
626 | |||||
627 | /** |
||||
628 | * Returns an array containing HTML/ANSI wrapping blocks. |
||||
629 | * Available blocks are: `traceBlock`, `dumpBlock`, `timeBlock`, and `detailsBlock`. |
||||
630 | * All this blocks will contain a placeholder for a `*printf()` function to inject content. |
||||
631 | * |
||||
632 | 3 | * @return void |
|||
633 | */ |
||||
634 | 3 | private static function getDumpingBlocks(): array |
|||
635 | { |
||||
636 | $isCli = self::isCli(); |
||||
637 | 3 | ||||
638 | 3 | $colors = [ |
|||
639 | 'accentColor' => static::$accentColor, |
||||
640 | 'contrastColor' => static::$contrastColor, |
||||
641 | 3 | ]; |
|||
642 | 3 | ||||
643 | $traceBlock = HTML::div('%s', [ |
||||
644 | 'style' => Misc::interpolate(static::$styles['traceBlock'], $colors, '%%') |
||||
645 | 3 | ]); |
|||
646 | 3 | ||||
647 | $dumpBlock = HTML::div('%s', [ |
||||
648 | 'style' => Misc::interpolate(static::$styles['dumpBlock'], $colors, '%%') |
||||
649 | 3 | ]); |
|||
650 | 3 | ||||
651 | $timeBlock = HTML::div('START_TIME + %.2fms', [ |
||||
652 | 'style' => Misc::interpolate(static::$styles['timeBlock'], $colors, '%%') |
||||
653 | 3 | ]); |
|||
654 | 3 | ||||
655 | 3 | $detailsBlock = (new HTML(false)) |
|||
656 | 3 | ->open('details', ['open' => null]) |
|||
657 | ->summary('Expand/Collapse', [ |
||||
658 | 3 | 'style' => Misc::interpolate(static::$styles['detailsBlock'], $colors, '%%') |
|||
659 | 3 | ]) |
|||
660 | 3 | ->main('%s') |
|||
661 | ->close() |
||||
662 | 3 | ->return(); |
|||
663 | 3 | ||||
664 | 3 | if ($isCli) { |
|||
665 | 3 | $traceBlock = "\n// \e[33;1mTRACE:\e[0m \e[34;46m[%s]\e[0m \n\n"; |
|||
666 | 3 | $dumpBlock = "%s"; |
|||
667 | $timeBlock = "\n\n// \e[36mSTART_TIME\e[0m + \e[35m%.2f\e[0mms \n\n\n"; |
||||
668 | $detailsBlock = "%s"; |
||||
669 | 3 | } |
|||
670 | |||||
671 | return compact('traceBlock', 'dumpBlock', 'timeBlock', 'detailsBlock'); |
||||
672 | } |
||||
673 | |||||
674 | /** |
||||
675 | * Returns the last caller trace before `dd()` or `dump()` if the format of `file:line`. |
||||
676 | * |
||||
677 | 3 | * @return string |
|||
678 | */ |
||||
679 | 3 | private static function getValidCallerTrace(): string |
|||
680 | { |
||||
681 | 3 | $trace = 'Trace: N/A'; |
|||
682 | |||||
683 | 3 | array_filter(array_reverse(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)), function ($backtrace) use (&$trace) { |
|||
684 | 3 | static $hasFound = false; |
|||
685 | 3 | if (!$hasFound && in_array($backtrace['function'], ['dump', 'dd'])) { |
|||
686 | $trace = $backtrace['file'] . ':' . $backtrace['line']; |
||||
687 | 3 | $hasFound = true; |
|||
688 | |||||
689 | return true; |
||||
690 | 3 | } |
|||
691 | |||||
692 | return false; |
||||
693 | 3 | }); |
|||
694 | |||||
695 | return $trace; |
||||
696 | } |
||||
697 | |||||
698 | /** |
||||
699 | * Converts a hex color to the closest standard ANSI color code. |
||||
700 | * Standard ANSI colors include: black, red, green, yellow, blue, magenta, cyan and white. |
||||
701 | * |
||||
702 | 3 | * @return int |
|||
703 | */ |
||||
704 | private static function getAnsiCodeFromHexColor(string $color): int |
||||
705 | 3 | { |
|||
706 | $colors = [ |
||||
707 | 'black' => ['ansi' => 30, 'rgb' => [0, 0, 0]], |
||||
708 | 'red' => ['ansi' => 31, 'rgb' => [255, 0, 0]], |
||||
709 | 'green' => ['ansi' => 32, 'rgb' => [0, 128, 0]], |
||||
710 | 'yellow' => ['ansi' => 33, 'rgb' => [255, 255, 0]], |
||||
711 | 'blue' => ['ansi' => 34, 'rgb' => [0, 0, 255]], |
||||
712 | 'magenta' => ['ansi' => 35, 'rgb' => [255, 0, 255]], |
||||
713 | 'cyan' => ['ansi' => 36, 'rgb' => [0, 255, 255]], |
||||
714 | 'white' => ['ansi' => 37, 'rgb' => [255, 255, 255]], |
||||
715 | 'default' => ['ansi' => 39, 'rgb' => [128, 128, 128]], |
||||
716 | 3 | ]; |
|||
717 | 3 | ||||
718 | $hexClr = ltrim($color, '#'); |
||||
719 | 3 | $hexNum = strval(strlen($hexClr)); |
|||
720 | $hexPos = [ |
||||
721 | '3' => [0, 0, 1, 1, 2, 2], |
||||
722 | '6' => [0, 1, 2, 3, 4, 5], |
||||
723 | ]; |
||||
724 | 3 | ||||
725 | 3 | [$r, $g, $b] = [ |
|||
726 | 3 | $hexClr[$hexPos[$hexNum][0]] . $hexClr[$hexPos[$hexNum][1]], |
|||
727 | $hexClr[$hexPos[$hexNum][2]] . $hexClr[$hexPos[$hexNum][3]], |
||||
728 | $hexClr[$hexPos[$hexNum][4]] . $hexClr[$hexPos[$hexNum][5]], |
||||
729 | 3 | ]; |
|||
730 | |||||
731 | 3 | $color = [hexdec($r), hexdec($g), hexdec($b)]; |
|||
732 | 3 | ||||
733 | 3 | $distances = []; |
|||
734 | 3 | foreach ($colors as $name => $values) { |
|||
735 | 3 | $distances[$name] = sqrt( |
|||
736 | 3 | pow($values['rgb'][0] - $color[0], 2) + |
|||
737 | pow($values['rgb'][1] - $color[1], 2) + |
||||
738 | pow($values['rgb'][2] - $color[2], 2) |
||||
739 | ); |
||||
740 | 3 | } |
|||
741 | 3 | ||||
742 | 3 | $colorName = ''; |
|||
743 | 3 | $minDistance = pow(2, 30); |
|||
744 | 3 | foreach ($distances as $key => $value) { |
|||
745 | 3 | if ($value < $minDistance) { |
|||
746 | $minDistance = $value; |
||||
747 | $colorName = $key; |
||||
748 | } |
||||
749 | 3 | } |
|||
750 | |||||
751 | return $colors[$colorName]['ansi']; |
||||
752 | } |
||||
753 | |||||
754 | /** |
||||
755 | * Sets PHP syntax highlighting colors according to current class state. |
||||
756 | * |
||||
757 | * @return void |
||||
758 | * |
||||
759 | * @codeCoverageIgnore |
||||
760 | */ |
||||
761 | private static function setSyntaxHighlighting(): void |
||||
762 | { |
||||
763 | if (self::isCli()) { |
||||
764 | // use default entries for better contrast. |
||||
765 | return; |
||||
766 | } |
||||
767 | |||||
768 | $tokens = self::$syntaxHighlightTokens; |
||||
769 | |||||
770 | foreach ($tokens as $token) { |
||||
771 | $color = self::$syntaxHighlightColors[$token] ?? ini_get("highlight.{$token}"); |
||||
772 | $style = self::$syntaxHighlightStyles[$token] ?? chr(8); |
||||
773 | |||||
774 | $highlighting = sprintf('%s;%s', $color, $style); |
||||
775 | |||||
776 | ini_set("highlight.{$token}", $highlighting); |
||||
777 | } |
||||
778 | } |
||||
779 | |||||
780 | /** |
||||
781 | * Checks whether the script is currently running in CLI mode or not. |
||||
782 | * |
||||
783 | 3 | * @return bool |
|||
784 | */ |
||||
785 | 3 | private static function isCli(): bool |
|||
786 | { |
||||
787 | return PHP_SAPI === 'cli'; |
||||
788 | } |
||||
789 | } |
||||
790 |