Skip to content

Commit

Permalink
Merge pull request #668 from tighten/jbk/exception-handling
Browse files Browse the repository at this point in the history
Improve error handling
  • Loading branch information
bakerkretzmar authored Nov 21, 2022
2 parents d20f6f1 + 7aebadc commit 30a77b2
Show file tree
Hide file tree
Showing 9 changed files with 327 additions and 6 deletions.
10 changes: 7 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@
"require": {
"php": "^8.0.2 <8.3",
"illuminate/collections": "^9.0",
"illuminate/console": "^9.21.1",
"illuminate/container": "^9.0",
"illuminate/filesystem": "^9.0",
"illuminate/support": "^9.0",
"illuminate/view": "^9.8",
"michelf/php-markdown": "^1.9",
"mnapoli/front-yaml": "^1.5",
"nunomaduro/collision": "^6.0",
"spatie/laravel-ignition": "^1.6",
"symfony/console": "^5.4 || ^6.0",
"symfony/error-handler": "^5.0 || ^6.0",
"symfony/finder": "^5.3.7 || ^6.0",
"symfony/process": "^5.0 || ^6.0",
"symfony/var-dumper": "^5.0 || ^6.0",
Expand All @@ -31,7 +35,7 @@
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.11",
"mikey179/vfsstream": "^1.6.10",
"mikey179/vfsstream": "^1.6.11",
"mockery/mockery": "^1.4",
"phpunit/phpunit": "^9.3.3"
},
Expand All @@ -55,8 +59,8 @@
"format" : "php-cs-fixer fix --verbose"
},
"config": {
"optimize-autoloader": true,
"sort-packages": true
"sort-packages": true,
"optimize-autoloader": true
},
"minimum-stability": "dev",
"prefer-stable": true
Expand Down
10 changes: 9 additions & 1 deletion jigsaw
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,20 @@ if (file_exists(getcwd() . '/vendor/autoload.php')) {

$app = new TightenCo\Jigsaw\Container;

$app->bootstrapWith([]);
$app->singleton(
Illuminate\Contracts\Debug\ExceptionHandler::class,
TightenCo\Jigsaw\Exceptions\Handler::class,
);

$app->bootstrapWith([
TightenCo\Jigsaw\Bootstrap\HandleExceptions::class,
]);

$application = new Symfony\Component\Console\Application('Jigsaw', '1.5.0');
$application->add($app[TightenCo\Jigsaw\Console\InitCommand::class]);
$application->add(new TightenCo\Jigsaw\Console\BuildCommand($app));
$application->add(new TightenCo\Jigsaw\Console\ServeCommand($app));
$application->setCatchExceptions(false);

TightenCo\Jigsaw\Jigsaw::addUserCommands($application, $app);

Expand Down
130 changes: 130 additions & 0 deletions src/Bootstrap/HandleExceptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

namespace TightenCo\Jigsaw\Bootstrap;

use ErrorException;
use Exception;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\ErrorHandler\Error\FatalError;
use Throwable;
use TightenCo\Jigsaw\Container;
use TightenCo\Jigsaw\Exceptions\DeprecationException;

class HandleExceptions
{
public static $reservedMemory;

protected static ?Container $app;

public static function forgetApp(): void
{
static::$app = null;
}

public function bootstrap(Container $app): void
{
self::$reservedMemory = str_repeat('x', 32768);

static::$app = $app;

error_reporting(-1);

set_error_handler($this->forwardTo('handleError'));
set_exception_handler($this->forwardTo('handleException'));
register_shutdown_function($this->forwardTo('handleShutdown'));

/* @internal The '__testing' binding is for Jigsaw development only and may be removed. */
if (! $app->has('__testing') || ! $app['__testing']) {
ini_set('display_errors', 'Off');
}
}

/**
* Report PHP deprecations, or convert PHP errors to ErrorException instances.
*
* @param int $level
* @param string $message
* @param string $file
* @param int $line
* @param array $context
*
* @throws ErrorException
*/
private function handleError($level, $message, $file = '', $line = 0, $context = []): void
{
if (in_array($level, [E_DEPRECATED, E_USER_DEPRECATED])) {
$this->handleDeprecation(new DeprecationException($message, 0, $level, $file, $line));

return;
}

if (error_reporting() & $level) {
throw new ErrorException($message, 0, $level, $file, $line);
}
}

/**
* Handle a deprecation.
*
* @throws \TightenCo\Jigsaw\Exceptions\DeprecationException
*/
private function handleDeprecation(Throwable $e): void
{
/* @internal The '__testing' binding is for Jigsaw development only and may be removed. */
if (static::$app->has('__testing') && static::$app['__testing']) {
throw $e;
}

try {
static::$app->make(ExceptionHandler::class)->report($e);
} catch (Exception $e) {
//
}

static::$app->make(ExceptionHandler::class)->renderForConsole(new ConsoleOutput, $e);
}

/**
* Handle an uncaught exception from the application.
*
* Note: Most exceptions can be handled in a try / catch block higher
* in the app, but fatal error exceptions must be handled
* differently since they are not normal exceptions.
*/
private function handleException(Throwable $e): void
{
self::$reservedMemory = null;

try {
static::$app->make(ExceptionHandler::class)->report($e);
} catch (Exception $e) {
//
}

static::$app->make(ExceptionHandler::class)->renderForConsole(new ConsoleOutput, $e);
}

/**
* Handle the PHP shutdown event.
*/
private function handleShutdown(): void
{
self::$reservedMemory = null;

if (
! is_null($error = error_get_last())
&& in_array($error['type'], [E_COMPILE_ERROR, E_CORE_ERROR, E_ERROR, E_PARSE])
) {
$this->handleException(new FatalError($error['message'], 0, $error, 0));
}
}

/**
* Forward a method call to the given method on this class if an application instance exists.
*/
private function forwardTo(string $method): callable
{
return fn (...$arguments) => static::$app ? $this->{$method}(...$arguments) : false;
}
}
11 changes: 10 additions & 1 deletion src/Console/BuildCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
namespace TightenCo\Jigsaw\Console;

use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Support\Arr;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use TightenCo\Jigsaw\File\ConfigFile;
use TightenCo\Jigsaw\File\TemporaryFilesystem;
use TightenCo\Jigsaw\Jigsaw;
Expand Down Expand Up @@ -57,7 +59,14 @@ protected function fire()
$this->consoleOutput->writeIntro($env, $this->useCache(), $cacheExists);

if ($this->confirmDestination()) {
$this->app->make(Jigsaw::class)->build($env, $this->useCache());
try {
$this->app->make(Jigsaw::class)->build($env, $this->useCache());
} catch (Throwable $e) {
$this->app->make(ExceptionHandler::class)->report($e);
$this->app->make(ExceptionHandler::class)->renderForConsole($this->consoleOutput, $e);

return static::FAILURE;
}

$this->consoleOutput
->writeTime(round(microtime(true) - $startTime, 2), $this->useCache(), $cacheExists)
Expand Down
1 change: 1 addition & 0 deletions src/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ private function registerCoreProviders(): void
private function registerConfiguredProviders(): void
{
foreach ([
Providers\ExceptionServiceProvider::class,
Providers\FilesystemServiceProvider::class,
Providers\MarkdownServiceProvider::class,
Providers\ViewServiceProvider::class,
Expand Down
10 changes: 10 additions & 0 deletions src/Exceptions/DeprecationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace TightenCo\Jigsaw\Exceptions;

use ErrorException;

class DeprecationException extends ErrorException
{
//
}
124 changes: 124 additions & 0 deletions src/Exceptions/Handler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php

namespace TightenCo\Jigsaw\Exceptions;

use Closure;
use Illuminate\Console\View\Components\BulletList;
use Illuminate\Console\View\Components\Error;
use Illuminate\Console\View\Components\Warn;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Support\Traits\ReflectsClosures;
use Illuminate\View\ViewException;
use InvalidArgumentException;
use NunoMaduro\Collision\Adapters\Laravel\Inspector;
use NunoMaduro\Collision\Provider;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Exception\CommandNotFoundException;
use Symfony\Component\Console\Exception\ExceptionInterface as SymfonyConsoleExceptionInterface;
use Throwable;

class Handler implements ExceptionHandler
{
use ReflectsClosures;

/** @var array<string, Closure> */
private array $exceptionMap = [];

public function report(Throwable $e): void
{
//
}

public function shouldReport(Throwable $e): bool
{
return true;
}

public function render($request, Throwable $e): void
{
//
}

/**
* @param \Symfony\Component\Console\Output\OutputInterface $output
*/
public function renderForConsole($output, Throwable $e): void
{
if ($e instanceof CommandNotFoundException) {
$message = str($e->getMessage())->explode('.')->first();

if (! empty($alternatives = $e->getAlternatives())) {
(new Error($output))->render("{$message}. Did you mean one of these?");
(new BulletList($output))->render($alternatives);
} else {
(new Error($output))->render($message);
}

return;
}

if ($e instanceof SymfonyConsoleExceptionInterface) {
(new ConsoleApplication)->renderThrowable($e, $output);

return;
}

if ($e instanceof DeprecationException) {
// If the deprecation appears to have come from a compiled Blade view, wrap it in
// a ViewException and map it manually so Ignition will add the uncompiled path
if (preg_match('/cache\/\w+\.php$/', $e->getFile()) === 1) {
$e = $this->mapException(
new ViewException("{$e->getMessage()} (View: )", 0, 1, $e->getFile(), $e->getLine(), $e),
);
}

(new Warn($output))->render("{$e->getMessage()} in {$e->getFile()} on line {$e->getLine()}");

return;
}

$e = $this->mapException($e);

/** @var \NunoMaduro\Collision\Provider $provider */
$provider = app(Provider::class);

$handler = $provider->register()->getHandler()->setOutput($output);
$handler->setInspector(new Inspector($e));

$handler->handle();
}

public function map(Closure|string $from, Closure|string|null $to = null): static
{
if (is_string($to)) {
$to = fn ($exception) => new $to('', 0, $exception);
}

if (is_callable($from) && is_null($to)) {
$from = $this->firstClosureParameterType($to = $from);
}

if (! is_string($from) || ! $to instanceof Closure) {
throw new InvalidArgumentException('Invalid exception mapping.');
}

$this->exceptionMap[$from] = $to;

return $this;
}

protected function mapException(Throwable $e): Throwable
{
if (method_exists($e, 'getInnerException') && ($inner = $e->getInnerException()) instanceof Throwable) {
return $inner;
}

foreach ($this->exceptionMap as $class => $mapper) {
if ($e instanceof $class) {
return $mapper($e);
}
}

return $e;
}
}
18 changes: 18 additions & 0 deletions src/Providers/ExceptionServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace TightenCo\Jigsaw\Providers;

use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\View\ViewException;
use Spatie\LaravelIgnition\Views\ViewExceptionMapper;
use TightenCo\Jigsaw\Support\ServiceProvider;

class ExceptionServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->make(ExceptionHandler::class)->map(
fn (ViewException $e) => $this->app->make(ViewExceptionMapper::class)->map($e),
);
}
}
Loading

0 comments on commit 30a77b2

Please sign in to comment.