HtmlRenderer::getVendorPaths()   B
last analyzed

Complexity

Conditions 7
Paths 5

Size

Total Lines 25
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 9.7875

Importance

Changes 0
Metric Value
eloc 12
dl 0
loc 25
ccs 8
cts 13
cp 0.6153
rs 8.8333
c 0
b 0
f 0
cc 7
nc 5
nop 0
crap 9.7875
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(
249 2
            $t->getFile(),
250 2
            $t->getLine(),
251 2
            null,
252 2
            null,
253 2
            [],
254 2
            1,
255 2
            false,
256 2
            [],
257 2
        );
258
259 2
        $length = count($trace);
260 2
        for ($i = 0; $i < $length; ++$i) {
261 1
            $file = !empty($trace[$i]['file']) ? $trace[$i]['file'] : null;
262 1
            $line = !empty($trace[$i]['line']) ? $trace[$i]['line'] : null;
263 1
            $class = !empty($trace[$i]['class']) ? $trace[$i]['class'] : null;
264 1
            $args = !empty($trace[$i]['args']) ? $trace[$i]['args'] : [];
265
266 1
            $parameters = [];
267 1
            $function = null;
268 1
            if (!empty($trace[$i]['function']) && $trace[$i]['function'] !== 'unknown') {
269 1
                $function = $trace[$i]['function'];
270 1
                if ($class !== null && !str_contains($function, '{closure}')) {
271 1
                    $parameters = (new \ReflectionMethod($class, $function))->getParameters();
272
                }
273
            }
274 1
            $index = $i + 2;
275
276 1
            if ($this->isVendorFile($file)) {
277
                $vendor[$index] = $this->renderCallStackItem(
278
                    $file,
279
                    $line,
280
                    $class,
281
                    $function,
282
                    $args,
283
                    $index,
284
                    true,
285
                    $parameters,
286
                );
287
            } else {
288 1
                $application[$index] = $this->renderCallStackItem(
289 1
                    $file,
290 1
                    $line,
291 1
                    $class,
292 1
                    $function,
293 1
                    $args,
294 1
                    $index,
295 1
                    false,
296 1
                    $parameters,
297 1
                );
298
            }
299
        }
300
301 2
        return $this->renderTemplate($this->defaultTemplatePath . '/_call-stack-items.php', [
302 2
            'applicationItems' => $application,
303 2
            'vendorItemGroups' => $this->groupVendorCallStackItems($vendor),
304 2
        ]);
305
    }
306
307
    /**
308
     * Converts arguments array to its string representation.
309
     *
310
     * @param array $args arguments array to be converted
311
     *
312
     * @return string The string representation of the arguments array.
313
     */
314 12
    public function argumentsToString(array $args, bool $truncate = true): string
315
    {
316 12
        $count = 0;
317 12
        $isAssoc = $args !== array_values($args);
318
319
        /**
320
         * @var mixed $value
321
         */
322 12
        foreach ($args as $key => $value) {
323 12
            $count++;
324
325 12
            if ($truncate && $count >= 5) {
326 1
                if ($count > 5) {
327 1
                    unset($args[$key]);
328
                } else {
329 1
                    $args[$key] = '...';
330
                }
331 1
                continue;
332
            }
333
334 12
            if (is_object($value)) {
335 2
                $args[$key] = '<span class="title">' . $this->htmlEncode($this->removeAnonymous($value::class) . '#' . spl_object_id($value)) . '</span>';
336 11
            } elseif (is_bool($value)) {
337 2
                $args[$key] = '<span class="keyword">' . ($value ? 'true' : 'false') . '</span>';
338 10
            } elseif (is_string($value)) {
339 6
                $fullValue = $this->htmlEncode($value);
340 6
                if ($truncate && mb_strlen($value, 'UTF-8') > 32) {
341 2
                    $displayValue = $this->htmlEncode(mb_substr($value, 0, 32, 'UTF-8')) . '...';
342 2
                    $args[$key] = "<span class=\"string\" title=\"$fullValue\">'$displayValue'</span>";
343
                } else {
344 6
                    $args[$key] = "<span class=\"string\">'$fullValue'</span>";
345
                }
346 6
            } elseif (is_array($value)) {
347 2
                unset($args[$key]);
348 2
                $args[$key] = '[' . $this->argumentsToString($value, $truncate) . ']';
349 5
            } elseif ($value === null) {
350 2
                $args[$key] = '<span class="keyword">null</span>';
351 4
            } elseif (is_resource($value)) {
352 1
                $args[$key] = '<span class="keyword">resource</span>';
353
            } else {
354 3
                $args[$key] = '<span class="number">' . (string) $value . '</span>';
355
            }
356
357 12
            if (is_string($key)) {
358 2
                $args[$key] = '<span class="string">\'' . $this->htmlEncode($key) . "'</span> => $args[$key]";
359 11
            } elseif ($isAssoc) {
360 1
                $args[$key] = "<span class=\"number\">$key</span> => $args[$key]";
361
            }
362
        }
363
364
        /** @var string[] $args */
365
366 12
        ksort($args);
367 12
        return implode(', ', $args);
368
    }
