Browse Source

Initial commit

2.1.0
Dustin Wilson 2 years ago
commit
e6e99a1d5c
  1. 76
      .gitignore
  2. 12
      README.md
  3. 22
      composer.json
  4. 71
      composer.lock
  5. 101
      lib/Catcher.php
  6. 221
      lib/Catcher/PlainTextHandler.php
  7. 202
      lib/Catcher/ThrowableController.php
  8. 66
      lib/Catcher/ThrowableHandler.php
  9. 17
      lib/Error.php

76
.gitignore

@ -0,0 +1,76 @@
# Catcher-specific
/test*.html
/test*.php
/test/*
# General
*.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msm
*.msp
# Windows shortcuts
*.lnk
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
/vendor/
/vendor-bin/*/vendor
/tests/html5lib-tests
/tests/.phpunit.result.cache
/tests/coverage
cachegrind.out.*

12
README.md

@ -0,0 +1,12 @@
# Catcher
Catcher is a Throwable catcher and error handling library for PHP.
## Example
```php
$catcher = new Catcher(new PlainTextHandler([
'outputBacktrace' => true,
'backtraceArgFrameLimit' => 2
]));
```

22
composer.json

@ -0,0 +1,22 @@
{
"name": "mensbeam/catcher",
"description": "Catches exceptions, errors, and shutdowns",
"type": "library",
"license": "MIT",
"autoload": {
"psr-4": {
"Mensbeam\\Framework\\": "lib/",
"Mensbeam\\Framework\\Test\\": "test/"
}
},
"authors": [
{
"name": "Dustin Wilson",
"email": "dustin@dustinwilson.com"
}
],
"require": {
"php": ">=8.1",
"psr/log": "^3.0"
}
}

71
composer.lock

@ -0,0 +1,71 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "8998cce6e05b3ccc6aca7c736416f570",
"packages": [
{
"name": "psr/log",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/log.git",
"reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001",
"reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Log\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for logging libraries",
"homepage": "https://github.com/php-fig/log",
"keywords": [
"log",
"psr",
"psr-3"
],
"support": {
"source": "https://github.com/php-fig/log/tree/3.0.0"
},
"time": "2021-07-14T16:46:02+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=8.1"
},
"platform-dev": [],
"plugin-api-version": "2.3.0"
}

101
lib/Catcher.php

@ -0,0 +1,101 @@
<?php
/**
* @license MIT
* Copyright 2022 Dustin Wilson, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace Mensbeam\Framework;
use Mensbeam\Framework\Catcher\{
ThrowableController,
ThrowableHandler
};
class Catcher {
/**
* Array of handlers the exceptions are passed to
*
* @var ThrowableHandler[]
*/
protected array $handlers = [];
/** Flag set when the shutdown handler is run */
protected bool $isShuttingDown = false;
public function __construct(ThrowableHandler ...$handlers) {
$this->handlers = $handlers;
$prev = set_error_handler([ $this, 'handleError' ]);
$prev = set_exception_handler([ $this, 'handleThrowable' ]);
register_shutdown_function([ $this, 'handleShutdown' ]);
}
/**
* Gets the list of handlers
*
* @return ThrowableHandler[]
*/
public function getHandlers(): array {
return $this->handlers;
}
/**
* Converts regular errors into throwable Errors for easier handling; meant to be
* used with set_error_handler.
*
* @internal
*/
public function handleError(int $code, string $message, ?string $file = null, ?int $line = null): bool {
if ($code !== 0 && error_reporting()) {
$error = new Error($message, $code, $file, $line);
if ($this->isShuttingDown) {
throw $error;
} else {
$this->handleThrowable($error);
}
return true;
}
return false;
}
/**
* Handles both Exceptions and Errors; meant to be used with set_exception_handler.
*
* @internal
*/
public function handleThrowable(\Throwable $throwable): void {
$controller = new ThrowableController($throwable);
foreach ($this->handlers as $h) {
// If the handler returns true it means the handler handled the exception properly,
// and there's no reason to pass it off to another.
if ($h->handle($throwable, $controller)) {
break;
}
}
if ($this->isShuttingDown || $throwable instanceof \Exception || in_array($throwable->getCode(), [ \E_ERROR, \E_PARSE, \E_CORE_ERROR, \E_COMPILE_ERROR, \E_USER_ERROR ])) {
exit($throwable->getCode());
}
}
/**
* Handles shutdowns, passes all possible built-in error codes to the error handler.
*
* @internal
*/
public function handleShutdown() {
$this->isShuttingDown = true;
$error = error_get_last();
if ($error && in_array($error['type'], [ \E_ERROR, \E_PARSE, \E_CORE_ERROR, \E_CORE_WARNING, \E_COMPILE_ERROR, \E_COMPILE_WARNING ])) {
$this->handleError($error['type'], $error['message'], $error['file'], $error['line']);
}
}
}

