Test Failed
Pull Request — master (#853)
by butschster
06:58
created

ConsoleRenderer   A

Complexity

Total Complexity 34

Size/Duplication

Total Lines 227
Duplicated Lines 0 %

Test Coverage

Coverage 93.02%

Importance

Changes 4
Bugs 0 Features 0
Metric Value
wmc 34
eloc 116
c 4
b 0
f 0
dl 0
loc 227
ccs 80
cts 86
cp 0.9302
rs 9.68

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 47 8
A format() 0 18 4
B isColorsSupported() 0 17 8
B renderTrace() 0 56 9
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
25
    private array $lines = [];
26
27
    protected const COLORS = [
28
        'bg:red'     => Color::BG_RED,
29
        'bg:cyan'    => Color::BG_CYAN,
30
        'bg:magenta' => Color::BG_MAGENTA,
31
        'bg:white'   => Color::BG_WHITE,
32
        'white'      => Color::LIGHT_WHITE,
33
        'green'      => Color::GREEN,
34
        'black'      => Color::BLACK,
35
        'red'        => Color::RED,
36
        'yellow'     => Color::YELLOW,
37
        'reset'      => Color::RESET,
38
    ];
39
40
    private bool $colorsSupport;
41
42
    /**
43 7
     * @param bool|resource $stream
44
     */
45 7
    public function __construct(mixed $stream = null)
46 7
    {
47
        $stream ??= \defined('\STDOUT') ? \STDOUT : \fopen('php://stdout', 'wb');
48
49
        $this->colorsSupport = $this->isColorsSupported($stream);
50
    }
51
52 6
    /**
53
     * Disable or enable colorization support.
54 6
     */
55
    public function setColorsSupport(bool $enabled = true): void
56
    {
57 7
        $this->colorsSupport = $enabled;
58
    }
59
60
    public function render(
61
        \Throwable $exception,
62 7
        ?Verbosity $verbosity = null,
63
        string $format = null
64 7
    ): string {
65 7
        $verbosity ??= $this->defaultVerbosity;
66 7
67 7
        $exceptions = [$exception];
68
        while ($exception = $exception->getPrevious()) {
69 7
            $exceptions[] = $exception;
70 7
        }
71 7
72 7
        $exceptions = \array_reverse($exceptions);
73 7
74
        $result = [];
75 7
        $rootDir = \getcwd();
76 2
77 2
        foreach ($exceptions as $exception) {
78 2
            $row = $this->renderHeader(
79 5
                \sprintf("[%s]\n%s", $exception::class, $exception->getMessage()),
80 1
                $exception instanceof \Error ? 'bg:magenta,white' : 'bg:red,white'
81
            );
82
83 7
            $file = \str_starts_with($exception->getFile(), $rootDir)
84
                ? \substr($exception->getFile(), \strlen($rootDir) + 1)
85
                : $exception->getFile();
86
87
            $row .= $this->format(
88
                "<yellow>in</reset> <green>%s</reset><yellow>:</reset><white>%s</reset>\n",
89
                $file,
90
                $exception->getLine()
91
            );
92 7
93
            if ($verbosity->value >= Verbosity::DEBUG->value) {
94 7
                $row .= $this->renderTrace($exception, new Highlighter(
95
                    $this->colorsSupport ? new ConsoleStyle() : new PlainStyle()
96 7
                ));
97
            } elseif ($verbosity->value >= Verbosity::VERBOSE->value) {
98 7
                $row .= $this->renderTrace($exception);
99 7
            }
100 7
101 7
            $result[] = $row;
102
        }
103 7
104
        $this->lines = [];
105 7
106 7
        return \implode("\n", \array_reverse($result));
107 7
    }
108 7
109 7
    /**
110 7
     * Render title using outlining border.
111 7
     *
112
     * @param string $title Title.
113
     * @param string $style Formatting.
114 7
     */
115
    private function renderHeader(string $title, string $style, int $padding = 0): string
116
    {
117
        $result = '';
118
119
        $lines = \explode("\n", \str_replace("\r", '', $title));
120 3
121
        $length = 0;
122 3
        \array_walk($lines, static function ($v) use (&$length): void {
123 3
            $length = max($length, \mb_strlen($v));
124
        });
125
126
        $length += $padding;
127 3
128
        foreach ($lines as $line) {
129 3
            $result .= $this->format(
130 3
                "<{$style}>%s%s%s</reset>\n",
131 3
                \str_repeat('', $padding + 1),
132 3
                $line,
133 3
                \str_repeat('', $length - \mb_strlen($line) + 1)
134 3
            );
135 3
        }
136 3
137
        return $result;
138 3
    }
139 3
140 3
    /**
141 3
     * Render exception call stack.
142
     */
143
    private function renderTrace(\Throwable $e, Highlighter $h = null): string
144 3
    {
145 3
        $stacktrace = $this->getStacktrace($e);
146 3
        if (empty($stacktrace)) {
147 3
            return '';
148 3
        }
149 3
150
        $result = "\n";
151
        $rootDir = \getcwd();
152
153
        $pad = \strlen((string)\count($stacktrace));
154
155
        foreach ($stacktrace as $i => $trace) {
156
            if (isset($trace['type'], $trace['class'])) {
157
                $line = $this->format(
158 3
                    ' <white>%s. %s%s%s()</reset>',
159
                    \str_pad((string)((int) $i + 1), $pad, ' ', \STR_PAD_LEFT),
160 3
                    $trace['class'],
161 2
                    $trace['type'],
162 2
                    $trace['function']
163 2
                );
164 2
            } else {
165 2
                $line = $this->format(
166
                    ' <white>%s()</reset>',
167
                    $trace['function']
168
                );
169 3
            }
170
171
            if (isset($trace['file'])) {
172
                $file = \str_starts_with($trace['file'], $rootDir)
173
                    ? \substr($trace['file'], \strlen($rootDir) + 1)
174
                    : $trace['file'];
175 7
176
                $line .= $this->format(
177 7
                    ' <yellow>at</reset> <green>%s</reset><yellow>:</reset><white>%s</reset>',
178 2
                    $file,
179
                    $trace['line']
180 5
                );
181 5
            }
182 5
183 5
            if (\in_array($line, $this->lines, true)) {
184 5
                continue;
185
            }
186
187
            $result .= $line . "\n";
188 5
189 5
            if ($h !== null && !empty($trace['file'])) {
190
                $result .= $h->highlightLines(
191
                        \file_get_contents($trace['file']),
192 7
                        $trace['line'],
193
                        static::SHOW_LINES
194
                    ) . "\n";
195
            }
196
        }
197
198
        return $result;
199
    }
200
201
    /**
202
     * Format string and apply color formatting (if enabled).
203
     */
204
    private function format(string $format, mixed ...$args): string
205
    {
206
        if (!$this->colorsSupport) {
207
            $format = \preg_replace('/<[^>]+>/', '', $format);
208
        } else {
209
            $format = \preg_replace_callback('/(<([^>]+)>)/', static function ($partial) {
210
                $style = '';
211
                foreach (\explode(',', \trim($partial[2], '/')) as $color) {
212
                    if (isset(self::COLORS[$color])) {
213
                        $style .= self::COLORS[$color];
214
                    }
215
                }
216
217
                return $style;
218
            }, $format);
219
        }
220
221
        return \sprintf($format, ...$args);
222
    }
223
224
    /**
225
     * Returns true if the STDOUT supports colorization.
226
     * @codeCoverageIgnore
227
     * @link https://github.com/symfony/Console/blob/master/Output/StreamOutput.php#L94
228
     */
229
    private function isColorsSupported(mixed $stream = STDOUT): bool
230
    {
231
        if ('Hyper' === \getenv('TERM_PROGRAM')) {
232
            return true;
233
        }
234
235
        try {
236
            if (\DIRECTORY_SEPARATOR === '\\') {
237
                return (\function_exists('sapi_windows_vt100_support') && @\sapi_windows_vt100_support($stream))
238
                    || \getenv('ANSICON') !== false
239
                    || \getenv('ConEmuANSI') === 'ON'
240
                    || \getenv('TERM') === 'xterm';
241
            }
242
243
            return @\stream_isatty($stream);
244
        } catch (\Throwable) {
245
            return false;
246
        }
247
    }
248
}
249