369
370
    /**
371
     * Renders the information about request.
372
     *
373
     * @return string The rendering result.
374
     */
375 2
    public function renderRequest(ServerRequestInterface $request): string
376
    {
377 2
        $output = $request->getMethod() . ' ' . $request->getUri() . "\n";
378
379 2
        $headers = $request->getHeaders();
380 2
        unset($headers['Host']);
381 2
        ksort($headers);
382
383 2
        foreach ($headers as $name => $values) {
384 2
            foreach ($values as $value) {
385 2
                $output .= "$name: $value\n";
386
            }
387
        }
388
389 2
        $body = (string)$request->getBody();
390 2
        if (!empty($body)) {
391
            $output .= "\n" . $body . "\n\n";
392
        }
393
394 2
        return $output;
395
    }
396
397
    /**
398
     * Renders the information about curl request.
399
     *
400
     * @return string The rendering result.
401
     */
402 2
    public function renderCurl(ServerRequestInterface $request): string
403
    {
404
        try {
405 2
            $output = (new Command())
406 2
                ->setRequest($request)
407 2
                ->build();
408 2
        } catch (Throwable $e) {
409 2
            return 'Error generating curl command: ' . $e->getMessage();
410
        }
411
412
        return $output;
413
    }
414
415
    /**
416
     * Creates string containing HTML link which refers to the home page
417
     * of determined web-server software and its full name.
418
     *
419
     * @return string The server software information hyperlink.
420
     */
421 9
    public function createServerInformationLink(ServerRequestInterface $request): string
422
    {
423 9
        $serverSoftware = (string) ($request->getServerParams()['SERVER_SOFTWARE'] ?? '');
424
425 9
        if ($serverSoftware === '') {
426 2
            return '';
427
        }
428
429 7
        $serverUrls = [
430 7
            'https://httpd.apache.org/' => ['apache'],
431 7
            'https://nginx.org/' => ['nginx'],
432 7
            'https://lighttpd.net/' => ['lighttpd'],
433 7
            'https://iis.net/' => ['iis', 'services'],
434 7
            'https://www.php.net/manual/en/features.commandline.webserver.php' => ['development'],
435 7
        ];
436
437 7
        foreach ($serverUrls as $url => $keywords) {
438 7
            foreach ($keywords as $keyword) {
439 7
                if (stripos($serverSoftware, $keyword) !== false) {
440 6
                    return '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">'
441 6
                        . $this->htmlEncode($serverSoftware) . '</a>';
442
                }
443
            }
444
        }
445
446 1
        return '';
447
    }
448
449
    /**
450
     * Returns the name of the throwable instance.
451
     *
452
     * @return string The name of the throwable instance.
453
     */
454 2
    public function getThrowableName(Throwable $throwable): string
455
    {
456 2
        $name = $throwable::class;
457
458 2
        if ($throwable instanceof FriendlyExceptionInterface) {
459 1
            $name = $throwable->getName() . ' (' . $name . ')';
460
        }
461
462 2
        return $name;
463
    }
464
465
    /**
466
     * Renders a template.
467
     *
468
     * @param string $path The full path of the template file for rendering.
469
     * @param array $parameters The name-value pairs that will be extracted and made available in the template file.
470
     *
471
     * @throws Throwable
472
     *
473
     * @return string The rendering result.
474
     *
475
     * @psalm-suppress PossiblyInvalidFunctionCall
476
     * @psalm-suppress PossiblyFalseArgument
477
     * @psalm-suppress UnresolvableInclude
478
     */
479 10
    private function renderTemplate(string $path, array $parameters): string
480
    {
481 10
        if (!file_exists($path)) {
482 1
            throw new RuntimeException("Template not found at $path");
483
        }
484
485 9
        $renderer = function (): void {
486
            /** @psalm-suppress MixedArgument */
487 9
            extract(func_get_arg(1), EXTR_OVERWRITE);
488 9
            require func_get_arg(0);
489 9
        };
490
491 9
        $obInitialLevel = ob_get_level();
492 9
        ob_start();
493 9
        ob_implicit_flush(false);
494
495
        try {
496
            /** @psalm-suppress PossiblyNullFunctionCall */
497 9
            $renderer->bindTo($this)($path, $parameters);
498 8
            return ob_get_clean();
499 1
        } catch (Throwable $e) {
500 1
            while (ob_get_level() > $obInitialLevel) {
501 1
                if (!@ob_end_clean()) {
502
                    ob_clean();
503
                }
504
            }
505 1
            throw $e;
506
        }
507
    }
508
509
    /**
510
     * Renders a single call stack element.
511
     *
512
     * @param string|null $file The name where call has happened.
513
     * @param int|null $line The number on which call has happened.
514
     * @param string|null $class The called class name.
515
     * @param string|null $function The called function/method name.
516
     * @param array $args The array of method arguments.
517
     * @param int $index The number of the call stack element.
518
     * @param bool $isVendorFile Whether given name of the file belongs to the vendor package.
519
     *
520
     * @throws Throwable
521
     *
522
     * @return string HTML content of the rendered call stack element.
523
     */