221
lib/Catcher/PlainTextHandler.php

@ -0,0 +1,221 @@
<?php
/**
* @license MIT
* Copyright 2022 Dustin Wilson, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace Mensbeam\Framework\Catcher;
use \Psr\Log\{
LoggerAwareInterface,
LoggerInterface
};
class PlainTextHandler extends ThrowableHandler implements LoggerAwareInterface {
protected static ?string $contentType = 'text/plain';
/** The number of backtrace frames in which to print arguments; defaults to 5 */
protected int $_backtraceArgFrameLimit = 5;
/** The PSR-3 compatible logger in which to log to; defaults to null (no logging) */
protected ?LoggerInterface $_logger = null;
/** If true the handler will output backtraces; defaults to false */
protected bool $_outputBacktrace = false;
/** If true the handler will output previous throwables; defaults to true */
protected bool $_outputPrevious = true;
/**
* If true the handler will output times to the output. This is ignored by the
* logger which should have its own timestamping methods; defaults to true
*/
protected bool $_outputTime = true;
/** The PHP-standard date format which to use for times printed to output */
protected string $_timeFormat = '[H:i:s]';
public function __construct(array $config = []) {
parent::__construct($config);
}
public function getBacktraceArgFrameLimit(): int {
return $this->_getBacktraceArgFrameLimit;
}
public function getLogger(): ?LoggerInterface {
return $this->_logger;
}
public function getOutputBacktrace(): bool {
return $this->_outputBacktrace;
}
public function getOutputPrevious(): bool {
return $this->_outputPrevious;
}
public function getOutputTime(): bool {
return $this->_outputTime;
}
public function getTimeFormat(): bool {
return $this->_timeFormat;
}
public function handle(\Throwable $throwable, ThrowableController $controller): bool {
// If this can't output and there's no logger to log to then there's nothing to do
// here. Continue on to the next handler.
if (!$this->_output && $this->_logger === null) {
return false;
}
$message = $this->serializeThrowable($throwable, $controller);
if ($this->_outputPrevious) {
$prev = $throwable->getPrevious();
$prevController = $controller->getPrevious();
while ($prev) {
$message .= sprintf("\n\nCaused by ↴\n%s", $this->serializeThrowable($prev, $prevController));
$prev = $prev->getPrevious();
$prevController = $prevController->getPrevious();
}
}
if ($this->_outputBacktrace) {
$frames = $controller->getFrames();
$message .= "\nStack trace:";
$num = 1;
foreach ($frames as $frame) {
$class = (!empty($frame['error'])) ? "{$frame['error']} ({$frame['class']})" : $frame['class'] ?? '';
$function = $frame['function'] ?? '';
$args = '';
if (!empty($frame['args']) && $this->_backtraceArgFrameLimit >= $num) {
$args = "\n" . preg_replace('/^/m', str_repeat(' ', strlen((string)$num) + 2) . '| ', var_export($frame['args'], true));
}
$template = "\n%3d. %s";
if ($class && $function) {
$template .= '::';
}
$template .= ($function) ? '%s()' : '%s';
$template .= ' %s:%d%s';
$message .= sprintf(
"$template\n",
$num++,
$class,
$function,
$frame['file'],
$frame['line'],
$args
);
}
}
if ($this->_logger !== null) {
$this->log($throwable, $message);
}
if ($this->_output) {
// Logger handles its own timestamps
if ($this->_outputTime && $this->_timeFormat !== '') {
$time = (new \DateTime())->format($this->_timeFormat) . ' ';
$timeStrlen = strlen($time);
$message = preg_replace('/^/m', str_repeat(' ', $timeStrlen), $message);
$message = preg_replace('/^ {' . $timeStrlen . '}/', $time, $message);
}
if (\PHP_SAPI === 'cli') {
fprintf(\STDERR, "$message\n");
} else {
$this->sendContentTypeHeader();
http_response_code(500);
echo "$message\n";
}
return (!$this->_passthrough);
}
return false;
}
public function setBacktraceArgFrameLimit(int $value): void {
$this->_getBacktraceArgFrameLimit = $value;
}
public function setLogger(?LoggerInterface $value): void {
$this->_logger = $value;
}
public function setOutputBacktrace(bool $value): void {
$this->_outputBacktrace = $value;
}
public function setOutputPrevious(bool $value): void {
$this->_outputPrevious = $value;
}
public function setOutputTime(bool $value): void {
$this->_outputTime = $value;
}
public function setTimeFormat(bool $value): void {
$this->_timeFormat = $value;
}
protected function log(\Throwable $throwable, string $message): void {
if ($throwable instanceof \Error) {
switch ($throwable->getCode()) {
case \E_NOTICE:
case \E_USER_NOTICE:
case \E_STRICT:
$this->_logger->notice($message);
break;
case \E_WARNING:
case \E_COMPILE_WARNING:
case \E_USER_WARNING:
case \E_DEPRECATED:
case \E_USER_DEPRECATED:
$this->_logger->warning($message);
break;
case \E_RECOVERABLE_ERROR:
$this->_logger->error($message);
break;
case \E_PARSE:
case \E_CORE_ERROR:
case \E_COMPILE_ERROR:
$this->_logger->alert($message);
break;
}
} elseif ($throwable instanceof \Exception) {
if ($throwable instanceof \PharException || $throwable instanceof \RuntimeException) {
$this->_logger->alert($message);
}
}
$this->_logger->critical($message);
}
protected function serializeThrowable(\Throwable $throwable, ThrowableController $controller): string {
$class = $throwable::class;
if ($throwable instanceof \Error) {
$type = $controller->getErrorType();
$class = ($type !== null) ? "$type (" . $throwable::class . ")" : $throwable::class;
}
return sprintf(
'%s: %s in file %s on line %d',
$class,
$throwable->getMessage(),
$throwable->getFile(),
$throwable->getLine()
);
}
}

