Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 96 additions & 1 deletion src/Console.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down
43 changes: 43 additions & 0 deletions tests/ConsoleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
23 changes: 23 additions & 0 deletions tests/ThrowingCommandMock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/*
* This file is part of Webisters CLI Library.
*
* (c) Hafiz Muhammad Moaz <thewebisters@gmail.com>
*
* 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);
}
}
23 changes: 23 additions & 0 deletions tests/ZeroCodeThrowingCommandMock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/*
* This file is part of Webisters CLI Library.
*
* (c) Hafiz Muhammad Moaz <thewebisters@gmail.com>
*
* 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');
}
}
Loading