33namespace Phaseolies \Error \Handlers ;
44
55use Throwable ;
6- use Symfony \Component \Console \Output \ConsoleOutput ;
76use Phaseolies \Error \Contracts \ErrorHandlerInterface ;
87
98class 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