Passed
Pull Request — master (#116)
by Dmitriy
05:33 queued 02:43
created

HtmlRenderer::getThrowableName()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 4
c 0
b 0
f 0
dl 0
loc 9
ccs 5
cts 5
cp 1
rs 10
cc 2
nc 2
nop 1
crap 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\ErrorHandler\Renderer;
6
7
use Alexkart\CurlBuilder\Command;
8
use cebe\markdown\GithubMarkdown;
9
use Psr\Http\Message\ServerRequestInterface;
10
use RuntimeException;
11
use Throwable;
12
use Yiisoft\ErrorHandler\CompositeException;
13
use Yiisoft\ErrorHandler\ErrorData;
14
use Yiisoft\ErrorHandler\Exception\ErrorException;
15
use Yiisoft\ErrorHandler\ThrowableRendererInterface;
16
use Yiisoft\FriendlyException\FriendlyExceptionInterface;
17
18
use function array_values;
19
use function dirname;
20
use function extract;
21
use function file;
22
use function file_exists;
23
use function func_get_arg;
24
use function glob;
25
use function htmlspecialchars;
26
use function implode;
27
use function is_array;
28
use function is_bool;
29
use function is_file;
30
use function is_object;
31
use function is_resource;
32
use function is_string;
33
use function ksort;
34
use function mb_strlen;
35
use function mb_substr;
36
use function ob_clean;
37
use function ob_end_clean;
38
use function ob_get_clean;
39
use function ob_get_level;
40
use function ob_implicit_flush;
41
use function ob_start;
42
use function realpath;
43
use function str_replace;
44
use function stripos;
45
use function strlen;
46
47
/**
48
 * Formats throwable into HTML string.
49
 *
50
 * @psalm-import-type DebugBacktraceType from ErrorException
51
 */
52
final class HtmlRenderer implements ThrowableRendererInterface
53
{
54
    private GithubMarkdown $markdownParser;
55
56
    /**
57
     * @var string The full path to the default template directory.
58
     */
59
    private string $defaultTemplatePath;
60
61
    /**
62
     * @var string The full path of the template file for rendering exceptions without call stack information.
63
     *
64
     * This template should be used in production.
65
     */
66
    private string $template;
67
68
    /**
69
     * @var string The full path of the template file for rendering exceptions with call stack information.
70
     *
71
     * This template should be used in development.
72
     */
73
    private string $verboseTemplate;
74
75
    /**
76
     * @var int The maximum number of source code lines to be displayed. Defaults to 19.
77
     */
78
    private int $maxSourceLines;
79
80
    /**
81
     * @var int The maximum number of trace source code lines to be displayed. Defaults to 13.
82
     */
83
    private int $maxTraceLines;
84
85
    /**
86
     * @var string|null The trace header line with placeholders to be be substituted. Defaults to null.
87
     *
88
     * The placeholders are {file}, {line} and {icon}. A typical use case is the creation of IDE-specific links,
89
     * since when you click on a trace header link, it opens directly in the IDE. You can also insert custom content.
90
     *
91
     * Example IDE link:
92
     *
93
     * ```
94
     * <a href="ide://open?file={file}&line={line}">{icon}</a>
95
     * ```
96
     */
97
    private ?string $traceHeaderLine;
98
99
    /**
100
     * @var string[]|null The list of vendor paths is determined automatically.
101
     *
102
     * One path if the error handler is installed as a vendor package, or a list of package vendor paths
103
     * if the error handler is installed for development in {@link https://github.com/yiisoft/yii-dev-tool}.
104
     */
105
    private ?array $vendorPaths = null;
106
107
    /**
108
     * @param array $settings Settings can have the following keys:
109
     * - template: string, full path of the template file for rendering exceptions without call stack information.
110
     * - verboseTemplate: string, full path of the template file for rendering exceptions with call stack information.
111
     * - maxSourceLines: int, maximum number of source code lines to be displayed. Defaults to 19.
112
     * - maxTraceLines: int, maximum number of trace source code lines to be displayed. Defaults to 13.
113
     * - traceHeaderLine: string, trace header line with placeholders to be be substituted. Defaults to null.
114
     *
115
     * @psalm-param array{
116
     *   template?: string,
117
     *   verboseTemplate?: string,
118
     *   maxSourceLines?: int,
119
     *   maxTraceLines?: int,
120
     *   traceHeaderLine?: string,
121
     * } $settings
122
     */
123 39
    public function __construct(array $settings = [])
124
    {
125 39
        $this->markdownParser = new GithubMarkdown();
126 39
        $this->markdownParser->html5 = true;
127
128 39
        $this->defaultTemplatePath = dirname(__DIR__, 2) . '/templates';
129 39
        $this->template = $settings['template'] ?? $this->defaultTemplatePath . '/production.php';
130 39
        $this->verboseTemplate = $settings['verboseTemplate'] ?? $this->defaultTemplatePath . '/development.php';
131 39
        $this->maxSourceLines = $settings['maxSourceLines'] ?? 19;
132 39
        $this->maxTraceLines = $settings['maxTraceLines'] ?? 13;
133 39
        $this->traceHeaderLine = $settings['traceHeaderLine'] ?? null;
134
    }
135
136 5
    public function render(Throwable $t, ServerRequestInterface $request = null): ErrorData
137
    {
138 5
        return new ErrorData($this->renderTemplate($this->template, [
139 5
            'request' => $request,
140 5
            'throwable' => $t,
141 5
        ]));
142
    }
143
144 4
    public function renderVerbose(Throwable $t, ServerRequestInterface $request = null): ErrorData
145
    {
146 4
        return new ErrorData($this->renderTemplate($this->verboseTemplate, [
147 4
            'request' => $request,
148 4
            'throwable' => $t,
149 4
        ]));
150
    }
151
152
    /**
153
     * Encodes special characters into HTML entities for use as a content.
154
     *
155
     * @param string $content The content to be encoded.
156
     *
157
     * @return string Encoded content.
158
     */
159 18
    public function htmlEncode(string $content): string
160
    {
161 18
        return htmlspecialchars($content, ENT_QUOTES, 'UTF-8');
162
    }
163
164
    public function parseMarkdown(string $content): string
165
    {
166
        $html = $this->markdownParser->parse($content);
167
        /**
168
         * @psalm-suppress InvalidArgument
169
         *
170
         * @link https://github.com/vimeo/psalm/issues/4317
171
         */
172
        return strip_tags($html, [
173
            'h1',
174
            'h2',
175
            'h3',
176
            'h4',
177
            'h5',
178
            'h6',
179
            'hr',
180
            'pre',
181
            'code',
182
            'blockquote',
183
            'table',
184
            'tr',
185
            'td',
186
            'th',
187
            'thead',
188
            'tbody',
189
            'strong',
190
            'em',
191
            'b',
192
            'i',
193
            'u',
194
            's',
195
            'span',
196
            'a',
197
            'p',
198
            'br',
199
            'nobr',
200
            'ul',
201
            'ol',
202
            'li',
203
            'img',
204
        ]);
205
    }
206
207
    /**
208
     * Renders the previous exception stack for a given Exception.
209
     *
210
     * @param Throwable $t The exception whose precursors should be rendered.
211
     *
212
     * @throws Throwable
213
     *
214
     * @return string HTML content of the rendered previous exceptions. Empty string if there are none.
215
     */
216 2
    public function renderPreviousExceptions(Throwable $t): string
217
    {
218 2
        $templatePath = $this->defaultTemplatePath . '/_previous-exception.php';
219
220 2
        if ($t instanceof CompositeException) {
221
            $result = [];
222
            foreach ($t->getPreviousExceptions() as $exception) {
223
                $result[] = $this->renderTemplate($templatePath, ['throwable' => $exception]);
224
            }
225
            return implode('', $result);
226
        }
227 2
        if ($t->getPrevious() !== null) {
228 1
            return $this->renderTemplate($templatePath, ['throwable' => $t->getPrevious()]);
229
        }
230
231 2
        return '';
232
    }
233
234
    /**
235
     * Renders call stack.
236
     *
237
     * @param Throwable $t The exception to get call stack from.
238
     *
239
     * @throws Throwable
240
     *
241
     * @return string HTML content of the rendered call stack.
242
     *
243
     * @psalm-param DebugBacktraceType $trace
244
     */
245 2
    public function renderCallStack(Throwable $t, array $trace = []): string
246
    {
247 2
        $application = $vendor = [];
248 2
        $application[1] = $this->renderCallStackItem($t->getFile(), $t->getLine(), null, null, [], 1, false);
249
250 2
        $length = count($trace);
251 2
        for ($i = 0; $i < $length; ++$i) {
252 1
            $file = !empty($trace[$i]['file']) ? $trace[$i]['file'] : null;
253 1
            $line = !empty($trace[$i]['line']) ? $trace[$i]['line'] : null;
254 1
            $class = !empty($trace[$i]['class']) ? $trace[$i]['class'] : null;
255 1
            $args = !empty($trace[$i]['args']) ? $trace[$i]['args'] : [];
256
257 1
            $function = null;
258 1
            if (!empty($trace[$i]['function']) && $trace[$i]['function'] !== 'unknown') {
259 1
                $function = $trace[$i]['function'];
260
            }
261 1
            $index = $i + 2;
262
263 1
            if ($isVendor = $this->isVendorFile($file)) {
264
                $vendor[$index] = $this->renderCallStackItem($file, $line, $class, $function, $args, $index, $isVendor);
265
                continue;
266
            }
267
268 1
            $application[$index] = $this->renderCallStackItem($file, $line, $class, $function, $args, $index, $isVendor);
269
        }
270
271 2
        return $this->renderTemplate($this->defaultTemplatePath . '/_call-stack-items.php', [
272 2
            'applicationItems' => $application,
273 2
            'vendorItemGroups' => $this->groupVendorCallStackItems($vendor),
274 2
        ]);
275
    }
276
277
    /**
278
     * Converts arguments array to its string representation.
279
     *
280
     * @param array $args arguments array to be converted
281
     *
282
     * @return string The string representation of the arguments array.
283
     */
284 12
    public function argumentsToString(array $args, bool $truncate = true): string
285
    {
286 12
        $count = 0;
287 12
        $isAssoc = $args !== array_values($args);
288
289
        /**
290
         * @var mixed $value
291
         */
292 12
        foreach ($args as $key => $value) {
293 12
            $count++;
294
295 12
            if ($truncate && $count >= 5) {
296 1
                if ($count > 5) {
297 1
                    unset($args[$key]);
298
                } else {
299 1
                    $args[$key] = '...';
300
                }
301 1
                continue;
302
            }
303
304 12
            if (is_object($value)) {
305 2
                $args[$key] = '<span class="title">' . $this->htmlEncode($this->removeAnonymous($value::class)) . '</span>';
306 11
            } elseif (is_bool($value)) {
307 2
                $args[$key] = '<span class="keyword">' . ($value ? 'true' : 'false') . '</span>';
308 10
            } elseif (is_string($value)) {
309 6
                $fullValue = $this->htmlEncode($value);
310 6
                if ($truncate && mb_strlen($value, 'UTF-8') > 32) {
311 2
                    $displayValue = $this->htmlEncode(mb_substr($value, 0, 32, 'UTF-8')) . '...';
312 2
                    $args[$key] = "<span class=\"string\" title=\"$fullValue\">'$displayValue'</span>";
313
                } else {
314 6
                    $args[$key] = "<span class=\"string\">'$fullValue'</span>";
315
                }
316 6
            } elseif (is_array($value)) {
317 2
                unset($args[$key]);
318 2
                $args[$key] = '[' . $this->argumentsToString($value, $truncate) . ']';
319 5
            } elseif ($value === null) {
320 2
                $args[$key] = '<span class="keyword">null</span>';
321 4
            } elseif (is_resource($value)) {
322 1
                $args[$key] = '<span class="keyword">resource</span>';
323
            } else {
324 3
                $args[$key] = '<span class="number">' . (string) $value . '</span>';
325
            }
326
327 12
            if (is_string($key)) {
328 2
                $args[$key] = '<span class="string">\'' . $this->htmlEncode($key) . "'</span> => $args[$key]";
329 11
            } elseif ($isAssoc) {
330 1
                $args[$key] = "<span class=\"number\">$key</span> => $args[$key]";
331
            }
332
        }
333
334
        /** @var string[] $args */
335
336 12
        ksort($args);
337 12
        return implode(', ', $args);
338
    }
339
340
    /**
341
     * Renders the information about request.
342
     *
343
     * @return string The rendering result.
344
     */
345 2
    public function renderRequest(ServerRequestInterface $request): string
346
    {
347 2
        $output = $request->getMethod() . ' ' . $request->getUri() . "\n";
348
349 2
        $headers = $request->getHeaders();
350 2
        unset($headers['Host']);
351 2
        ksort($headers);
352
353 2
        foreach ($headers as $name => $values) {
354 2
            foreach ($values as $value) {
355 2
                $output .= "$name: $value\n";
356
            }
357
        }
358
359 2
        $body = (string)$request->getBody();
360 2
        if (!empty($body)) {
361
            $output .= "\n" . $body . "\n\n";
362
        }
363
364 2
        return $output;
365
    }
366
367
    /**
368
     * Renders the information about curl request.
369
     *
370
     * @return string The rendering result.
371
     */
372 2
    public function renderCurl(ServerRequestInterface $request): string
373
    {
374
        try {
375 2
            $output = (new Command())
376 2
                ->setRequest($request)
377 2
                ->build();
378 2
        } catch (Throwable $e) {
379 2
            return 'Error generating curl command: ' . $e->getMessage();
380
        }
381
382
        return $output;
383
    }
384
385
    /**
386
     * Creates string containing HTML link which refers to the home page
387
     * of determined web-server software and its full name.
388
     *
389
     * @return string The server software information hyperlink.
390
     */
391 9
    public function createServerInformationLink(ServerRequestInterface $request): string
392
    {
393 9
        $serverSoftware = (string) ($request->getServerParams()['SERVER_SOFTWARE'] ?? '');
394
395 9
        if ($serverSoftware === '') {
396 2
            return '';
397
        }
398
399 7
        $serverUrls = [
400 7
            'https://httpd.apache.org/' => ['apache'],
401 7
            'https://nginx.org/' => ['nginx'],
402 7
            'https://lighttpd.net/' => ['lighttpd'],
403 7
            'https://iis.net/' => ['iis', 'services'],
404 7
            'https://www.php.net/manual/en/features.commandline.webserver.php' => ['development'],
405 7
        ];
406
407 7
        foreach ($serverUrls as $url => $keywords) {
408 7
            foreach ($keywords as $keyword) {
409 7
                if (stripos($serverSoftware, $keyword) !== false) {
410 6
                    return '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">'
411 6
                        . $this->htmlEncode($serverSoftware) . '</a>';
412
                }
413
            }
414
        }
415
416 1
        return '';
417
    }
418
419
    /**
420
     * Returns the name of the throwable instance.
421
     *
422
     * @return string The name of the throwable instance.
423
     */
424 2
    public function getThrowableName(Throwable $throwable): string
425
    {
426 2
        $name = $throwable::class;
427
428 2
        if ($throwable instanceof FriendlyExceptionInterface) {
429 1
            $name = $throwable->getName() . ' (' . $name . ')';
430
        }
431
432 2
        return $name;
433
    }
434
435
    /**
436
     * Renders a template.
437
     *
438
     * @param string $path The full path of the template file for rendering.
439
     * @param array $parameters The name-value pairs that will be extracted and made available in the template file.
440
     *
441
     * @throws Throwable
442
     *
443
     * @return string The rendering result.
444
     *
445
     * @psalm-suppress PossiblyInvalidFunctionCall
446
     * @psalm-suppress PossiblyFalseArgument
447
     * @psalm-suppress UnresolvableInclude
448
     */
449 10
    private function renderTemplate(string $path, array $parameters): string
450
    {
451 10
        if (!file_exists($path)) {
452 1
            throw new RuntimeException("Template not found at $path");
453
        }
454
455 9
        $renderer = function (): void {
456
            /** @psalm-suppress MixedArgument */
457 9
            extract(func_get_arg(1), EXTR_OVERWRITE);
458 9
            require func_get_arg(0);
459 9
        };
460
461 9
        $obInitialLevel = ob_get_level();
462 9
        ob_start();
463 9
        ob_implicit_flush(false);
464
465
        try {
466
            /** @psalm-suppress PossiblyNullFunctionCall */
467 9
            $renderer->bindTo($this)($path, $parameters);
468 8
            return ob_get_clean();
469 1
        } catch (Throwable $e) {
470 1
            while (ob_get_level() > $obInitialLevel) {
471 1
                if (!@ob_end_clean()) {
472
                    ob_clean();
473
                }
474
            }
475 1
            throw $e;
476
        }
477
    }
478
479
    /**
480
     * Renders a single call stack element.
481
     *
482
     * @param string|null $file The name where call has happened.
483
     * @param int|null $line The number on which call has happened.
484
     * @param string|null $class The called class name.
485
     * @param string|null $function The called function/method name.
486
     * @param array $args The array of method arguments.
487
     * @param int $index The number of the call stack element.
488
     * @param bool $isVendorFile Whether given name of the file belongs to the vendor package.
489
     *
490
     * @throws Throwable
491
     *
492
     * @return string HTML content of the rendered call stack element.
493
     */
494 3
    private function renderCallStackItem(
495
        ?string $file,
496
        ?int $line,
497
        ?string $class,
498
        ?string $function,
499
        array $args,
500
        int $index,
501
        bool $isVendorFile
502
    ): string {
503 3
        $lines = [];
504 3
        $begin = $end = 0;
505
506 3
        if ($file !== null && $line !== null) {
507 3
            $line--; // adjust line number from one-based to zero-based
508 3
            $lines = @file($file);
509 3
            if ($line < 0 || $lines === false || ($lineCount = count($lines)) < $line) {
510 1
                return '';
511
            }
512 2
            $half = (int) (($index === 1 ? $this->maxSourceLines : $this->maxTraceLines) / 2);
513 2
            $begin = $line - $half > 0 ? $line - $half : 0;
514 2
            $end = $line + $half < $lineCount ? $line + $half : $lineCount - 1;
515
        }
516
517 2
        return $this->renderTemplate($this->defaultTemplatePath . '/_call-stack-item.php', [
518 2
            'file' => $file,
519 2
            'line' => $line,
520 2
            'class' => $class,
521 2
            'function' => $function,
522 2
            'index' => $index,
523 2
            'lines' => $lines,
524 2
            'begin' => $begin,
525 2
            'end' => $end,
526 2
            'args' => $args,
527 2
            'isVendorFile' => $isVendorFile,
528 2
        ]);
529
    }
530
531
    /**
532
     * Groups a vendor call stack items to render.
533
     *
534
     * @param array<int, string> $items The list of the vendor call stack items.
535
     *
536
     * @return array<int, array<int, string>> The grouped items of the vendor call stack.
537
     */
538 3
    private function groupVendorCallStackItems(array $items): array
539
    {
540 3
        $groupIndex = null;
541 3
        $groupedItems = [];
542
543 3
        foreach ($items as $index => $item) {
544 1
            if ($groupIndex === null) {
545 1
                $groupIndex = $index;
546 1
                $groupedItems[$groupIndex][$index] = $item;
547 1
                continue;
548
            }
549
550 1
            if (isset($items[$index - 1])) {
551 1
                $groupedItems[$groupIndex][$index] = $item;
552 1
                continue;
553
            }
554
555 1
            $groupIndex = $index;
556 1
            $groupedItems[$groupIndex][$index] = $item;
557
        }
558
559
        /** @psalm-var array<int, array<int, string>> $groupedItems It's need for Psalm <=4.30 only. */
560
561 3
        return $groupedItems;
562
    }
563
564
    /**
565
     * Determines whether given name of the file belongs to the vendor package.
566
     *
567
     * @param string|null $file The name to be checked.
568
     *
569
     * @return bool Whether given name of the file belongs to the vendor package.
570
     */
571 5
    private function isVendorFile(?string $file): bool
572
    {
573 5
        if ($file === null) {
574 1
            return false;
575
        }
576
577 4
        $file = realpath($file);
578
579 4
        if ($file === false) {
580 1
            return false;
581
        }
582
583 3
        foreach ($this->getVendorPaths() as $vendorPath) {
584 1
            if (str_starts_with($file, $vendorPath)) {
585 1
                return true;
586
            }
587
        }
588
589 3
        return false;
590
    }
591
592
    /**
593
     * Returns a list of vendor paths.
594
     *
595
     * @return string[] The list of vendor paths.
596
     *
597
     * @see $vendorPaths
598
     */
599 3
    private function getVendorPaths(): array
600
    {
601 3
        if ($this->vendorPaths !== null) {
602 2
            return $this->vendorPaths;
603
        }
604
605 2
        $rootPath = dirname(__DIR__, 4);
606
607
        // If the error handler is installed as a vendor package.
608
        /** @psalm-suppress InvalidLiteralArgument It is Psalm bug, {@see https://github.com/vimeo/psalm/issues/9196} */
609 2
        if (strlen($rootPath) > 6 && str_contains($rootPath, 'vendor')) {
610
            $this->vendorPaths = [$rootPath];
611
            return $this->vendorPaths;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->vendorPaths returns the type null which is incompatible with the type-hinted return array.
Loading history...
612
        }
613
614
        // If the error handler is installed for development in `yiisoft/yii-dev-tool`.
615 2
        if (is_file("{$rootPath}/yii-dev") || is_file("{$rootPath}/yii-dev.bat")) {
616
            $vendorPaths = glob("{$rootPath}/dev/*/vendor");
617
            /** @var string[] */
618
            $this->vendorPaths = empty($vendorPaths) ? [] : str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $vendorPaths);
619
            return $this->vendorPaths;
620
        }
621
622 2
        $this->vendorPaths = [];
623 2
        return $this->vendorPaths;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->vendorPaths returns the type null which is incompatible with the type-hinted return array.
Loading history...
624
    }
625
626 2
    public function removeAnonymous(string $value): string
627
    {
628 2
        $anonymousPosition = strpos($value, '@anonymous');
629
630 2
        return $anonymousPosition !== false ? substr($value, 0, $anonymousPosition) : $value;
631
    }
632
}
633