Passed
Push — master ( 72467b...56df5a )
by Dmitriy
50s queued 14s
created

HtmlRenderer   F

Complexity

Total Complexity 75

Size/Duplication

Total Lines 566
Duplicated Lines 0 %

Test Coverage

Coverage 80.43%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
wmc 75
eloc 224
c 2
b 0
f 0
dl 0
loc 566
ccs 189
cts 235
cp 0.8043
rs 2.4

17 Methods

Rating   Name   Duplication   Size   Complexity  
A renderTemplate() 0 27 5
A groupVendorCallStackItems() 0 24 4
A parseMarkdown() 0 40 1
B getVendorPaths() 0 25 7
A render() 0 5 1
A htmlEncode() 0 3 1
B renderCallStackItem() 0 34 9
A createServerInformationLink() 0 26 5
B renderCallStack() 0 28 9
A isVendorFile() 0 19 5
A renderRequest() 0 17 4
A renderVerbose() 0 5 1
A renderPreviousExceptions() 0 16 4
C argumentsToString() 0 54 14
A renderCurl() 0 11 2
A __construct() 0 11 1
A getThrowableName() 0 9 2

How to fix   Complexity   

Complex Class

Complex classes like HtmlRenderer often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use HtmlRenderer, and based on these observations, apply Extract Interface, too.

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