202
lib/Catcher/ThrowableController.php

@ -0,0 +1,202 @@
<?php
/**
* @license MIT
* Copyright 2022 Dustin Wilson, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace Mensbeam\Framework\Catcher;
class ThrowableController {
private string|bool|null $errorType = false;
private ?array $frames = null;
private ThrowableController|bool|null $previousThrowableController = false;
private \Throwable $throwable;
public function __construct(\Throwable $throwable) {
$this->throwable = $throwable;
}
/** Gets the type name for an Error object */
public function getErrorType(): ?string {
if ($this->errorType !== false) {
return $this->errorType;
}
if (!$this->throwable instanceof \Error) {
$this->errorType = null;
return null;
}
switch ($this->throwable->getCode()) {
case \E_ERROR:
$this->errorType = 'PHP Fatal Error';
break;
case \E_WARNING:
$this->errorType = 'PHP Warning';
break;
case \E_PARSE:
$this->errorType = 'PHP Parsing Error';
break;
case \E_NOTICE:
$this->errorType = 'PHP Notice';
break;
case \E_CORE_ERROR:
$this->errorType = 'PHP Core Error';
break;
case \E_CORE_WARNING:
$this->errorType = 'PHP Core Warning';
break;
case \E_COMPILE_ERROR:
$this->errorType = 'Compile Error';
break;
case \E_COMPILE_WARNING:
$this->errorType = 'Compile Warning';
break;
case \E_STRICT:
$this->errorType = 'Runtime Notice';
break;
case \E_RECOVERABLE_ERROR:
$this->errorType = 'Recoverable Error';
break;
case \E_DEPRECATED:
case \E_USER_DEPRECATED:
$this->errorType = 'Deprecated';
break;
case \E_USER_ERROR:
$this->errorType = 'Fatal Error';
break;
case \E_USER_WARNING:
$this->errorType = 'Warning';
break;
case \E_USER_NOTICE:
$this->errorType = 'Notice';
break;
default:
$this->errorType = null;
}
return $this->errorType;
}
/** Gets backtrace frames */
public function getFrames(): array {
if ($this->frames !== null) {
return $this->frames;
}
if (
!$this->throwable instanceof \Error ||
!in_array($this->throwable->getCode(), [ E_ERROR, E_PARSE, E_CORE_ERROR, E_CORE_WARNING, E_COMPILE_ERROR, E_COMPILE_WARNING ]) ||
!extension_loaded('xdebug') ||
!function_exists('xdebug_info') ||
sizeof(xdebug_info('mode')) === 0
) {
$frames = $this->throwable->getTrace();
} else {
$frames = array_diff_key(array_reverse(xdebug_get_function_stack()), debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS));
}
// PHP for some stupid reason thinks it's okay not to provide line numbers and file
// names when using call_user_func_array; this fixes that.
// (https://bugs.php.net/bug.php?id=44428)
foreach ($frames as $key => $frame) {
if (empty($frame['file'])) {
$file = '[INTERNAL]';
$line = 0;
$next = $frames[$key + 1] ?? [];
if (
!empty($frame['file']) &&
!empty($frame['function']) &&
!empty($frame['line']) &&
str_contains($frame['function'], 'call_user_func')
) {
$file = $next['file'];
$line = $next['line'];
}
$frames[$key]['file'] = $file;
$frames[$key]['line'] = $line;
}
$frames[$key]['line'] = (int)$frames[$key]['line'];
}
// Delete everything that has anything to do with userland error handling
for ($frameCount = count($frames), $i = $frameCount - 1; $i >= 0; $i--) {
$frame = $frames[$i];
if ($frame['file'] === $this->throwable->getFile() && $frame['line'] === $this->throwable->getLine()) {
array_splice($frames, 0, $i);
break;
}
}
// Add a frame for the throwable to the beginning of the array
$f = [
'file' => $this->throwable->getFile(),
'line' => (int)$this->throwable->getLine(),
'class' => $this->throwable::class,
'args' => [
$this->throwable->getMessage()
]
];
// Add the error name if it is an Error.
if ($this->throwable instanceof \Error) {
$error = $this->getErrorType();
if ($error !== null) {
$f['error'] = $error;
}
}
array_unshift($frames, $f);
// Go through previous throwables and merge in their frames
if ($prev = $this->getPrevious()) {
$a = $frames;
$b = $prev->getFrames();
$prevThrowable = $prev->getThrowable();
$diff = $a;
for ($i = count($a) - 1, $j = count($b) - 1; $i >= 0 && $j >= 0; $i--, $j--) {
$af = $diff[$i]['file'];
$bf = $b[$j]['file'];
if ($af && $bf && $af === $bf && $diff[$i]['line'] === $b[$j]['line']) {
unset($diff[$i]);
}
}
$frames = [ ...$diff, ...$b ];
}
$this->frames = $frames;
return $frames;
}
public function getPrevious(): ?ThrowableController {
if ($this->previousThrowableController !== false) {
return $this->previousThrowableController;
}
if ($prev = $this->throwable->getPrevious()) {
$prev = new ThrowableController($prev);
}
$this->previousThrowableController = $prev;
return $prev;
}
public function getThrowable(): \Throwable {
return $this->throwable;
}
}

