From 4f55a7d84bf11db0f47c00c4f91cdbcd9c6fe37e Mon Sep 17 00:00:00 2001 From: Hafiz Muhammad Moaz Date: Mon, 7 Sep 2026 15:39:29 +0500 Subject: [PATCH 1/3] feat: exception handling with debug mode and custom handler (fixes #51) --- src/Console.php | 97 ++++++++++++++++++++++++++++++++++- tests/ConsoleTest.php | 43 ++++++++++++++++ tests/ThrowingCommandMock.php | 23 +++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 tests/ThrowingCommandMock.php diff --git a/src/Console.php b/src/Console.php index c480630..2ef8875 100644 --- a/src/Console.php +++ b/src/Console.php @@ -65,6 +65,17 @@ class Console * command was not found or failed validation. */ protected int $exitCode = 0; + /** + * When true, escaped exceptions are rendered with the class name and + * a stack trace instead of just the message. + */ + protected bool $debug = false; + /** + * Optional application supplied handler for uncaught command exceptions. + * + * @var callable(\Throwable):void|null + */ + protected $exceptionHandler; /** * Console constructor. @@ -355,6 +366,50 @@ public function getExitCode() : int return $this->exitCode; } + /** + * Enable or disable debug rendering of uncaught command exceptions. + * + * When enabled, escaped exceptions are rendered with their class name and + * a full stack trace instead of just the message. + * + * @param bool $debug True to enable debug rendering + * + * @return static + */ + public function setDebug(bool $debug) : static + { + $this->debug = $debug; + return $this; + } + + /** + * Tell whether debug rendering of uncaught exceptions is enabled. + * + * @return bool + */ + #[Pure] + public function isDebug() : bool + { + return $this->debug; + } + + /** + * Register an application handler for uncaught command exceptions. + * + * The handler receives the Throwable and is responsible for any logging + * or rendering the application needs. When set, it runs before the + * library's own error output, which is then skipped. + * + * @param callable(\Throwable):void|null $handler The handler or null to clear it + * + * @return static + */ + public function setExceptionHandler(?callable $handler) : static + { + $this->exceptionHandler = $handler; + return $this; + } + /** * Dispatch the current command and return its exit code. * @@ -383,11 +438,51 @@ protected function dispatch() : int return $this->exitCode; } $command->applyDefaults($this); - $command->run(); + try { + $command->run(); + } catch (\Throwable $exception) { + return $this->handleException($exception); + } $this->exitCode = $command->getExitCode(); return $this->exitCode; } + /** + * Handle an exception that escaped the dispatched command. + * + * If an application exception handler is registered it is invoked and the + * library renders nothing. Otherwise the message is printed in red on + * STDERR, with the class name and stack trace added in debug mode, and a + * non zero exit code is reported. + * + * @param \Throwable $exception The uncaught exception + * + * @return int The process exit code + */ + protected function handleException(\Throwable $exception) : int + { + if ($this->exceptionHandler !== null) { + ($this->exceptionHandler)($exception); + $this->exitCode = 1; + return $this->exitCode; + } + $exitCode = (int) $exception->getCode(); + if ($exitCode < 1 || $exitCode > 254) { + $exitCode = 1; + } + $message = $exception->getMessage(); + if ($this->debug) { + $message = \get_class($exception) . ': ' . $message + . \PHP_EOL . $exception->getTraceAsString(); + } + CLI::error( + CLI::style($message, ForegroundColor::brightRed), + \defined('TESTING') ? null : $exitCode + ); + $this->exitCode = $exitCode; + return $this->exitCode; + } + /** * Report argument or option validation errors for the requested command. * diff --git a/tests/ConsoleTest.php b/tests/ConsoleTest.php index a3b3041..ba29e6e 100644 --- a/tests/ConsoleTest.php +++ b/tests/ConsoleTest.php @@ -597,4 +597,47 @@ public function testHelpForUnknownCommandYieldsExitCodeOne() : void self::assertSame(1, $this->console->run()); self::assertSame(1, $this->console->getExitCode()); } + public function testThrownExceptionIsReportedOnStderr() : void + { + Stderr::reset(); + Stderr::init(); + $this->console->addCommand(new ThrowingCommandMock($this->console)); + $this->console->prepare(['file.php', 'thrower']); + self::assertSame(7, $this->console->run()); + self::assertStringContainsString('boom', Stderr::getContents()); + Stderr::reset(); + } + + public function testDebugModeRevealsClassAndTrace() : void + { + Stderr::reset(); + Stderr::init(); + $this->console->addCommand(new ThrowingCommandMock($this->console)); + $this->console->setDebug(true); + $this->console->prepare(['file.php', 'thrower']); + $this->console->run(); + self::assertStringContainsString('RuntimeException', Stderr::getContents()); + self::assertStringContainsString('#0', Stderr::getContents()); + $this->console->setDebug(false); + Stderr::reset(); + } + + public function testExceptionHandlerBypassesDefaultRendering() : void + { + Stderr::reset(); + Stderr::init(); + $caught = null; + $this->console->setExceptionHandler(static function (\Throwable $exception) use (&$caught) : void { + $caught = $exception; + }); + $this->console->addCommand(new ThrowingCommandMock($this->console)); + $this->console->prepare(['file.php', 'thrower']); + self::assertSame(1, $this->console->run()); + self::assertNotNull($caught); + self::assertSame('boom', $caught->getMessage()); + self::assertStringNotContainsString('boom', Stderr::getContents()); + $this->console->setExceptionHandler(null); + Stderr::reset(); + } + } diff --git a/tests/ThrowingCommandMock.php b/tests/ThrowingCommandMock.php new file mode 100644 index 0000000..57e2b53 --- /dev/null +++ b/tests/ThrowingCommandMock.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Tests\CLI; + +use Framework\CLI\Command; + +class ThrowingCommandMock extends Command +{ + protected string $name = 'thrower'; + + public function run() : void + { + throw new \RuntimeException('boom', 7); + } +} From 2e0a6ab7123d0d8589fcb8da7d3140161f362085 Mon Sep 17 00:00:00 2001 From: Hafiz Muhammad Moaz Date: Mon, 7 Sep 2026 15:41:36 +0500 Subject: [PATCH 2/3] feat: add ZeroCodeThrowingCommandMock for invalid exception code test --- tests/ZeroCodeThrowingCommandMock.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/ZeroCodeThrowingCommandMock.php diff --git a/tests/ZeroCodeThrowingCommandMock.php b/tests/ZeroCodeThrowingCommandMock.php new file mode 100644 index 0000000..8748843 --- /dev/null +++ b/tests/ZeroCodeThrowingCommandMock.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Tests\CLI; + +use Framework\CLI\Command; + +class ZeroCodeThrowingCommandMock extends Command +{ + protected string $name = 'zero-thrower'; + + public function run() : void + { + throw new \LogicException('zero code'); + } +} From 6546ee14dc95280b90bea959a8ebb17a2f3e7486 Mon Sep 17 00:00:00 2001 From: Hafiz Muhammad Moaz Date: Mon, 7 Sep 2026 15:43:10 +0500 Subject: [PATCH 3/3] style: apply coding standard to ConsoleTest --- tests/ConsoleTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ConsoleTest.php b/tests/ConsoleTest.php index ba29e6e..271d38f 100644 --- a/tests/ConsoleTest.php +++ b/tests/ConsoleTest.php @@ -597,6 +597,7 @@ public function testHelpForUnknownCommandYieldsExitCodeOne() : void self::assertSame(1, $this->console->run()); self::assertSame(1, $this->console->getExitCode()); } + public function testThrownExceptionIsReportedOnStderr() : void { Stderr::reset(); @@ -639,5 +640,4 @@ public function testExceptionHandlerBypassesDefaultRendering() : void $this->console->setExceptionHandler(null); Stderr::reset(); } - }