Passed
Pull Request — master (#38)
by Evgeniy
03:11
created

HtmlRenderer::renderPreviousExceptions()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

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