66
lib/Catcher/ThrowableHandler.php

@ -0,0 +1,66 @@
<?php
/**
* @license MIT
* Copyright 2022 Dustin Wilson, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace Mensbeam\Framework\Catcher;
abstract class ThrowableHandler {
protected static ?string $contentType = null;
/** If true the handler will output data; if false it will be silent */
protected bool $_output = true;
/**
* If true the handler will pass on through to the next handler even if it
* successfully handles the throwable; if false it will prevent execution of the
* next handler if it successfully handles the throwable
*/
protected bool $_passthrough = false;
public function __construct(array $config = []) {
foreach ($config as $key => $value) {
$key = "_$key";
$this->$key = $value;
}
}
public function getContentType(): ?string {
return static::$contentType;
}
public function getOutput(): bool {
return $this->_output;
}
public function getPassthrough(): bool {
return $this->_passthrough;
}
abstract public function handle(\Throwable $throwable, ThrowableController $controller): bool;
public function setOutput(bool $value): void {
$this->_output = $value;
}
public function setPassthrough(bool $value): void {
$this->_passthrough = $value;
}
protected function sendContentTypeHeader(): void {
if (!isset($_SERVER['REQUEST_URI']) || headers_sent()) {
return;
}
header('Content-Type: ' . static::$contentType);
}
}

17
lib/Error.php

@ -0,0 +1,17 @@
<?php
/**
* @license MIT
* Copyright 2022 Dustin Wilson, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace Mensbeam\Framework;
class Error extends \Error {
public function __construct(string $message = '', int $code = 0, ?string $file = null, ?int $line = line, ?\Throwable $previous = null) {
parent::__construct($message, $code, $previous);
$this->file = $file;
$this->line = $line;
}
}
Loading…
Cancel
Save