Passed
Pull Request — master (#57)
by Sergei
02:44
created

HtmlRenderer   F

Complexity

Total Complexity 74

Size/Duplication

Total Lines 547
Duplicated Lines 0 %

Test Coverage

Coverage 93.6%

Importance

Changes 2
Bugs 1 Features 0
Metric Value
wmc 74
eloc 219
c 2
b 1
f 0
dl 0
loc 547
ccs 161
cts 172
cp 0.936
rs 2.48

17 Methods

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