Test Failed
Pull Request — master (#38)
by Evgeniy
02:49
created

HtmlRenderer::getVendorPaths()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 23
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 42

Importance

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