Passed
Pull Request — master (#38)
by Evgeniy
02:52
created

HtmlRenderer::render()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

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