Test Failed
Pull Request — master (#57)
by Sergei
02:40
created

HtmlRenderer::parseMarkdown()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 35
Code Lines 33

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 1

Importance

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