Skip to content

Commit e48fe2f

Browse files
authored
Improved CLI Error Output (#196)
2 parents 738891a + 070a831 commit e48fe2f

File tree

2 files changed

+293
-19
lines changed

2 files changed

+293
-19
lines changed

src/Phaseolies/Error/ErrorHandler.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ protected static function logException(Throwable $exception): void
5454

5555
protected static function handleFallback(Throwable $exception): void
5656
{
57-
abort(500, "An error occurred. Please try again later.");
57+
abort($exception->getCode() ?: 500, "An error occurred. Please try again later.");
5858

5959
exit(1);
6060
}

src/Phaseolies/Error/Handlers/CliErrorHandler.php

Lines changed: 292 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,44 +3,318 @@
33
namespace Phaseolies\Error\Handlers;
44

55
use Throwable;
6-
use Symfony\Component\Console\Output\ConsoleOutput;
76
use Phaseolies\Error\Contracts\ErrorHandlerInterface;
87

98
class CliErrorHandler implements ErrorHandlerInterface
109
{
1110
/**
12-
* Outputs formatted error information to the console.
11+
* Maximum number of stack frames to display.
1312
*
13+
* @var int
14+
*/
15+
protected int $maxFrames = 10;
16+
17+
/**
18+
* Number of lines of source context to show around the error line.
19+
*
20+
* @var int
21+
*/
22+
protected int $sourceContextLines = 5;
23+
24+
/**
25+
* Handle the exception by rendering it to the console.
26+
*
1427
* @param Throwable $exception
1528
* @return void
1629
*/
1730
public function handle(Throwable $exception): void
1831
{
19-
$output = new ConsoleOutput();
20-
$section = $output->section();
21-
22-
$section->writeln([
23-
'',
24-
'<fg=red;options=bold><bg=red;fg=white;> ERROR </></>',
25-
''
26-
]);
27-
28-
$section->writeln([
29-
sprintf('<fg=red;>⛔ ERROR:</> <fg=red>%s</>', $exception->getMessage()),
30-
sprintf('<fg=red>📄 FILE:</> <fg=white>%s</>', $exception->getFile()),
31-
sprintf('<fg=red>📌 LINE:</> <fg=white>%d</>', $exception->getLine()),
32-
]);
32+
$this->renderException($exception);
3333

3434
exit(1);
3535
}
3636

3737
/**
38-
* Checks if this handler should be used (CLI mode).
38+
* Check if this handler supports the current environment (CLI).
3939
*
4040
* @return bool
4141
*/
4242
public function supports(): bool
4343
{
4444
return PHP_SAPI === 'cli' || defined('STDIN');
4545
}
46-
}
46+
47+
/**
48+
* Render the full exception output, including chained causes.
49+
*
50+
* @param Throwable $exception
51+
* @param bool $isCause
52+
* @return void
53+
*/
54+
protected function renderException(Throwable $exception, bool $isCause = false): void
55+
{
56+
// Recurse to show the root cause first
57+
if ($exception->getPrevious()) {
58+
$this->renderException($exception->getPrevious(), true);
59+
$this->writeln();
60+
}
61+
62+
$label = $isCause ? 'Caused by' : 'Error';
63+
$class = get_class($exception);
64+
$message = $exception->getMessage();
65+
$file = $this->relativePath($exception->getFile());
66+
$line = $exception->getLine();
67+
68+
// Header
69+
$this->writeln();
70+
$this->writeln($this->bg(' ' . strtoupper($label) . ' ', 'white', 'red') . ' ' . $this->fg($class, 'red', bold: true));
71+
$this->writeln();
72+
73+
// Message
74+
foreach (explode("\n", wordwrap($message, 72, "\n")) as $msgLine) {
75+
$this->writeln(' ' . $this->fg($msgLine, 'white'));
76+
}
77+
$this->writeln();
78+
79+
// Location
80+
$this->writeln(
81+
' ' . $this->fg('at ', 'gray') .
82+
$this->fg($file, 'green') .
83+
$this->fg(':', 'gray') .
84+
$this->fg((string) $line, 'yellow')
85+
);
86+
$this->writeln();
87+
88+
// Source preview
89+
$this->renderSourceContext($exception->getFile(), $line);
90+
91+
// Stack trace
92+
$this->renderStackTrace($exception);
93+
}
94+
95+
/**
96+
* Render a snippet of the source file around the error line.
97+
*
98+
* @param string $file
99+
* @param int $errorLine
100+
* @return void
101+
*/
102+
protected function renderSourceContext(string $file, int $errorLine): void
103+
{
104+
if (!is_readable($file)) {
105+
return;
106+
}
107+
108+
$lines = file($file, FILE_IGNORE_NEW_LINES);
109+
$total = count($lines);
110+
$start = max(0, $errorLine - $this->sourceContextLines - 1);
111+
$end = min($total - 1, $errorLine + $this->sourceContextLines - 1);
112+
$gutterWidth = strlen((string) ($end + 1));
113+
114+
$this->separator();
115+
116+
for ($i = $start; $i <= $end; $i++) {
117+
$lineNum = $i + 1;
118+
$isError = $lineNum === $errorLine;
119+
$gutter = str_pad((string) $lineNum, $gutterWidth, ' ', STR_PAD_LEFT);
120+
$code = $this->expandTabs($lines[$i] ?? '');
121+
122+
if ($isError) {
123+
$this->write($this->fg('', 'red', bold: true));
124+
$this->write($this->fg($gutter, 'red'));
125+
$this->write($this->fg('', 'red'));
126+
$this->writeln($this->fg($code, 'white'));
127+
} else {
128+
$this->write($this->fg(' ', 'gray'));
129+
$this->write($this->fg($gutter, 'gray'));
130+
$this->write($this->fg('', 'gray'));
131+
$this->writeln($this->fg($code, 'gray'));
132+
}
133+
}
134+
135+
$this->separator();
136+
$this->writeln();
137+
}
138+
139+
/**
140+
* Render the formatted stack trace.
141+
*
142+
* @param Throwable $exception
143+
* @return void
144+
*/
145+
protected function renderStackTrace(Throwable $exception): void
146+
{
147+
$frames = $exception->getTrace();
148+
$count = min(count($frames), $this->maxFrames);
149+
$hidden = max(0, count($frames) - $this->maxFrames);
150+
151+
$this->writeln(' ' . $this->fg('Stack trace:', 'yellow', bold: true));
152+
$this->writeln();
153+
154+
for ($i = 0; $i < $count; $i++) {
155+
$frame = $frames[$i];
156+
$number = str_pad((string) ($i + 1), 3, ' ', STR_PAD_LEFT);
157+
158+
$location = isset($frame['file'])
159+
? $this->relativePath($frame['file']) . ':' . ($frame['line'] ?? '?')
160+
: '[internal]';
161+
162+
$call = $this->formatCall($frame);
163+
164+
$this->writeln(
165+
$this->fg($number, 'gray') . ' ' .
166+
$this->fg($location, 'green') . "\n" .
167+
' ' . $this->fg($call, 'white')
168+
);
169+
}
170+
171+
if ($hidden > 0) {
172+
$this->writeln(' ' . $this->fg("{$hidden} more frame(s) hidden", 'gray'));
173+
}
174+
175+
$this->writeln();
176+
}
177+
178+
/**
179+
* Format a single stack frame's call signature.
180+
*
181+
* @param array $frame
182+
* @return void
183+
*/
184+
protected function formatCall(array $frame): string
185+
{
186+
$call = '';
187+
188+
if (isset($frame['class'])) {
189+
$call .= $frame['class'] . ($frame['type'] ?? '::');
190+
}
191+
192+
$call .= ($frame['function'] ?? '{closure}') . '(';
193+
194+
if (!empty($frame['args'])) {
195+
$args = array_map(fn($arg) => $this->formatArg($arg), $frame['args']);
196+
$call .= implode(', ', $args);
197+
}
198+
199+
$call .= ')';
200+
201+
return $call;
202+
}
203+
204+
/**
205+
* Format a single argument value for display.
206+
*
207+
* @param mixed $arg
208+
* @return string
209+
*/
210+
protected function formatArg(mixed $arg): string
211+
{
212+
return match (true) {
213+
is_null($arg) => 'null',
214+
is_bool($arg) => $arg ? 'true' : 'false',
215+
is_int($arg) => (string) $arg,
216+
is_float($arg) => (string) $arg,
217+
is_string($arg) => '"' . (strlen($arg) > 30 ? substr($arg, 0, 27) . '...' : $arg) . '"',
218+
is_array($arg) => 'array(' . count($arg) . ')',
219+
is_object($arg) => get_class($arg),
220+
default => gettype($arg),
221+
};
222+
}
223+
224+
/**
225+
* Write raw text to STDERR without a newline.
226+
*
227+
* @param string $text
228+
* @return void
229+
*/
230+
protected function write(string $text): void
231+
{
232+
fwrite(STDERR, $text);
233+
}
234+
235+
/**
236+
* Write text to STDERR followed by a newline.
237+
*
238+
* @param string $text
239+
* @return void
240+
*/
241+
protected function writeln(string $text = ''): void
242+
{
243+
fwrite(STDERR, $text . PHP_EOL);
244+
}
245+
246+
/**
247+
* Output a visual separator line in gray color.
248+
*
249+
* @return void
250+
*/
251+
protected function separator(): void
252+
{
253+
$this->writeln($this->fg(' ' . str_repeat('', 70), 'gray'));
254+
}
255+
256+
/**
257+
* Apply ANSI foreground color formatting to text.
258+
*
259+
* @param string $text
260+
* @param string $color
261+
* @param bool $bold
262+
* @return string
263+
*/
264+
protected function fg(string $text, string $color, bool $bold = false): string
265+
{
266+
$codes = [
267+
'black' => '30', 'red' => '31', 'green' => '32',
268+
'yellow' => '33', 'blue' => '34', 'magenta' => '35',
269+
'cyan' => '36', 'white' => '37', 'gray' => '90',
270+
];
271+
272+
$code = $codes[$color] ?? '37';
273+
$prefix = $bold ? "\033[1;{$code}m" : "\033[{$code}m";
274+
275+
return $prefix . $text . "\033[0m";
276+
}
277+
278+
/**
279+
* Apply ANSI foreground and background color formatting to text.
280+
*
281+
* @param string $text
282+
* @param string $fg
283+
* @param string $bg
284+
* @return string
285+
*/
286+
protected function bg(string $text, string $fg, string $bg): string
287+
{
288+
$fgCodes = ['white' => '37', 'black' => '30'];
289+
$bgCodes = ['red' => '41', 'yellow' => '43', 'blue' => '44'];
290+
291+
return "\033[1;" . ($fgCodes[$fg] ?? '37') . ';' . ($bgCodes[$bg] ?? '41') . "m" . $text . "\033[0m";
292+
}
293+
294+
/**
295+
* Convert an absolute path to a relative path based on current working directory.
296+
*
297+
* @param string $path
298+
* @return string
299+
*/
300+
protected function relativePath(string $path): string
301+
{
302+
$cwd = getcwd();
303+
if ($cwd && str_starts_with($path, $cwd)) {
304+
return '.' . substr($path, strlen($cwd));
305+
}
306+
return $path;
307+
}
308+
309+
/**
310+
* Replace tab characters with spaces.
311+
*
312+
* @param string $line
313+
* @param int
314+
* @return string
315+
*/
316+
protected function expandTabs(string $line, int $tabSize = 4): string
317+
{
318+
return str_replace("\t", str_repeat(' ', $tabSize), $line);
319+
}
320+
}

0 commit comments

Comments
 (0)