ConsoleRenderer   A
last analyzed

Complexity

Total Complexity 38

Size/Duplication

Total Lines 239
Duplicated Lines 0 %

Test Coverage

Coverage 98.2%

Importance

Changes 0
Metric Value
wmc 38
eloc 126
dl 0
loc 239
ccs 109
cts 111
cp 0.982
rs 9.36
c 0
b 0
f 0

7 Methods

Rating   Name   Duplication   Size   Complexity  
A renderHeader() 0 23 2
A setColorsSupport() 0 3 1
A __construct() 0 5 2
B render() 0 53 9
A format() 0 18 4
B isColorsSupported() 0 17 8
C renderTrace() 0 63 12
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Spiral\Exceptions\Renderer;
6
7
use Codedungeon\PHPCliColors\Color;
8
use Spiral\Exceptions\Style\ConsoleStyle;
9
use Spiral\Exceptions\Style\PlainStyle;
10
use Spiral\Exceptions\Verbosity;
11
12
/**
13
 * Verbosity levels:
14
 *
15
 * 1) {@see Verbosity::BASIC} - only message header and line number
16
 * 2) {@see Verbosity::VERBOSE} - stack information
17
 * 3) {@see Verbosity::DEBUG} - stack and source information.
18
 */
19
class ConsoleRenderer extends AbstractRenderer
20
{
21
    // Lines to show around targeted line.
22
    public const SHOW_LINES = 2;
23
    protected const FORMATS = ['console', 'cli'];
24
    protected const COLORS = [
25
        'bg:red' => Color::BG_RED,
26
        'bg:cyan' => Color::BG_CYAN,
27
        'bg:magenta' => Color::BG_MAGENTA,
28
        'bg:white' => Color::BG_WHITE,
29
        'white' => Color::LIGHT_WHITE,
30
        'green' => Color::GREEN,
31
        'gray' => Color::GRAY,
32
        'black' => Color::BLACK,
33
        'red' => Color::RED,
34
        'yellow' => Color::YELLOW,
35
        'reset' => Color::RESET,
36
    ];
37
38
    private array $lines = [];
39
    private bool $colorsSupport;
40
41
    /**
42
     * @param bool|resource|null $stream
43
     */
44 7
    public function __construct(mixed $stream = null)
45
    {
46 7
        $stream ??= \defined('\STDOUT') ? \STDOUT : \fopen('php://stdout', 'wb');
47
48 7
        $this->colorsSupport = $this->isColorsSupported($stream);
49
    }
50
51
    /**
52
     * Disable or enable colorization support.
53
     */
54 6
    public function setColorsSupport(bool $enabled = true): void
55
    {
56 6
        $this->colorsSupport = $enabled;
57
    }
58
59 7
    public function render(
60
        \Throwable $exception,
61
        ?Verbosity $verbosity = null,
62
        ?string $format = null,
63
    ): string {
64 7
        $verbosity ??= $this->defaultVerbosity;
65
66 7
        $exceptions = [$exception];
67 7
        $currentE = $exception;
68
69 7
        while ($exception = $exception->getPrevious()) {
70 1
            $exceptions[] = $exception;
71
        }
72
73 7
        $exceptions = \array_reverse($exceptions);
74
75 7
        $result = [];
76 7
        $rootDir = \getcwd();
77
78 7
        foreach ($exceptions as $exception) {
79 7
            $prefix = $currentE === $exception ? '' : 'Previous: ';
80 7
            $row = $this->renderHeader(
81 7
                \sprintf("%s[%s]\n%s", $prefix, $exception::class, $exception->getMessage()),
82 7
                $exception instanceof \Error ? 'bg:magenta,white' : 'bg:red,white',
83 7
            );
84
85 7
            $file = \str_starts_with($exception->getFile(), $rootDir)
86 7
                ? \substr($exception->getFile(), \strlen($rootDir) + 1)
87
                : $exception->getFile();
88
89 7
            $row .= $this->format(
90 7
                "<yellow>in</reset> <green>%s</reset><yellow>:</reset><white>%s</reset>\n",
91 7
                $file,
92 7
                $exception->getLine(),
93 7
            );
94
95 7
            if ($verbosity->value >= Verbosity::DEBUG->value) {
96 2
                $row .= $this->renderTrace(
97 2
                    $exception,
98 2
                    new Highlighter(
99 2
                        $this->colorsSupport ? new ConsoleStyle() : new PlainStyle(),
100 2
                    ),
101 2
                );
102 5
            } elseif ($verbosity->value >= Verbosity::VERBOSE->value) {
103 1
                $row .= $this->renderTrace($exception);
104
            }
105
106 7
            $result[] = $row;
107
        }
108
109 7
        $this->lines = [];
110
111 7
        return \implode("\n", \array_reverse($result));
112
    }
113
114
    /**
115
     * Render title using outlining border.
116
     *
117
     * @param string $title Title.
118
     * @param string $style Formatting.
119
     */
120 7
    private function renderHeader(string $title, string $style, int $padding = 0): string
121
    {
122 7
        $result = '';
123
124 7
        $lines = \explode("\n", \str_replace("\r", '', $title));
125
126 7
        $length = 0;
127 7
        \array_walk($lines, static function ($v) use (&$length): void {
128 7
            $length = \max($length, \mb_strlen($v));
129 7
        });
130
131 7
        $length += $padding;
132
133 7
        foreach ($lines as $line) {
134 7
            $result .= $this->format(
135 7
                "<{$style}>%s%s%s</reset>\n",
136 7
                \str_repeat('', $padding + 1),
137 7
                $line,
138 7
                \str_repeat('', $length - \mb_strlen($line) + 1),
139 7
            );
140
        }
141
142 7
        return $result;
143
    }
144
145
    /**
146
     * Render exception call stack.
147
     */
148 3
    private function renderTrace(\Throwable $e, ?Highlighter $h = null): string
149
    {
150 3
        $stacktrace = $this->getStacktrace($e);
151 3
        if (empty($stacktrace)) {
152
            return '';
153
        }
154
155 3
        $result = "\n";
156 3
        $rootDir = \getcwd();
157
158 3
        $pad = \strlen((string) \count($stacktrace));
159
160 3
        foreach ($stacktrace as $i => $trace) {
161 3
            $file = isset($trace['file']) ? (string) $trace['file'] : null;
162 3
            $classColor = 'while';
163
164 3
            if ($file !== null) {
165 3
                \str_starts_with($file, $rootDir) and $file = \substr($file, \strlen($rootDir) + 1);
166 3
                $classColor = \str_starts_with($file, 'vendor/') ? 'gray' : 'white';
167
            }
168
169 3
            if (isset($trace['type'], $trace['class'])) {
170 3
                $line = $this->format(
171 3
                    "<$classColor>%s.</reset> <white>%s%s%s()</reset>",
172 3
                    \str_pad((string) ((int) $i + 1), $pad, ' ', \STR_PAD_LEFT),
173 3
                    $trace['class'],
174 3
                    $trace['type'],
175 3
                    $trace['function'],
176 3
                );
177
            } else {
178 3
                $line = $this->format(
179 3
                    ' <white>%s()</reset>',
180 3
                    $trace['function'],
181 3
                );
182
            }
183 3
            if ($file !== null) {
184 3
                $line .= $this->format(
185 3
                    ' <yellow>at</reset> <green>%s</reset><yellow>:</reset><white>%s</reset>',
186 3
                    $file,
187 3
                    $trace['line'],
188 3
                );
189
            }
190
191 3
            if (\in_array($line, $this->lines, true)) {
192 1
                continue;
193
            }
194
195 3
            $this->lines[] = $line;
196
197 3
            $result .= $line . "\n";
198
199 3
            if ($h !== null && !empty($trace['file'])) {
200 2
                $str = @\file_get_contents($trace['file']);
201 2
                $result .= $h->highlightLines(
202 2
                    $str,
0 ignored issues
show
Bug introduced by
It seems like $str can also be of type false; however, parameter $source of Spiral\Exceptions\Render...ghter::highlightLines() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

202
                    /** @scrutinizer ignore-type */ $str,
Loading history...
203 2
                    $trace['line'],
204 2
                    static::SHOW_LINES,
205 2
                ) . "\n";
206 2
                unset($str);
207
            }
208
        }
209
210 3
        return $result;
211
    }
212
213
    /**
214
     * Format string and apply color formatting (if enabled).
215
     */
216 7
    private function format(string $format, mixed ...$args): string
217
    {
218 7
        if (!$this->colorsSupport) {
219 1
            $format = \preg_replace('/<[^>]+>/', '', $format);
220
        } else {
221 6
            $format = \preg_replace_callback('/(<([^>]+)>)/', static function ($partial) {
222 6
                $style = '';
223 6
                foreach (\explode(',', \trim($partial[2], '/')) as $color) {
224 6
                    if (isset(self::COLORS[$color])) {
225 6
                        $style .= self::COLORS[$color];
226
                    }
227
                }
228
229 6
                return $style;
230 6
            }, $format);
231
        }
232
233 7
        return \sprintf($format, ...$args);
234
    }
235
236
    /**
237
     * Returns true if the STDOUT supports colorization.
238
     * @codeCoverageIgnore
239
     * @link https://github.com/symfony/Console/blob/master/Output/StreamOutput.php#L94
240
     */
241
    private function isColorsSupported(mixed $stream = STDOUT): bool
242
    {
243
        if (\getenv('TERM_PROGRAM') === 'Hyper') {
244
            return true;
245
        }
246
247
        try {
248
            if (\DIRECTORY_SEPARATOR === '\\') {
249
                return (\function_exists('sapi_windows_vt100_support') && @\sapi_windows_vt100_support($stream))
250
                    || \getenv('ANSICON') !== false
251
                    || \getenv('ConEmuANSI') === 'ON'
252
                    || \getenv('TERM') === 'xterm';
253
            }
254
255
            return @\stream_isatty($stream);
256
        } catch (\Throwable) {
257
            return false;
258
        }
259
    }
260
}
261