Passed
Pull Request — master (#26)
by Evgeniy
03:07
created

HtmlRenderer   D

Complexity

Total Complexity 58

Size/Duplication

Total Lines 387
Duplicated Lines 0 %

Test Coverage

Coverage 80.85%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
wmc 58
eloc 141
c 2
b 0
f 0
dl 0
loc 387
ccs 114
cts 141
cp 0.8085
rs 4.5599

14 Methods

Rating   Name   Duplication   Size   Complexity  
A renderTemplate() 0 25 6
A render() 0 5 1
A htmlEncode() 0 3 1
B renderCallStackItem() 0 26 9
A createServerInformationLink() 0 26 5
B renderCallStack() 0 19 8
A renderRequest() 0 17 4
A isCoreFile() 0 3 2
A renderVerbose() 0 5 1
A renderPreviousExceptions() 0 8 2
C argumentsToString() 0 49 14
A renderCurl() 0 9 2
A __construct() 0 8 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 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 htmlspecialchars;
23
use function implode;
24
use function is_array;
25
use function is_bool;
26
use function is_object;
27
use function is_resource;
28
use function is_string;
29
use function ksort;
30
use function mb_strlen;
31
use function mb_substr;
32
use function ob_clean;
33
use function ob_get_clean;
34
use function ob_get_level;
35
use function ob_end_clean;
36
use function ob_implicit_flush;
37
use function ob_start;
38
use function realpath;
39
use function rtrim;
40
use function stripos;
41
use function strpos;
42
43
/**
44
 * Formats throwable into HTML string.
45
 */
46
final class HtmlRenderer implements ThrowableRendererInterface
47
{
48
    /**
49
     * @var string The full path to the default template directory.
50
     */
51
    private string $defaultTemplatePath;
52
53
    /**
54
     * @var string The full path of the template file for rendering exceptions without call stack information.
55
     *
56
     * This template should be used in production.
57
     */
58
    private string $template;
59
60
    /**
61
     * @var string The full path of the template file for rendering exceptions with call stack information.
62
     *
63
     * This template should be used in development.
64
     */
65
    private string $verboseTemplate;
66
67
    /**
68
     * @var int The maximum number of source code lines to be displayed. Defaults to 19.
69
     */
70
    private int $maxSourceLines;
71
72
    /**
73
     * @var int The maximum number of trace source code lines to be displayed. Defaults to 13.
74
     */
75
    private int $maxTraceLines;
76
77
    /**
78
     * @var string|null The trace header line with placeholders to be be substituted. Defaults to null.
79
     *
80
     * The placeholders are {file}, {line} and {icon}. A typical use case is the creation of IDE-specific links,
81
     * since when you click on a trace header link, it opens directly in the IDE. You can also insert custom content.
82
     *
83
     * Example IDE link:
84
     *
85
     * ```
86
     * <a href="ide://open?file={file}&line={line}">{icon}</a>
87
     * ```
88
     */
89
    private ?string $traceHeaderLine;
90
91
    /**
92
     * @param array $settings Settings can have the following keys:
93
     * - template: string, full path of the template file for rendering exceptions without call stack information.
94
     * - verboseTemplate: string, full path of the template file for rendering exceptions with call stack information.
95
     * - maxSourceLines: int, maximum number of source code lines to be displayed. Defaults to 19.
96
     * - maxTraceLines: int, maximum number of trace source code lines to be displayed. Defaults to 13.
97
     * - traceHeaderLine: string, trace header line with placeholders to be be substituted. Defaults to null.
98
     */
99 5
    public function __construct(array $settings = [])
100
    {
101 5
        $this->defaultTemplatePath = dirname(__DIR__, 2) . '/templates';
102 5
        $this->template = $settings['template'] ?? $this->defaultTemplatePath . '/production.php';
103 5
        $this->verboseTemplate = $settings['verboseTemplate'] ?? $this->defaultTemplatePath . '/development.php';
104 5
        $this->maxSourceLines = $settings['maxSourceLines']  ?? 19;
105 5
        $this->maxTraceLines = $settings['maxTraceLines']  ?? 13;
106 5
        $this->traceHeaderLine = $settings['traceHeaderLine'] ?? null;
107 5
    }
108
109 3
    public function render(Throwable $t, ServerRequestInterface $request = null): ErrorData
110
    {
111 3
        return new ErrorData($this->renderTemplate($this->template, [
112 3
            'request' => $request,
113 3
            'throwable' => $t,
114
        ]));
115
    }
116
117 2
    public function renderVerbose(Throwable $t, ServerRequestInterface $request = null): ErrorData
118
    {
119 2
        return new ErrorData($this->renderTemplate($this->verboseTemplate, [
120 2
            'request' => $request,
121 2
            'throwable' => $t,
122
        ]));
123
    }
124
125
    /**
126
     * Encodes special characters into HTML entities for use as a content.
127
     *
128
     * @param string $content The content to be encoded.
129
     *
130
     * @return string Encoded content.
131
     */
132 2
    private function htmlEncode(string $content): string
133
    {
134 2
        return htmlspecialchars($content, ENT_QUOTES, 'UTF-8');
135
    }
136
137
    /**
138
     * Renders a template.
139
     *
140
     * @param string $path The full path of the template file for rendering.
141
     * @param array $parameters The name-value pairs that will be extracted and made available in the template file.
142
     *
143
     * @throws Throwable
144
     *
145
     * @return string The rendering result.
146
     */
147 5
    private function renderTemplate(string $path, array $parameters): string
148
    {
149 5
        if (!file_exists($path)) {
150 1
            throw new RuntimeException("Template not found at $path");
151
        }
152
153 4
        $renderer = function (): void {
154 4
            extract(func_get_arg(1), EXTR_OVERWRITE);
155 4
            require func_get_arg(0);
156 4
        };
157
158 4
        $obInitialLevel = ob_get_level();
159 4
        ob_start();
160 4
        PHP_VERSION_ID >= 80000 ? ob_implicit_flush(false) : ob_implicit_flush(0);
161
162
        try {
163 4
            $renderer->bindTo($this)($path, $parameters);
164 4
            return ob_get_clean();
165
        } catch (Throwable $e) {
166
            while (ob_get_level() > $obInitialLevel) {
167
                if (!@ob_end_clean()) {
168
                    ob_clean();
169
                }
170
            }
171
            throw $e;
172
        }
173
    }
174
175
    /**
176
     * Renders the previous exception stack for a given Exception.
177
     *
178
     * @param Throwable $t The exception whose precursors should be rendered.
179
     *
180
     * @throws Throwable
181
     *
182
     * @return string HTML content of the rendered previous exceptions. Empty string if there are none.
183
     */
184 1
    private function renderPreviousExceptions(Throwable $t): string
0 ignored issues
show
Unused Code introduced by
The method renderPreviousExceptions() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
185
    {
186 1
        if (($previous = $t->getPrevious()) !== null) {
187
            $templatePath = $this->defaultTemplatePath . '/_previous-exception.php';
188
            return $this->renderTemplate($templatePath, ['throwable' => $previous]);
189
        }
190
191 1
        return '';
192
    }
193
194
    /**
195
     * Renders call stack.
196
     *
197
     * @param Throwable $t The exception to get call stack from.
198
     *
199
     * @throws Throwable
200
     *
201
     * @return string HTML content of the rendered call stack.
202
     */
203 1
    private function renderCallStack(Throwable $t): string
0 ignored issues
show
Unused Code introduced by
The method renderCallStack() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
204
    {
205 1
        $out = '<ul>';
206 1
        $out .= $this->renderCallStackItem($t->getFile(), $t->getLine(), null, null, [], 1);
207
208 1
        for ($i = 0, $trace = $t->getTrace(), $length = count($trace); $i < $length; ++$i) {
209 1
            $file = !empty($trace[$i]['file']) ? $trace[$i]['file'] : null;
210 1
            $line = !empty($trace[$i]['line']) ? $trace[$i]['line'] : null;
211 1
            $class = !empty($trace[$i]['class']) ? $trace[$i]['class'] : null;
212 1
            $function = null;
213 1
            if (!empty($trace[$i]['function']) && $trace[$i]['function'] !== 'unknown') {
214 1
                $function = $trace[$i]['function'];
215
            }
216 1
            $args = !empty($trace[$i]['args']) ? $trace[$i]['args'] : [];
217 1
            $out .= $this->renderCallStackItem($file, $line, $class, $function, $args, $i + 2);
218
        }
219
220 1
        $out .= '</ul>';
221 1
        return $out;
222
    }
223
224
    /**
225
     * Renders a single call stack element.
226
     *
227
     * @param string|null $file The name where call has happened.
228
     * @param int|null $line The number on which call has happened.
229
     * @param string|null $class The called class name.
230
     * @param string|null $function The called function/method name.
231
     * @param array $args The array of method arguments.
232
     * @param int $index The number of the call stack element.
233
     *
234
     * @throws Throwable
235
     *
236
     * @return string HTML content of the rendered call stack element.
237
     */
238 1
    private function renderCallStackItem(?string $file, ?int $line, ?string $class, ?string $function, array $args, int $index): string
239
    {
240 1
        $lines = [];
241 1
        $begin = $end = 0;
242
243 1
        if ($file !== null && $line !== null) {
244 1
            $line--; // adjust line number from one-based to zero-based
245 1
            $lines = @file($file);
246 1
            if ($line < 0 || $lines === false || ($lineCount = count($lines)) < $line) {
247
                return '';
248
            }
249 1
            $half = (int) (($index === 1 ? $this->maxSourceLines : $this->maxTraceLines) / 2);
250 1
            $begin = $line - $half > 0 ? $line - $half : 0;
251 1
            $end = $line + $half < $lineCount ? $line + $half : $lineCount - 1;
252
        }
253
254 1
        return $this->renderTemplate($this->defaultTemplatePath . '/_call-stack-item.php', [
255 1
            'file' => $file,
256 1
            'line' => $line,
257 1
            'class' => $class,
258 1
            'function' => $function,
259 1
            'index' => $index,
260 1
            'lines' => $lines,
261 1
            'begin' => $begin,
262 1
            'end' => $end,
263 1
            'args' => $args,
264
        ]);
265
    }
266
267
    /**
268
     * Converts arguments array to its string representation.
269
     *
270
     * @param array $args arguments array to be converted
271
     *
272
     * @return string The string representation of the arguments array.
273
     */
274 1
    private function argumentsToString(array $args): string
275
    {
276 1
        $count = 0;
277 1
        $isAssoc = $args !== array_values($args);
278
279 1
        foreach ($args as $key => $value) {
280 1
            $count++;
281
282 1
            if ($count >= 5) {
283 1
                if ($count > 5) {
284 1
                    unset($args[$key]);
285
                } else {
286 1
                    $args[$key] = '...';
287
                }
288 1
                continue;
289
            }
290
291 1
            if (is_object($value)) {
292 1
                $args[$key] = '<span class="title">' . $this->htmlEncode(get_class($value)) . '</span>';
293 1
            } elseif (is_bool($value)) {
294 1
                $args[$key] = '<span class="keyword">' . ($value ? 'true' : 'false') . '</span>';
295 1
            } elseif (is_string($value)) {
296 1
                $fullValue = $this->htmlEncode($value);
297 1
                if (mb_strlen($value, 'UTF-8') > 32) {
298
                    $displayValue = $this->htmlEncode(mb_substr($value, 0, 32, 'UTF-8')) . '...';
299
                    $args[$key] = "<span class=\"string\" title=\"$fullValue\">'$displayValue'</span>";
300
                } else {
301 1
                    $args[$key] = "<span class=\"string\">'$fullValue'</span>";
302
                }
303 1
            } elseif (is_array($value)) {
304 1
                unset($args[$key]);
305 1
                $args[$key] = '[' . $this->argumentsToString($value) . ']';
306
            } elseif ($value === null) {
307
                $args[$key] = '<span class="keyword">null</span>';
308
            } elseif (is_resource($value)) {
309
                $args[$key] = '<span class="keyword">resource</span>';
310
            } else {
311
                $args[$key] = '<span class="number">' . $value . '</span>';
312
            }
313
314 1
            if (is_string($key)) {
315 1
                $args[$key] = '<span class="string">\'' . $this->htmlEncode($key) . "'</span> => $args[$key]";
316 1
            } elseif ($isAssoc) {
317
                $args[$key] = "<span class=\"number\">$key</span> => $args[$key]";
318
            }
319
        }
320
321 1
        ksort($args);
322 1
        return implode(', ', $args);
323
    }
324
325
    /**
326
     * Renders the information about request.
327
     *
328
     * @param ServerRequestInterface $request
329
     *
330
     * @return string The rendering result.
331
     */
332 1
    private function renderRequest(ServerRequestInterface $request): string
0 ignored issues
show
Unused Code introduced by
The method renderRequest() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
333
    {
334 1
        $output = $request->getMethod() . ' ' . $request->getUri() . "\n";
335
336 1
        foreach ($request->getHeaders() as $name => $values) {
337 1
            if ($name === 'Host') {
338
                continue;
339
            }
340
341 1
            foreach ($values as $value) {
342 1
                $output .= "$name: $value\n";
343
            }
344
        }
345
346 1
        $output .= "\n" . $request->getBody() . "\n\n";
347
348 1
        return '<pre>' . $this->htmlEncode(rtrim($output, "\n")) . '</pre>';
349
    }
350
351
    /**
352
     * Renders the information about curl request.
353
     *
354
     * @param ServerRequestInterface $request
355
     *
356
     * @return string The rendering result.
357
     */
358 1
    private function renderCurl(ServerRequestInterface $request): string
0 ignored issues
show
Unused Code introduced by
The method renderCurl() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
359
    {
360
        try {
361 1
            $output = (new Command())->setRequest($request)->build();
362
        } catch (Throwable $e) {
363
            $output = 'Error generating curl command: ' . $e->getMessage();
364
        }
365
366 1
        return $this->htmlEncode($output);
367
    }
368
369
    /**
370
     * Creates string containing HTML link which refers to the home page
371
     * of determined web-server software and its full name.
372
     *
373
     * @param ServerRequestInterface $request
374
     *
375
     * @return string The server software information hyperlink.
376
     */
377 1
    private function createServerInformationLink(ServerRequestInterface $request): string
0 ignored issues
show
Unused Code introduced by
The method createServerInformationLink() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
378
    {
379 1
        $serverSoftware = $request->getServerParams()['SERVER_SOFTWARE'] ?? null;
380
381 1
        if ($serverSoftware === null) {
382 1
            return '';
383
        }
384
385
        $serverUrls = [
386
            'https://httpd.apache.org/' => ['apache'],
387
            'https://nginx.org/' => ['nginx'],
388
            'https://lighttpd.net/' => ['lighttpd'],
389
            'https://iis.net/' => ['iis', 'services'],
390
            'https://www.php.net/manual/en/features.commandline.webserver.php' => ['development'],
391
        ];
392
393
        foreach ($serverUrls as $url => $keywords) {
394
            foreach ($keywords as $keyword) {
395
                if (stripos($serverSoftware, $keyword) !== false) {
396
                    return '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">'
397
                        . $this->htmlEncode($serverSoftware) . '</a>';
398
                }
399
            }
400
        }
401
402
        return '';
403
    }
404
405
    /**
406
     * Returns the name of the throwable instance.
407
     *
408
     * @param Throwable $throwable
409
     *
410
     * @return string The name of the throwable instance.
411
     */
412 1
    private function getThrowableName(Throwable $throwable): string
0 ignored issues
show
Unused Code introduced by
The method getThrowableName() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
413
    {
414 1
        $name = get_class($throwable);
415
416 1
        if ($throwable instanceof FriendlyExceptionInterface) {
417
            $name = $throwable->getName() . ' (' . $name . ')';
418
        }
419
420 1
        return $name;
421
    }
422
423
    /**
424
     * Determines whether given name of the file belongs to the framework.
425
     *
426
     * @param string|null $file The name to be checked.
427
     *
428
     * @return bool Whether given name of the file belongs to the framework.
429
     */
430 1
    private function isCoreFile(?string $file): bool
0 ignored issues
show
Unused Code introduced by
The method isCoreFile() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
431
    {
432 1
        return $file === null || strpos(realpath($file), dirname(__DIR__, 3)) === 0;
433
    }
434
}
435