524 3
    private function renderCallStackItem(
525
        ?string $file,
526
        ?int $line,
527
        ?string $class,
528
        ?string $function,
529
        array $args,
530
        int $index,
531
        bool $isVendorFile,
532
        array $reflectionParameters,
533
    ): string {
534 3
        $lines = [];
535 3
        $begin = $end = 0;
536
537 3
        if ($file !== null && $line !== null) {
538 3
            $line--; // adjust line number from one-based to zero-based
539 3
            $lines = @file($file);
540 3
            if ($line < 0 || $lines === false || ($lineCount = count($lines)) < $line) {
541 1
                return '';
542
            }
543 2
            $half = (int) (($index === 1 ? $this->maxSourceLines : $this->maxTraceLines) / 2);
544 2
            $begin = $line - $half > 0 ? $line - $half : 0;
545 2
            $end = $line + $half < $lineCount ? $line + $half : $lineCount - 1;
546
        }
547
548 2
        return $this->renderTemplate($this->defaultTemplatePath . '/_call-stack-item.php', [
549 2
            'file' => $file,
550 2
            'line' => $line,
551 2
            'class' => $class,
552 2
            'function' => $function,
553 2
            'index' => $index,
554 2
            'lines' => $lines,
555 2
            'begin' => $begin,
556 2
            'end' => $end,
557 2
            'args' => $args,
558 2
            'isVendorFile' => $isVendorFile,
559 2
            'reflectionParameters' => $reflectionParameters,
560 2
        ]);
561
    }
562
563
    /**
564
     * Groups a vendor call stack items to render.
565
     *
566
     * @param array<int, string> $items The list of the vendor call stack items.
567
     *
568
     * @return array<int, array<int, string>> The grouped items of the vendor call stack.
569
     */
570 3
    private function groupVendorCallStackItems(array $items): array
571
    {
572 3
        $groupIndex = null;
573 3
        $groupedItems = [];
574
575 3
        foreach ($items as $index => $item) {
576 1
            if ($groupIndex === null) {
577 1
                $groupIndex = $index;
578 1
                $groupedItems[$groupIndex][$index] = $item;
579 1
                continue;
580
            }
581
582 1
            if (isset($items[$index - 1])) {
583 1
                $groupedItems[$groupIndex][$index] = $item;
584 1
                continue;
585
            }
586
587 1
            $groupIndex = $index;
588 1
            $groupedItems[$groupIndex][$index] = $item;
589
        }
590
591
        /** @psalm-var array<int, array<int, string>> $groupedItems It's need for Psalm <=4.30 only. */
592
593 3
        return $groupedItems;
594
    }
595
596
    /**
597
     * Determines whether given name of the file belongs to the vendor package.
598
     *
599
     * @param string|null $file The name to be checked.
600
     *
601
     * @return bool Whether given name of the file belongs to the vendor package.
602
     */
603 5
    private function isVendorFile(?string $file): bool
604
    {
605 5
        if ($file === null) {
606 1
            return false;
607
        }
608
609 4
        $file = realpath($file);
610
611 4
        if ($file === false) {
612 1
            return false;
613
        }
614
615 3
        foreach ($this->getVendorPaths() as $vendorPath) {
616 1
            if (str_starts_with($file, $vendorPath)) {
617 1
                return true;
618
            }
619
        }
620
621 3
        return false;
622
    }
623
624
    /**
625
     * Returns a list of vendor paths.
626
     *
627
     * @return string[] The list of vendor paths.
628
     *
629
     * @see $vendorPaths
630
     */
631 3
    private function getVendorPaths(): array
632
    {
633 3
        if ($this->vendorPaths !== null) {
634 2
            return $this->vendorPaths;
635
        }
636
637 2
        $rootPath = dirname(__DIR__, 4);
638
639
        // If the error handler is installed as a vendor package.
640
        /** @psalm-suppress InvalidLiteralArgument It is Psalm bug, {@see https://github.com/vimeo/psalm/issues/9196} */
641 2
        if (strlen($rootPath) > 6 && str_contains($rootPath, 'vendor')) {
642
            $this->vendorPaths = [$rootPath];
643
            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...
644
        }
645
646
        // If the error handler is installed for development in `yiisoft/yii-dev-tool`.
647 2
        if (is_file("{$rootPath}/yii-dev") || is_file("{$rootPath}/yii-dev.bat")) {
648
            $vendorPaths = glob("{$rootPath}/dev/*/vendor");
649
            /** @var string[] */
650
            $this->vendorPaths = empty($vendorPaths) ? [] : str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $vendorPaths);
651
            return $this->vendorPaths;
652
        }
653
654 2
        $this->vendorPaths = [];
655 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...
656
    }
657
658 2
    public function removeAnonymous(string $value): string
659
    {
660 2
        $anonymousPosition = strpos($value, '@anonymous');
661
662 2
        return $anonymousPosition !== false ? substr($value, 0, $anonymousPosition) : $value;
663
    }
664
}
665