From a46604a8c65f285be99a1f1b99f3d9f28215b1d0 Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 13 Sep 2023 22:32:02 -0400 Subject: [PATCH 01/13] working --- README.md | 4 +- docs/API:-The-Exceptable-Interface.md | 8 +- docs/API:-The-ExceptableException-Class.md | 10 +- docs/Home.md | 2 +- src/ErrorCase.php | 42 ++++ src/Exceptable.php | 111 ++++----- src/ExceptableException.php | 6 +- src/Handler.php | 16 +- src/IsErrorCase.php | 91 ++++++++ src/IsExceptable.php | 253 +++------------------ src/Spl/BadFunctionCallException.php | 4 +- src/Spl/BadMethodCallException.php | 4 +- src/Spl/DomainException.php | 4 +- src/Spl/InvalidArgumentException.php | 4 +- src/Spl/LengthException.php | 4 +- src/Spl/LogicException.php | 4 +- src/Spl/OutOfBoundsException.php | 4 +- src/Spl/OutOfRangeException.php | 4 +- src/Spl/OverflowException.php | 4 +- src/Spl/RangeException.php | 4 +- src/Spl/RuntimeException.php | 4 +- src/Spl/UnderflowException.php | 4 +- src/Spl/UnexpectedValueException.php | 4 +- tests/HandlerTest.php | 10 +- tests/IsExceptableTest.php | 10 +- tests/TestCase.php | 2 +- tests/stubs.php | 4 +- 27 files changed, 276 insertions(+), 345 deletions(-) create mode 100644 src/ErrorCase.php create mode 100644 src/IsErrorCase.php diff --git a/README.md b/README.md index 6402b51..cd8a51c 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ a quick taste ```php + * @copyright 2014 - 2023 + * @license GPL-3.0 (only) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License, version 3. + * The right to apply the terms of later versions of the GPL is RESERVED. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ +declare(strict_types = 1); +namespace at\exceptable; + +use BackedEnum, + Throwable; + +use at\peekaboo\HasMessages; + +/** + * Defines error cases for an Exceptable. + * + * Implementing enums MUST be integer-backed (ints are error codes). + */ +interface ErrorCase extends BackedEnum, HasMessages { + + /** + * Builds and throws an exception based on this ErrorCase. + * + * @param array $context Additional exception context + * @param ?Throwable $previous Previous exception + * @throws Exceptable + */ + public function throw(array $context = [], Throwable $previous = null) : void; +} diff --git a/src/Exceptable.php b/src/Exceptable.php index 823b537..6d63023 100644 --- a/src/Exceptable.php +++ b/src/Exceptable.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2023 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -18,107 +18,84 @@ */ declare(strict_types = 1); -namespace AT\Exceptable; +namespace at\exceptable; -use ResourceBundle, - Throwable; +use Throwable; -use AT\Exceptable\ExceptableException; +use at\exceptable\ { + ErrorCase, + ExceptableError +}; /** * Augmented interface for exceptional exceptions. - * Exceptables uniquely identify specific error cases by exception FQCN + code. + * Exceptables uniquely identify specific error cases by error case. * * Caution: * - the implementing class must extend from Exception (or a subclass) and implement Exceptable. * - implementations cannot extend from PDOException, * because it breaks the Throwable interface (its getCode() returns a string). */ -interface Exceptable extends Throwable { +interface Exceptable extends Throwable, ExceptableInternals { /** - * Factory: creates a new Exceptable from the given error code, - * adjusting exception info to reflect the location of the calling code. + * Creates a new Exceptable from the given Error Case. * - * @see Exceptable::__construct() - * @return Exceptable + * @param ErrorCode $case Error case + * @param array $context Additional exception context + * @param ?Throwable $previous Previous exception + * @throws ExceptableError If Error is invalid */ - public static function create(int $code, ?array $context = [], Throwable $previous = null) : Exceptable; + public static function fromCase( + ErrorCase $case, + array $context = [], + ? Throwable $previous = null + ) : Exceptable; /** - * Gets information about a code known to the implementing class. + * Gets the ErrorCase this Exceptable uses. * - * @param int $code The exception code to look up - * @throws ExceptableException If the code is not known to the implementation - * @return array Information about the error case, including: - * - string $class Exception class - * - int $code Error code - * - string $message Error description - * - string $format ICU formatting template for contextualized error message - * - mixed $... Additional, implementation-specific information + * @return ErrorCase An ErrorCase */ - public static function getInfo(int $code) : array; + public function case() : ErrorCase; /** - * Checks whether the implementation has info about the given code. + * Gets contextual information about this exception. * - * @param int $code The code to check - * @return bool True if the code is known; false otherwise + * @return array Exception context, including: + * - string "__message__" The top-level exception message + * - string "__rootMessage__" The root exception message (may be the same as "__message__") + * - mixed "__...__" Additional, implementation-specific information + * - mixed "..." Additional context provided at time of error */ - public static function hasInfo(int $code) : bool; + public function context() : array; /** - * Checks whether the given exception matches a code known to the implementing class. + * Checks whether this exception matches the given error case. * - * @param Throwable $e Subject exception - * @param int $code Target code + * @param Throwable $e Subject exception + * @param int $code Target code * @return bool True if exception class and code matches; false otherwise */ - public static function is(Throwable $e, int $code) : bool; - - /** - * Sets up localized message support for the concrete implementation(s). - * - * @param string $locale Preferred locale - * @param ResourceBundle $messages Message format patterns - */ - public static function localize(string $locale, ResourceBundle $messages) : void; + public function is(ErrorCase $case) : bool; /** - * Factory: creates and throws a new Exceptable from the given error code, - * adjusting exception info to reflect the location of the calling code. - * - * @phan-suppress PhanTypeInvalidThrowsIsInterface - * Intentional. + * Traverses the chain of previous exception(s) and gets the root exception. * - * @see Exceptable::__construct() - * @throws Exceptable - */ - public static function throw(int $code, ?array $context = [], Throwable $previous = null) : void; - - /** - * @param int $code Exception code - * @param ?array $context Additional exception context - * @param Throwable|null $previous Previous exception - * @throws ExceptableException If code is invalid + * @return Throwable The root exception */ - public function __construct(int $code = 0, ?array $context = [], Throwable $previous = null); + public function root() : Throwable; +} - /** - * Gets contextual information about this exception. - * - * @return array Exception context, including: - * - string $__message__ The top-level exception message - * - string $__rootMessage__ The root exception message (may be the same as $__message__) - * - mixed $__...__ Additional, implementation-specific information - * - mixed $... Additional context provided at time of error - */ - public function getContext() : array; +/** Public Exceptable methods which are intended for internal use only. */ +interface ExceptableInternals { /** - * Traverses the chain of previous exception(s) and gets the root exception. + * Adjusts this exceptable's file/line to the previous stack frame + * (to account for where it's actually constructed vs. intended to be thrown from). * - * @return Throwable The root exception + * @internal {@used-by} Exceptable::fromCase(), ErrorCase::throw() + * @return Exceptable $this */ - public function getRoot() : Throwable; + public function _adjust(int $frame = 0) : Exceptable; } diff --git a/src/ExceptableException.php b/src/ExceptableException.php index 8f44c42..e1df2c1 100644 --- a/src/ExceptableException.php +++ b/src/ExceptableException.php @@ -18,11 +18,11 @@ */ declare(strict_types = 1); -namespace AT\Exceptable; +namespace at\exceptable; use Exception; -use AT\Exceptable\ { +use at\exceptable\ { Exceptable, IsExceptable }; @@ -30,7 +30,7 @@ /** * exceptableexceptionsexceptableexceptionsexceptableexceptions */ -class ExceptableException extends Exception implements Exceptable { +class ExceptableError extends Exception implements Exceptable { use IsExceptable; /** diff --git a/src/Handler.php b/src/Handler.php index 062f513..77edbe8 100644 --- a/src/Handler.php +++ b/src/Handler.php @@ -18,13 +18,13 @@ */ declare(strict_types = 1); -namespace AT\Exceptable; +namespace at\exceptable; use ErrorException, Exception, Throwable; -use AT\Exceptable\ExceptableException; +use at\exceptable\ExceptableError; use Psr\Log\ { LoggerAwareInterface as LoggerAware, @@ -154,7 +154,7 @@ public function handleError(int $c, string $m, string $f, int $l) : bool { * Handles uncaught exceptions. * * @param Throwable $e The uncaught exception - * @throws ExceptableException If no registered handler handles the exception + * @throws ExceptableError If no registered handler handles the exception */ public function handleException(Throwable $e) : void { $type = get_class($e); @@ -186,7 +186,7 @@ public function handleException(Throwable $e) : void { } $this->logException(false, $e); - throw new ExceptableException(ExceptableException::UNCAUGHT_EXCEPTION, [], $e); + throw new ExceptableError(ExceptableError::UNCAUGHT_EXCEPTION, [], $e); } /** @@ -303,7 +303,7 @@ public function throwErrors(int $types = E_ERROR | E_WARNING) : Handler { * * @param callable $callback The callback to execute * @param mixed ...$arguments Arguments to pass to the callback - * @throws ExceptableException If an exception is thrown and no registered handler handles it + * @throws ExceptableError If an exception is thrown and no registered handler handles it * @return mixed The value returned from the callback on success */ public function try(callable $callback, ...$arguments) { @@ -451,7 +451,7 @@ protected function logException(bool $handled, Throwable $e) : void { * @param callable $handler The handler to invoke * @param Throwable $e The exception to handle * @return bool True if handler ran successfully; false otherwise - * @throws ExceptableException INVALID_HANDLER if the handler errors + * @throws ExceptableError INVALID_HANDLER if the handler errors */ protected function runExceptionHandler(callable $handler, Throwable $e) : bool { try { @@ -465,8 +465,8 @@ protected function runExceptionHandler(callable $handler, Throwable $e) : bool { return false; } catch (Throwable $x) { - ExceptableException::throw( - ExceptableException::INVALID_HANDLER, + ExceptableError::throw( + ExceptableError::INVALID_HANDLER, ["type" => gettype($handler), "unhandled" => $e], $x ); diff --git a/src/IsErrorCase.php b/src/IsErrorCase.php new file mode 100644 index 0000000..b6adfc2 --- /dev/null +++ b/src/IsErrorCase.php @@ -0,0 +1,91 @@ + + * @copyright 2014 - 2023 + * @license GPL-3.0 (only) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License, version 3. + * The right to apply the terms of later versions of the GPL is RESERVED. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ +declare(strict_types = 1); +namespace at\exceptable; + +use BackedEnum, + Throwable; + +use at\peekaboo\HasMessages; + +/** + * Defines error cases for an Exceptable. + * + * Implementing enums MUST: + * - be integer-backed (ints are error codes) + * and SHOULD: + * - define EXCEPTABLE with the fully-qualified classname for the Exceptable class it should throw + * (will throw RuntimeErrors otherwise) + * - implement ->substituterMessageFormat() to provide a basic message template for each case + * (tip: use match(this) { ... }) + */ +trait isErrorCase { + use HasMessages; + + /** {@inheritDoc} */ + public function throw(array $context = [], Throwable $previous = null) : void { + throw $this->exceptableType()::fromCase($this, $context, $previous)->adjust(); + } + + /** + * Finds the Exceptable FQCN this ErrorCase should throw. + * + * @throws ExceptableError + * UNACCEPTABLE_EXCEPTABLE if static::EXCEPTABLE is defined but is not an Exceptable FQCN + * @return string A fully qualified Exceptable classname + */ + protected function exceptableType() : string { + if (defined("static::EXCEPTABLE")) { + if (! is_a(static::EXCEPTABLE, Exceptable::class, true)) { + ExceptableError::E::UNACCEPTABLE_EXCEPTABLE->throw([ + "type" => is_string(static::EXCEPTABLE) ? + static::EXCEPTABLE : + get_debug_type(static::EXCEPTABLE) + ]); + } + + return static::EXCEPTABLE; + } + + return RuntimeException::class; + } + + /** {@inheritDoc} */ + protected function findSubstituterMessageFormat(string $key) : ? string { + $message = "{$this->exceptableType()}::E::{$this->name}"; + $format = $this->substituterMessageFormat(); + return empty($format) ? + $message : + "{$message}: {$format}"; + } + + /** + * Provides a message format for this case. + * + * Returns an empty format by default. + * Override this method to return an appropriate message format for each of your cases. + * + * @return string A message format string with {tokens} for replacements + */ + protected function substituterMessageFormat() : string { + return match($this) { + default => "" + }; + } +} diff --git a/src/IsExceptable.php b/src/IsExceptable.php index a243c37..af858a3 100644 --- a/src/IsExceptable.php +++ b/src/IsExceptable.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2023 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -17,14 +17,16 @@ * If not, see . */ declare(strict_types = 1); - -namespace AT\Exceptable; +namespace at\exceptable; use MessageFormatter, ResourceBundle, Throwable; -use AT\Exceptable\Exceptable; +use at\exceptable\ { + ErrorCase, + Exceptable +}; /** * Base implementation for Exceptable interface, including contexted message construction. @@ -34,120 +36,41 @@ * const INFO is expected to be defined by implementations; all usage here checks first. */ trait IsExceptable { + use HasExceptableInternals; - /** @var string Preferred locale for messages. */ - protected static $locale; - - /** @var string Default locale for messages (MUST NOT be modified at runtime). */ - protected static $defaultLocale = "en"; + /** @see Exceptable::fromCase() $case */ + protected ErrorCase $case; - /** @var ResourceBundle ICU messages bundle. */ - protected static $messages; + /** @see Exceptable::fromCase() $context */ + protected array $context = []; - /** - * {@inheritDoc} - * - * @phan-suppress PhanTypeInstantiateTraitStaticOrSelf - * We define the constructor. You shouldn't change it. - */ - public static function create(int $code, ?array $context = [], Throwable $previous = null) : Exceptable { - $exceptable = new static($code, $context, $previous); + /** @see Exceptable::fromCase() $previous */ + protected ? Throwable $previous = null; - $frame = $exceptable->getTrace()[0]; - $exceptable->file = $frame["file"]; - $exceptable->line = $frame["line"]; - - assert($exceptable instanceof Exceptable); - return $exceptable; - } - - /** @see Exceptable::getInfo() */ - public static function getInfo(int $code) : array { - if (! static::hasInfo($code)) { - throw ExceptableException::create(ExceptableException::NO_SUCH_CODE, ['code' => $code]); - } - - return ["code" => $code] + static::INFO[$code] + ["format" => null]; - } - - /** @see Exceptable::hasInfo() */ - public static function hasInfo(int $code) : bool { - return defined("static::INFO") && - isset(static::INFO[$code]["message"]) && - is_string(static::INFO[$code]["message"]); + /** @see Exceptable::fromCase() */ + public static function fromCase(ErrorCase $case, array $context = [], ? Throwable $previous = null) : Exceptable { + assert(is_a(static::class, Exceptable::class, true)); + return (new static($case->message($this->context), $case->value, $previous))->adjust(); } /** @see Exceptable::is() */ - public static function is(Throwable $e, int $code) : bool { - return (get_class($e) === static::class) && - static::hasInfo($code) && - $e->getCode() === $code; - } - - /** @see Exceptable::localize() */ - public static function localize(string $locale, ResourceBundle $messages) : void { - static::$locale = $locale; - static::$messages = $messages; - } - - /** - * {@inheritDoc} - * - * @phan-suppress PhanTypeInstantiateTraitStaticOrSelf - * We define the constructor. You shouldn't change it. - */ - public static function throw(int $code, ?array $context = [], Throwable $previous = null) : void { - $exceptable = new static($code, $context, $previous); - - $frame = $exceptable->getTrace()[0]; - $exceptable->file = $frame["file"]; - $exceptable->line = $frame["line"]; - - assert($exceptable instanceof Exceptable); - throw $exceptable; + public function is(ErrorCase $case) : bool { + return $this->case === $case; } - /** - * @see https://php.net/class.Exception - * - * @var int $code - * @var string $file - * @var int $line - * @var string $message - */ - protected $code = 0; - protected string $file = ""; - protected int $line = 0; - protected $message = ""; - - /** @var array Contextual information. */ - protected $context = []; - - /** @see Exceptable::__construct() */ - public function __construct(int $code = 0, ?array $context = [], Throwable $previous = null) { - $this->context = $context ?? []; - $this->context["__rootMessage__"] = isset($previous) ? - $this->findRoot($previous)->getMessage() : - null; - - // @phan-suppress-next-line PhanTraitParentReference - parent::__construct($this->makeMessage($code), $code, $previous); - - $this->context["__rootMessage__"] = $this->context["__rootMessage__"] ?? $this->getMessage(); + /** @see Exceptable::case() */ + public function case() : ErrorCase { + return $this->case; } - /** @see Exceptable::getContext() */ - public function getContext() : array { + /** @see Exceptable::context() */ + public function context() : array { return $this->context; } - /** - * @see Exceptable::getRoot() - * - * @phan-suppress PhanTypeMismatchArgument - * Exceptables ARE Throwable. - */ - public function getRoot() : Throwable { + /** @see Exceptable::root() */ + public function root() : Throwable { + assert($this instanceof Throwable); return $this->findRoot($this); } @@ -164,121 +87,19 @@ protected function findRoot(Throwable $root) : Throwable { return $root; } +} - /** - * Looks up a message format by key, if a messages bundle is available. - * - * @param string|null $key Dot-delimited path to desired key - * @return string|null Message format on success; null otherwise - */ - protected function getMessageFormat(?string $key) : ?string { - if (! isset($key, static::$messages)) { - return null; - } - - $message = static::$messages; - foreach (explode(".", $key) as $next) { - if ($message instanceof ResourceBundle) { - $message = $message->get($next); - continue; - } - - return null; - } - - return is_scalar($message) ? (string) $message : null; - } - - /** - * Gets string-able context, as strings, for message formatting. - * - * @return string[] Formatting context - */ - protected function getFormattingContext() : array { - $formatting_context = []; - foreach ($this->context as $key => $value) { - if (! is_scalar($value) && ! (is_object($value) && method_exists($value, "__toString"))) { - continue; - } - - $formatting_context[$key] = (string) $value; - } - - return $formatting_context; - } - - /** - * Builds the exception message based on error code. - * - * @param int $code Error code - * @return string - */ - protected function makeMessage(int $code) : string { - $info = static::getInfo($code); - - $format = $this->getMessageFormat($info["formatKey"] ?? null) ?? $info["format"]; - if (isset($format)) { - if (extension_loaded("intl")) { - $message = MessageFormatter::formatMessage( - static::$locale ?? static::$defaultLocale, - $format, - $this->getFormattingContext() - ) ?: - $info["message"]; - - // :( - if ($message !== $format) { - return $message; - } - } - - return $this->substituteMessage($format) ?? $info["message"]; - } - - return $info["message"]; - } - - /** - * Fallback message formatter, used if Intl is not installed. - * Supports simple value substitution only. - * - * @param string $format Message format string - * @return string|null Formatted message on success; null otherwise - */ - protected function substituteMessage(string $format) : ?string { - $context = $this->getFormattingContext(); - preg_match_all("(\{(\w+)\})u", $format, $matches); - $placeholders = $matches[1]; - $replacements = []; - foreach ($placeholders as $placeholder) { - if (! isset($context[$placeholder])) { - return null; - } +/** Base implementation for Exceptable internal methods. */ +trait HasExceptableInternals { - $replacements["{{$placeholder}}"] = $this->context[$placeholder]; + /** @see ExceptableInternals::throw() */ + public function adjust(int $frame = 0) : Exceptable { + $info = $this->getTrace()[$frame] ?? null; + if (isset($frame)) { + $this->file = $info["file"]; + $this->line = $info["line"]; } - return strtr($format, $replacements); + return $this; } - - /** @see https://php.net/Throwable.getCode */ - abstract public function getCode(); - - /** @see https://php.net/Throwable.getFile */ - abstract public function getFile(); - - /** @see https://php.net/Throwable.getLine */ - abstract public function getLine(); - - /** @see https://php.net/Throwable.getMessage */ - abstract public function getMessage(); - - /** @see https://php.net/Throwable.getPrevious */ - abstract public function getPrevious(); - - /** @see https://php.net/Throwable.getTrace */ - abstract public function getTrace(); - - /** @see https://php.net/Throwable.getTraceasString */ - abstract public function getTraceAsString(); } diff --git a/src/Spl/BadFunctionCallException.php b/src/Spl/BadFunctionCallException.php index 8c011f1..095f7c2 100644 --- a/src/Spl/BadFunctionCallException.php +++ b/src/Spl/BadFunctionCallException.php @@ -18,11 +18,11 @@ */ declare(strict_types = 1); -namespace AT\Exceptable\Spl; +namespace at\exceptable\Spl; use BadFunctionCallException as SplBadFunctionCallException; -use AT\Exceptable\ { +use at\exceptable\ { Exceptable, IsExceptable }; diff --git a/src/Spl/BadMethodCallException.php b/src/Spl/BadMethodCallException.php index b1c2fe8..7305790 100644 --- a/src/Spl/BadMethodCallException.php +++ b/src/Spl/BadMethodCallException.php @@ -18,11 +18,11 @@ */ declare(strict_types = 1); -namespace AT\Exceptable\Spl; +namespace at\exceptable\Spl; use BadMethodCallException as SplBadMethodCallException; -use AT\Exceptable\ { +use at\exceptable\ { Exceptable, IsExceptable }; diff --git a/src/Spl/DomainException.php b/src/Spl/DomainException.php index 3580324..355c4a8 100644 --- a/src/Spl/DomainException.php +++ b/src/Spl/DomainException.php @@ -18,11 +18,11 @@ */ declare(strict_types = 1); -namespace AT\Exceptable\Spl; +namespace at\exceptable\Spl; use DomainException as SplDomainException; -use AT\Exceptable\ { +use at\exceptable\ { Exceptable, IsExceptable }; diff --git a/src/Spl/InvalidArgumentException.php b/src/Spl/InvalidArgumentException.php index bc6ebc2..5384c35 100644 --- a/src/Spl/InvalidArgumentException.php +++ b/src/Spl/InvalidArgumentException.php @@ -18,11 +18,11 @@ */ declare(strict_types = 1); -namespace AT\Exceptable\Spl; +namespace at\exceptable\Spl; use InvalidArgumentException as SplInvalidArgumentException; -use AT\Exceptable\ { +use at\exceptable\ { Exceptable, IsExceptable }; diff --git a/src/Spl/LengthException.php b/src/Spl/LengthException.php index b568685..c24fc0b 100644 --- a/src/Spl/LengthException.php +++ b/src/Spl/LengthException.php @@ -18,11 +18,11 @@ */ declare(strict_types = 1); -namespace AT\Exceptable\Spl; +namespace at\exceptable\Spl; use LengthException as SplLengthException; -use AT\Exceptable\ { +use at\exceptable\ { Exceptable, IsExceptable }; diff --git a/src/Spl/LogicException.php b/src/Spl/LogicException.php index 8100548..153f8ad 100644 --- a/src/Spl/LogicException.php +++ b/src/Spl/LogicException.php @@ -18,11 +18,11 @@ */ declare(strict_types = 1); -namespace AT\Exceptable\Spl; +namespace at\exceptable\Spl; use LogicException as SplLogicException; -use AT\Exceptable\ { +use at\exceptable\ { Exceptable, IsExceptable }; diff --git a/src/Spl/OutOfBoundsException.php b/src/Spl/OutOfBoundsException.php index 84eb2d2..b98aa03 100644 --- a/src/Spl/OutOfBoundsException.php +++ b/src/Spl/OutOfBoundsException.php @@ -18,11 +18,11 @@ */ declare(strict_types = 1); -namespace AT\Exceptable\Spl; +namespace at\exceptable\Spl; use OutOfBoundsException as SplOutOfBoundsException; -use AT\Exceptable\ { +use at\exceptable\ { Exceptable, IsExceptable }; diff --git a/src/Spl/OutOfRangeException.php b/src/Spl/OutOfRangeException.php index a0a9285..9654ba7 100644 --- a/src/Spl/OutOfRangeException.php +++ b/src/Spl/OutOfRangeException.php @@ -18,11 +18,11 @@ */ declare(strict_types = 1); -namespace AT\Exceptable\Spl; +namespace at\exceptable\Spl; use OutOfRangeException as SplOutOfRangeException; -use AT\Exceptable\ { +use at\exceptable\ { Exceptable, IsExceptable }; diff --git a/src/Spl/OverflowException.php b/src/Spl/OverflowException.php index 4a8b57a..a779b00 100644 --- a/src/Spl/OverflowException.php +++ b/src/Spl/OverflowException.php @@ -18,11 +18,11 @@ */ declare(strict_types = 1); -namespace AT\Exceptable\Spl; +namespace at\exceptable\Spl; use OverflowException as SplOverflowException; -use AT\Exceptable\ { +use at\exceptable\ { Exceptable, IsExceptable }; diff --git a/src/Spl/RangeException.php b/src/Spl/RangeException.php index d677680..1bdc0f8 100644 --- a/src/Spl/RangeException.php +++ b/src/Spl/RangeException.php @@ -18,11 +18,11 @@ */ declare(strict_types = 1); -namespace AT\Exceptable\Spl; +namespace at\exceptable\Spl; use RangeException as SplRangeException; -use AT\Exceptable\ { +use at\exceptable\ { Exceptable, IsExceptable }; diff --git a/src/Spl/RuntimeException.php b/src/Spl/RuntimeException.php index 4759096..74e41b3 100644 --- a/src/Spl/RuntimeException.php +++ b/src/Spl/RuntimeException.php @@ -18,11 +18,11 @@ */ declare(strict_types = 1); -namespace AT\Exceptable\Spl; +namespace at\exceptable\Spl; use RuntimeException as SplRuntimeException; -use AT\Exceptable\ { +use at\exceptable\ { Exceptable, IsExceptable }; diff --git a/src/Spl/UnderflowException.php b/src/Spl/UnderflowException.php index a323a55..095ac00 100644 --- a/src/Spl/UnderflowException.php +++ b/src/Spl/UnderflowException.php @@ -18,11 +18,11 @@ */ declare(strict_types = 1); -namespace AT\Exceptable\Spl; +namespace at\exceptable\Spl; use UnderflowException as SplUnderflowException; -use AT\Exceptable\ { +use at\exceptable\ { Exceptable, IsExceptable }; diff --git a/src/Spl/UnexpectedValueException.php b/src/Spl/UnexpectedValueException.php index 8952efe..5f7aa68 100644 --- a/src/Spl/UnexpectedValueException.php +++ b/src/Spl/UnexpectedValueException.php @@ -18,11 +18,11 @@ */ declare(strict_types = 1); -namespace AT\Exceptable\Spl; +namespace at\exceptable\Spl; use UnexpectedValueException as SplUnexpectedValueException; -use AT\Exceptable\ { +use at\exceptable\ { Exceptable, IsExceptable }; diff --git a/tests/HandlerTest.php b/tests/HandlerTest.php index f5788a4..1983f9f 100644 --- a/tests/HandlerTest.php +++ b/tests/HandlerTest.php @@ -19,16 +19,16 @@ */ declare(strict_types = 1); -namespace AT\Exceptable\Tests; +namespace at\exceptable\Tests; use ErrorException, Exception, ResourceBundle, Throwable; -use AT\Exceptable\ { +use at\exceptable\ { Exceptable, - ExceptableException, + ExceptableError, Handler, IsExceptable, Tests\TestCase @@ -49,7 +49,7 @@ * Due to this limitation, actual invocation of error/exception/shutdown handlers, * as well as the handle() method, must be manually tested outside of phpunit. * - * @covers AT\Exceptable\Handler + * @covers at\exceptable\Handler */ class HandlerTest extends TestCase { @@ -127,7 +127,7 @@ public function testHandleException() : void { public function testHandleExceptionFailure() : void { $e = new Exception("this one slips through"); $this->expectThrowable( - new ExceptableException(ExceptableException::UNCAUGHT_EXCEPTION, [], $e), + new ExceptableError(ExceptableError::UNCAUGHT_EXCEPTION, [], $e), self::EXPECT_THROWABLE_CODE | self::EXPECT_THROWABLE_MESSAGE ); diff --git a/tests/IsExceptableTest.php b/tests/IsExceptableTest.php index 356e44c..b82c9f1 100644 --- a/tests/IsExceptableTest.php +++ b/tests/IsExceptableTest.php @@ -19,15 +19,15 @@ */ declare(strict_types = 1); -namespace AT\Exceptable\Tests; +namespace at\exceptable\Tests; use Exception, ResourceBundle, Throwable; -use AT\Exceptable\ { +use at\exceptable\ { Exceptable, - ExceptableException, + ExceptableError, IsExceptable, Tests\TestCase }; @@ -35,7 +35,7 @@ /** * Basic tests for the default Exceptable implementation (the IsExceptable trait). * - * @covers AT\Exceptable\IsExceptable + * @covers at\exceptable\IsExceptable * * This test case can (should) be extended to test other concrete implementations: * - override exceptableFQCN() method to provide the name of the exceptable to test @@ -287,7 +287,7 @@ public function testGetBadInfo(int $code) : void { $fqcn = $this->exceptableFQCN(); $this->expectThrowable( - new ExceptableException(ExceptableException::NO_SUCH_CODE, ["code" => $code]), + new ExceptableError(ExceptableError::NO_SUCH_CODE, ["code" => $code]), self::EXPECT_THROWABLE_CODE | self::EXPECT_THROWABLE_MESSAGE ); diff --git a/tests/TestCase.php b/tests/TestCase.php index 48af62a..f1d159b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -19,7 +19,7 @@ */ declare(strict_types = 1); -namespace AT\Exceptable\Tests; +namespace at\exceptable\Tests; use BadMethodCallException, ReflectionClass, diff --git a/tests/stubs.php b/tests/stubs.php index 8b67406..6bfabbd 100644 --- a/tests/stubs.php +++ b/tests/stubs.php @@ -19,9 +19,9 @@ */ declare(strict_types = 1); -namespace AT\Exceptable; +namespace at\exceptable; -use AT\Exceptable\Tests\HandlerTest; +use at\exceptable\Tests\HandlerTest; if (! function_exists(register_shutdown_function::class)) { /** From 46de7566db1d6ec777c30d83424e7804ab5cfac5 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 27 Oct 2023 23:47:47 -0400 Subject: [PATCH 02/13] refactored --- composer.json | 12 +- src/{ErrorCase.php => Error.php} | 35 +++-- src/Exceptable.php | 72 +++++----- src/ExceptableError.php | 48 +++++++ src/ExceptableException.php | 63 --------- src/Handler.php | 2 +- src/IsError.php | 78 +++++++++++ src/IsErrorCase.php | 91 ------------ src/IsExceptable.php | 132 +++++++++++------- src/Spl/BadFunctionCallException.php | 17 +-- src/Spl/BadMethodCallException.php | 17 +-- src/Spl/DomainException.php | 17 +-- src/Spl/InvalidArgumentException.php | 17 +-- src/Spl/LengthException.php | 17 +-- src/Spl/LogicException.php | 17 +-- src/Spl/OutOfBoundsException.php | 19 +-- src/Spl/OutOfRangeException.php | 17 +-- src/Spl/OverflowException.php | 17 +-- src/Spl/RangeException.php | 17 +-- src/Spl/RuntimeException.php | 17 +-- src/Spl/SplError.php | 122 ++++++++++++++++ src/Spl/UnderflowException.php | 17 +-- src/Spl/UnexpectedValueException.php | 17 +-- tests/ErrorCaseTest.php | 201 +++++++++++++++++++++++++++ tests/HandlerTest.php | 2 +- tests/IsExceptableTest.php | 2 +- tests/TestCase.php | 2 +- tests/stubs.php | 2 +- 28 files changed, 653 insertions(+), 434 deletions(-) rename src/{ErrorCase.php => Error.php} (55%) create mode 100644 src/ExceptableError.php delete mode 100644 src/ExceptableException.php create mode 100644 src/IsError.php delete mode 100644 src/IsErrorCase.php create mode 100644 src/Spl/SplError.php create mode 100644 tests/ErrorCaseTest.php diff --git a/composer.json b/composer.json index 9a107d3..8a1a074 100644 --- a/composer.json +++ b/composer.json @@ -18,26 +18,26 @@ "source": "https://github.com/php-enspired/exceptable" }, "require": { - "php": "^7.4 || ^8" + "php": "^8.2" }, "suggest": { "ext-intl": "Adds support for localization and ICU message formatting." }, "require-dev": { - "phan/phan": "^5", - "phpunit/phpunit": "^9" + "phan/phan": "^5.4.3", + "phpunit/phpunit": "^10" }, "autoload": { "psr-4": { - "AT\\Exceptable\\": "src/", - "AT\\Exceptable\\Tests\\": "tests/" + "at\\exceptable\\": "src/", + "at\\exceptable\\Tests\\": "tests/" } }, "scripts": { "build:dist": "bin/build-dist", "build:locales": "genrb resources/language/*.txt -d resources/language/", "build:test": "bin/build-test", - "test:phan": "vendor/bin/phan", + "test:analyze": "vendor/bin/phan", "test:unit" : "vendor/bin/phpunit tests", "wiki:update": "git subtree push --prefix docs wiki master", "wiki:update-docs": "git subtree pull --prefix docs wiki master --squash --message='Merge wiki updates into docs'" diff --git a/src/ErrorCase.php b/src/Error.php similarity index 55% rename from src/ErrorCase.php rename to src/Error.php index ba32dcb..a14e242 100644 --- a/src/ErrorCase.php +++ b/src/Error.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2023 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -19,24 +19,33 @@ declare(strict_types = 1); namespace at\exceptable; -use BackedEnum, - Throwable; +use Throwable; use at\peekaboo\HasMessages; -/** - * Defines error cases for an Exceptable. - * - * Implementing enums MUST be integer-backed (ints are error codes). - */ -interface ErrorCase extends BackedEnum, HasMessages { +/** Defines error cases for use by an Exceptable, or as a standalone error value. */ +interface Error extends HasMessages { /** - * Builds and throws an exception based on this ErrorCase. + * Gets the error code for this case. + * + * @return int An error code + */ + public function code() : int; + + /** + * Creates an Exceptable from this Error case. * * @param array $context Additional exception context - * @param ?Throwable $previous Previous exception - * @throws Exceptable + * @param ?Throwable $previous Previous exception, if any + */ + public function exceptable(array $context = [], Throwable $previous = null) : Exceptable; + + /** + * Gets the error message for this case, using the given context. + * + * @param array $context Exception context + * @return string An error message */ - public function throw(array $context = [], Throwable $previous = null) : void; + public function message(array $context) : string; } diff --git a/src/Exceptable.php b/src/Exceptable.php index 6d63023..51bc730 100644 --- a/src/Exceptable.php +++ b/src/Exceptable.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2023 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -23,46 +23,45 @@ use Throwable; use at\exceptable\ { - ErrorCase, + Error, ExceptableError }; /** * Augmented interface for exceptional exceptions. - * Exceptables uniquely identify specific error cases by error case. + * Exceptables uniquely identify specific error cases. * * Caution: * - the implementing class must extend from Exception (or a subclass) and implement Exceptable. * - implementations cannot extend from PDOException, * because it breaks the Throwable interface (its getCode() returns a string). + * + * @phan-suppress PhanCommentObjectInClassConstantType */ -interface Exceptable extends Throwable, ExceptableInternals { +interface Exceptable extends Throwable { /** - * Creates a new Exceptable from the given Error Case. + * The default (0) Error for this Exceptable. * - * @param ErrorCode $case Error case - * @param array $context Additional exception context - * @param ?Throwable $previous Previous exception - * @throws ExceptableError If Error is invalid + * @var Error + * @todo Add type constraint once php 8.2 support is dropped. */ - public static function fromCase( - ErrorCase $case, - array $context = [], - ? Throwable $previous = null - ) : Exceptable; + public const DEFAULT_ERROR = ExceptableError::UnknownError; /** - * Gets the ErrorCase this Exceptable uses. + * Creates a new Exceptable from the given Error case. * - * @return ErrorCase An ErrorCase + * @param ?Error $e The Error case to build from + * @param array $context Additional exception context + * @param ?Throwable $previous Previous exception, if any + * @return Exceptable A new Exceptable on success */ - public function case() : ErrorCase; + public static function from(Error $e = null, array $context = [], Throwable $previous = null) : Exceptable; /** - * Gets contextual information about this exception. + * Gets contextual information about this Exceptable. * - * @return array Exception context, including: + * @return array Exceptable context, including: * - string "__message__" The top-level exception message * - string "__rootMessage__" The root exception message (may be the same as "__message__") * - mixed "__...__" Additional, implementation-specific information @@ -71,31 +70,34 @@ public function case() : ErrorCase; public function context() : array; /** - * Checks whether this exception matches the given error case. + * Gets this Exceptable's Error case. * - * @param Throwable $e Subject exception - * @param int $code Target code - * @return bool True if exception class and code matches; false otherwise + * @return Error The Error case this Exceptable was built from. */ - public function is(ErrorCase $case) : bool; + public function error() : Error; /** - * Traverses the chain of previous exception(s) and gets the root exception. + * Does this Exceptable contain the given Error case in its error chain? * - * @return Throwable The root exception + * @param Error $e The Error case to compare against + * @return bool True if the given Error belongs to this or a previous Exceptable; false otherwise */ - public function root() : Throwable; -} + public function has(Error $e) : bool; -/** Public Exceptable methods which are intended for internal use only. */ -interface ExceptableInternals { + /** + * Checks whether this exception matches the given error case. + * + * @param Error $e The Error case to compare against + * @return bool True if this Exceptable's Error case matches; false otherwise + */ + public function is(Error $e) : bool; /** - * Adjusts this exceptable's file/line to the previous stack frame - * (to account for where it's actually constructed vs. intended to be thrown from). + * Traverses the chain of previous exception(s) and gets the root exception. + * + * This may be the same as the top-level exception, if there are no previous exceptions. * - * @internal {@used-by} Exceptable::fromCase(), ErrorCase::throw() - * @return Exceptable $this + * @return Throwable The root exception */ - public function _adjust(int $frame = 0) : Exceptable; + public function root() : Throwable; } diff --git a/src/ExceptableError.php b/src/ExceptableError.php new file mode 100644 index 0000000..db1d029 --- /dev/null +++ b/src/ExceptableError.php @@ -0,0 +1,48 @@ + + * @copyright 2014 - 2024 + * @license GPL-3.0 (only) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License, version 3. + * The right to apply the terms of later versions of the GPL is RESERVED. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ +declare(strict_types = 1); + +namespace at\exceptable; + +use at\exceptable\ { + Error, + IsError +}; + +/** + * @phan-suppress PhanInvalidConstantExpression + * false positive + */ +enum ExceptableError : int implements Error { + use IsError; + + case UnacceptableError = 0; + case UncaughtException = 1; + case UnknownError = 2; + + /** @see MakesMessages::MESSAGES */ + protected const MESSAGES = [ + self::class => [ + self::UnacceptableError->name => + "Invalid Error type '{type}' (expected enum implementing " . Error::class . ")", + self::UncaughtException->name => "Uncaught Exception ({__rootType__}): {__rootMessage__}", + self::UnknownError->name => "{__rootMessage__}" + ] + ]; +} diff --git a/src/ExceptableException.php b/src/ExceptableException.php deleted file mode 100644 index e1df2c1..0000000 --- a/src/ExceptableException.php +++ /dev/null @@ -1,63 +0,0 @@ - - * @copyright 2014 - 2020 - * @license GPL-3.0 (only) - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License, version 3. - * The right to apply the terms of later versions of the GPL is RESERVED. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with this program. - * If not, see . - */ -declare(strict_types = 1); - -namespace at\exceptable; - -use Exception; - -use at\exceptable\ { - Exceptable, - IsExceptable -}; - -/** - * exceptableexceptionsexceptableexceptionsexceptableexceptions - */ -class ExceptableError extends Exception implements Exceptable { - use IsExceptable; - - /** - * @var int NO_SUCH_CODE Invalid exception code - * @var int UNCAUGHT_EXCEPTION Uncaught/unhandled exception during runtime - * @var int INVALID_HANDLER Invalid handler (e.g., wrong signature, or throws) - */ - public const NO_SUCH_CODE = 1; - public const UNCAUGHT_EXCEPTION = 2; - public const INVALID_HANDLER = 3; - - /** @see Exceptable::INFO */ - public const INFO = [ - self::NO_SUCH_CODE => [ - "message" => "No such code", - "format" => "No exception code '{code}' is known", - "formatKey" => "exceptable.exceptableexception.no_such_code" - ], - self::UNCAUGHT_EXCEPTION => [ - "message" => "Uncaught exception", - "format" => "No registered handler caught exception: {__rootMessage__}", - "formatKey" => "exceptable.exceptableexception.uncaught_exception" - ], - self::INVALID_HANDLER => [ - "message" => "Invalid handler", - "format" => "Invalid handler [{type}]: {__rootMessage__}", - "formatKey" => "exceptable.exceptableexception.invalid_handler" - ] - ]; -} diff --git a/src/Handler.php b/src/Handler.php index 77edbe8..b56a0d1 100644 --- a/src/Handler.php +++ b/src/Handler.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it diff --git a/src/IsError.php b/src/IsError.php new file mode 100644 index 0000000..983282f --- /dev/null +++ b/src/IsError.php @@ -0,0 +1,78 @@ + + * @copyright 2014 - 2024 + * @license GPL-3.0 (only) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License, version 3. + * The right to apply the terms of later versions of the GPL is RESERVED. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ +declare(strict_types = 1); +namespace at\exceptable; + +use BackedEnum, + Throwable, + UnitEnum; + +use at\exceptable\ { + Error, + Spl\RuntimeException +}; + +use at\peekaboo\MakesMessages; + +/** + * Defines error cases for an Exceptable. + * + * Implementing Enums may define MESSAGES to provide default message templates. + * If not defined, the case name will be used. + */ +trait isError { + use MakesMessages; + + /** @see Error::code() */ + public function code() : int { + assert($this instanceof UnitEnum); + + // return enum value if integers provided + if ($this instanceof BackedEnum && is_int($this->value)) { + return $this->value; + } + + // else determine code based on case order + foreach ($this->cases() as $code => $case) { + if ($case === $this) { + return $code + 1; + } + } + } + + /** @see Error::message() */ + public function message(array $context) : string { + assert($this instanceof UnitEnum); + + $error = static::class . ".{$this->name}"; + $message = $this->makeMessage($this->name, $context); + return empty($message) ? + $error : + "{$error}: {$message}"; + } + + /** @see Error::throw() */ + public function exceptable(array $context = [], Throwable $previous = null) : Exceptable { + assert($this instanceof Error); + return RuntimeException::from($this, $context, $previous); + } + + /** @see UnitEnum::cases() */ + abstract public static function cases() : array; +} diff --git a/src/IsErrorCase.php b/src/IsErrorCase.php deleted file mode 100644 index b6adfc2..0000000 --- a/src/IsErrorCase.php +++ /dev/null @@ -1,91 +0,0 @@ - - * @copyright 2014 - 2023 - * @license GPL-3.0 (only) - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License, version 3. - * The right to apply the terms of later versions of the GPL is RESERVED. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with this program. - * If not, see . - */ -declare(strict_types = 1); -namespace at\exceptable; - -use BackedEnum, - Throwable; - -use at\peekaboo\HasMessages; - -/** - * Defines error cases for an Exceptable. - * - * Implementing enums MUST: - * - be integer-backed (ints are error codes) - * and SHOULD: - * - define EXCEPTABLE with the fully-qualified classname for the Exceptable class it should throw - * (will throw RuntimeErrors otherwise) - * - implement ->substituterMessageFormat() to provide a basic message template for each case - * (tip: use match(this) { ... }) - */ -trait isErrorCase { - use HasMessages; - - /** {@inheritDoc} */ - public function throw(array $context = [], Throwable $previous = null) : void { - throw $this->exceptableType()::fromCase($this, $context, $previous)->adjust(); - } - - /** - * Finds the Exceptable FQCN this ErrorCase should throw. - * - * @throws ExceptableError - * UNACCEPTABLE_EXCEPTABLE if static::EXCEPTABLE is defined but is not an Exceptable FQCN - * @return string A fully qualified Exceptable classname - */ - protected function exceptableType() : string { - if (defined("static::EXCEPTABLE")) { - if (! is_a(static::EXCEPTABLE, Exceptable::class, true)) { - ExceptableError::E::UNACCEPTABLE_EXCEPTABLE->throw([ - "type" => is_string(static::EXCEPTABLE) ? - static::EXCEPTABLE : - get_debug_type(static::EXCEPTABLE) - ]); - } - - return static::EXCEPTABLE; - } - - return RuntimeException::class; - } - - /** {@inheritDoc} */ - protected function findSubstituterMessageFormat(string $key) : ? string { - $message = "{$this->exceptableType()}::E::{$this->name}"; - $format = $this->substituterMessageFormat(); - return empty($format) ? - $message : - "{$message}: {$format}"; - } - - /** - * Provides a message format for this case. - * - * Returns an empty format by default. - * Override this method to return an appropriate message format for each of your cases. - * - * @return string A message format string with {tokens} for replacements - */ - protected function substituterMessageFormat() : string { - return match($this) { - default => "" - }; - } -} diff --git a/src/IsExceptable.php b/src/IsExceptable.php index af858a3..83b2c3a 100644 --- a/src/IsExceptable.php +++ b/src/IsExceptable.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2023 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -19,87 +19,115 @@ declare(strict_types = 1); namespace at\exceptable; -use MessageFormatter, - ResourceBundle, - Throwable; +use Throwable; use at\exceptable\ { - ErrorCase, + Error, Exceptable }; /** * Base implementation for Exceptable interface, including contexted message construction. - * This trait MUST be used by a class which extends from Exception and implements Exceptable. * - * @phan-file-suppress PhanUndeclaredConstantOfClass - * const INFO is expected to be defined by implementations; all usage here checks first. + * This trait is intended for use only by Exceptable implementations. + * Methods assert() this but provide no runtime checks if asserts are disabled - approach with caution! */ trait IsExceptable { - use HasExceptableInternals; - /** @see Exceptable::fromCase() $case */ - protected ErrorCase $case; + /** @see Exceptable::from() */ + public static function from(Error $e = null, array $context = [], Throwable $previous = null) : Exceptable { + assert(is_a(static::class, Exceptable::class, true)); + // @phan-suppress-next-line PhanUndeclaredConstantOfClass + $e ??= static::DEFAULT_ERROR; + assert($e instanceof Error); + // @todo Is [2] correct? + // @phan-suppress-next-line PhanTypeInstantiateTraitStaticOrSelf + $ex = new static($e, $context, $previous, 2); + assert($ex instanceof Exceptable); + + return $ex; + } - /** @see Exceptable::fromCase() $context */ - protected array $context = []; + /** + * Finds the previous-most exception from the given exception. + * + * @param Throwable $ex the exception to start from + * @return Throwable The root exception (may be the same as the starting exception) + */ + private static function findRoot(Throwable $ex) : Throwable { + $root = $ex; + while (($previous = $root->getPrevious()) !== null) { + $root = $previous; + } - /** @see Exceptable::fromCase() $previous */ - protected ? Throwable $previous = null; + return $root; + } - /** @see Exceptable::fromCase() */ - public static function fromCase(ErrorCase $case, array $context = [], ? Throwable $previous = null) : Exceptable { - assert(is_a(static::class, Exceptable::class, true)); - return (new static($case->message($this->context), $case->value, $previous))->adjust(); + /** @see Exceptable::context() */ + public function context() : array { + assert($this instanceof Throwable); + + return [ + "__message__" => $this->getMessage() + ] + $this->context; } - /** @see Exceptable::is() */ - public function is(ErrorCase $case) : bool { - return $this->case === $case; + /** @see Exceptable::error() */ + public function error() : Error { + return $this->error; } - /** @see Exceptable::case() */ - public function case() : ErrorCase { - return $this->case; + /** @see Exceptable::has() */ + public function has(Error $e) : bool { + $ex = $this; + while ($ex instanceof Exceptable) { + if ($ex->error === $e) { + return true; + } + + $ex = $ex->getPrevious(); + } + + return false; } - /** @see Exceptable::context() */ - public function context() : array { - return $this->context; + /** @see Exceptable::is() */ + public function is(Error $e) : bool { + return ($this->error ?? null) === $e; } /** @see Exceptable::root() */ public function root() : Throwable { assert($this instanceof Throwable); - return $this->findRoot($this); + + return static::findRoot($this); } - /** - * Finds the root (most-previous) exception of the given exception. - * - * @param Throwable $root Subject exception - * @return Throwable Root exception - */ - protected function findRoot(Throwable $root) : Throwable { - while (($previous = $root->getPrevious()) !== null) { - $root = $previous; + /** Nonpublic constructor. Use Exceptable::from(). */ + private function __construct( + protected Error $error, + protected array $context = [], + Throwable $previous = null, + int $adjust = 1 + ) { + assert($this instanceof Exceptable); + + if (! empty($previous)) { + $root = static::findRoot($previous); + $context["__rootType__"] = $root::class; + $context["__rootMessage__"] = $root->getMessage(); + $context["__rootCode__"] = $root->getCode(); } - return $root; - } -} - -/** Base implementation for Exceptable internal methods. */ -trait HasExceptableInternals { + // @phan-suppress-next-line PhanTraitParentReference + parent::__construct($this->error->message($context), $this->error->code(), $previous); - /** @see ExceptableInternals::throw() */ - public function adjust(int $frame = 0) : Exceptable { - $info = $this->getTrace()[$frame] ?? null; - if (isset($frame)) { - $this->file = $info["file"]; - $this->line = $info["line"]; + $frame = $this->getTrace()[$adjust] ?? null; + if (! empty($frame)) { + // @phan-suppress-next-line PhanUndeclaredProperty + $this->file = $frame["file"]; + // @phan-suppress-next-line PhanUndeclaredProperty + $this->line = $frame["line"]; } - - return $this; } } diff --git a/src/Spl/BadFunctionCallException.php b/src/Spl/BadFunctionCallException.php index 095f7c2..444ac89 100644 --- a/src/Spl/BadFunctionCallException.php +++ b/src/Spl/BadFunctionCallException.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -24,7 +24,8 @@ use at\exceptable\ { Exceptable, - IsExceptable + IsExceptable, + Spl\SplError }; /** @@ -34,15 +35,5 @@ class BadFunctionCallException extends SplBadFunctionCallException implements Exceptable { use IsExceptable; - /** @var int Bad function call. */ - public const BAD_FUNCTION_CALL = 0; - - /** @see IsExceptable::getInfo() */ - public const INFO = [ - self::BAD_FUNCTION_CALL => [ - "message" => "Bad function call", - "formatKey" => "exceptable.spl.badfunctioncall", - "format" => "Bad function call: {__rootMessage__}" - ] - ]; + public const DEFAULT_ERROR = SplError::BadFunctionCall; } diff --git a/src/Spl/BadMethodCallException.php b/src/Spl/BadMethodCallException.php index 7305790..aadd78b 100644 --- a/src/Spl/BadMethodCallException.php +++ b/src/Spl/BadMethodCallException.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -24,7 +24,8 @@ use at\exceptable\ { Exceptable, - IsExceptable + IsExceptable, + Spl\SplError }; /** @@ -34,15 +35,5 @@ class BadMethodCallException extends SplBadMethodCallException implements Exceptable { use IsExceptable; - /** @var int Bad method call. */ - public const BAD_METHOD_CALL = 0; - - /** @see IsExceptable::getInfo() */ - public const INFO = [ - self::BAD_METHOD_CALL => [ - "message" => "Bad method call", - "formatKey" => "exceptable.spl.badmethodcall", - "format" => "Bad method call: {__rootMessage__}" - ] - ]; + public const DEFAULT_ERROR = SplError::BadMethodCall; } diff --git a/src/Spl/DomainException.php b/src/Spl/DomainException.php index 355c4a8..c257978 100644 --- a/src/Spl/DomainException.php +++ b/src/Spl/DomainException.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -24,7 +24,8 @@ use at\exceptable\ { Exceptable, - IsExceptable + IsExceptable, + Spl\SplError }; /** @@ -34,15 +35,5 @@ class DomainException extends SplDomainException implements Exceptable { use IsExceptable; - /** @var int Domain error. */ - public const DOMAIN_ERROR = 0; - - /** @see IsExceptable::getInfo() */ - public const INFO = [ - self::DOMAIN_ERROR => [ - "message" => "Domain error", - "formatKey" => "exceptable.spl.domainerror", - "format" => "Domain error: {__rootMessage__}" - ] - ]; + public const DEFAULT_ERROR = SplError::Domain; } diff --git a/src/Spl/InvalidArgumentException.php b/src/Spl/InvalidArgumentException.php index 5384c35..ae0f8d0 100644 --- a/src/Spl/InvalidArgumentException.php +++ b/src/Spl/InvalidArgumentException.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -24,7 +24,8 @@ use at\exceptable\ { Exceptable, - IsExceptable + IsExceptable, + Spl\SplError }; /** @@ -34,15 +35,5 @@ class InvalidArgumentException extends SplInvalidArgumentException implements Exceptable { use IsExceptable; - /** @var int Invalid argument. */ - public const INVALID_ARGUMENT = 0; - - /** @see IsExceptable::getInfo() */ - public const INFO = [ - self::INVALID_ARGUMENT => [ - "message" => "Invalid argument", - "formatKey" => "exceptable.spl.invalidargument", - "format" => "Invalid argument: {__rootMessage__}" - ] - ]; + public const DEFAULT_ERROR = SplError::InvalidArgument; } diff --git a/src/Spl/LengthException.php b/src/Spl/LengthException.php index c24fc0b..cbff808 100644 --- a/src/Spl/LengthException.php +++ b/src/Spl/LengthException.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -24,7 +24,8 @@ use at\exceptable\ { Exceptable, - IsExceptable + IsExceptable, + Spl\SplError }; /** @@ -34,15 +35,5 @@ class LengthException extends SplLengthException implements Exceptable { use IsExceptable; - /** @var int Length error. */ - public const LENGTH_ERROR = 0; - - /** @see IsExceptable::getInfo() */ - public const INFO = [ - self::LENGTH_ERROR => [ - "message" => "Length error", - "formatKey" => "exceptable.spl.lengtherror", - "format" => "Length error: {__rootMessage__}" - ] - ]; + public const DEFAULT_ERROR = SplError::InvalidArgument; } diff --git a/src/Spl/LogicException.php b/src/Spl/LogicException.php index 153f8ad..a1c5b40 100644 --- a/src/Spl/LogicException.php +++ b/src/Spl/LogicException.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -24,7 +24,8 @@ use at\exceptable\ { Exceptable, - IsExceptable + IsExceptable, + Spl\SplError }; /** @@ -34,15 +35,5 @@ class LogicException extends SplLogicException implements Exceptable { use IsExceptable; - /** @var int Program logic error. */ - public const PROGRAM_LOGIC_ERROR = 0; - - /** @see IsExceptable::getInfo() */ - public const INFO = [ - self::PROGRAM_LOGIC_ERROR => [ - "message" => "Program logic error", - "formatKey" => "exceptable.spl.programlogicerror", - "format" => "Program logic error: {__rootMessage__}" - ] - ]; + public const DEFAULT_ERROR = SplError::Logic; } diff --git a/src/Spl/OutOfBoundsException.php b/src/Spl/OutOfBoundsException.php index b98aa03..f609ca3 100644 --- a/src/Spl/OutOfBoundsException.php +++ b/src/Spl/OutOfBoundsException.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -24,7 +24,8 @@ use at\exceptable\ { Exceptable, - IsExceptable + IsExceptable, + Spl\SplError }; /** @@ -34,15 +35,7 @@ class OutOfBoundsException extends SplOutOfBoundsException implements Exceptable { use IsExceptable; - /** @var int Out of bounds. */ - public const OUT_OF_BOUNDS = 0; - - /** @see IsExceptable::getInfo() */ - public const INFO = [ - self::OUT_OF_BOUNDS => [ - "message" => "Out of bounds", - "formatKey" => "exceptable.spl.outofbounds", - "format" => "Out of bounds: {__rootMessage__}" - ] - ]; + public const DEFAULT_ERROR = SplError::OutOfBounds; } + + diff --git a/src/Spl/OutOfRangeException.php b/src/Spl/OutOfRangeException.php index 9654ba7..eba2b53 100644 --- a/src/Spl/OutOfRangeException.php +++ b/src/Spl/OutOfRangeException.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -24,7 +24,8 @@ use at\exceptable\ { Exceptable, - IsExceptable + IsExceptable, + Spl\SplError }; /** @@ -34,15 +35,5 @@ class OutOfRangeException extends SplOutOfRangeException implements Exceptable { use IsExceptable; - /** @var int Out of range. */ - public const OUT_OF_RANGE = 0; - - /** @see IsExceptable::getInfo() */ - public const INFO = [ - self::OUT_OF_RANGE => [ - "message" => "Out of range", - "formatKey" => "exceptable.spl.outofrange", - "format" => "Out of range: {__rootMessage__}" - ] - ]; + public const DEFAULT_ERROR = SplError::OutOfRange; } diff --git a/src/Spl/OverflowException.php b/src/Spl/OverflowException.php index a779b00..0d431ae 100644 --- a/src/Spl/OverflowException.php +++ b/src/Spl/OverflowException.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -24,7 +24,8 @@ use at\exceptable\ { Exceptable, - IsExceptable + IsExceptable, + Spl\SplError }; /** @@ -34,15 +35,5 @@ class OverflowException extends SplOverflowException implements Exceptable { use IsExceptable; - /** @var int Overflow. */ - public const OVERFLOW = 0; - - /** @see IsExceptable::getInfo() */ - public const INFO = [ - self::OVERFLOW => [ - "message" => "Overflow", - "formatKey" => "exceptable.spl.overflow", - "format" => "Overflow: {__rootMessage__}" - ] - ]; + public const DEFAULT_ERROR = SplError::Overflow; } diff --git a/src/Spl/RangeException.php b/src/Spl/RangeException.php index 1bdc0f8..5825a85 100644 --- a/src/Spl/RangeException.php +++ b/src/Spl/RangeException.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -24,7 +24,8 @@ use at\exceptable\ { Exceptable, - IsExceptable + IsExceptable, + Spl\SplError }; /** @@ -34,15 +35,5 @@ class RangeException extends SplRangeException implements Exceptable { use IsExceptable; - /** @var int Out of range. */ - public const OUT_OF_RANGE = 0; - - /** @see IsExceptable::getInfo() */ - public const INFO = [ - self::OUT_OF_RANGE => [ - "message" => "Out of range", - "formatKey" => "exceptable.spl.outofrange", - "format" => "Out of range: {__rootMessage__}" - ] - ]; + public const DEFAULT_ERROR = SplError::Range; } diff --git a/src/Spl/RuntimeException.php b/src/Spl/RuntimeException.php index 74e41b3..4eb3e90 100644 --- a/src/Spl/RuntimeException.php +++ b/src/Spl/RuntimeException.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -24,7 +24,8 @@ use at\exceptable\ { Exceptable, - IsExceptable + IsExceptable, + Spl\SplError }; /** @@ -34,15 +35,5 @@ class RuntimeException extends SplRuntimeException implements Exceptable { use IsExceptable; - /** @var int Runtime error. */ - public const RUNTIME_ERROR = 0; - - /** @see IsExceptable::getInfo() */ - public const INFO = [ - self::RUNTIME_ERROR => [ - "message" => "Runtime error", - "formatKey" => "exceptable.spl.runtimeerror", - "format" => "Runtime error: {__rootMessage__}" - ] - ]; + public const DEFAULT_ERROR = SplError::Runtime; } diff --git a/src/Spl/SplError.php b/src/Spl/SplError.php new file mode 100644 index 0000000..6b46d41 --- /dev/null +++ b/src/Spl/SplError.php @@ -0,0 +1,122 @@ + + * @copyright 2014 - 2024 + * @license GPL-3.0 (only) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License, version 3. + * The right to apply the terms of later versions of the GPL is RESERVED. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ +declare(strict_types = 1); + +namespace at\exceptable\Spl; + +use Throwable; + +use at\exceptable\ { + Error, + Exceptable, + IsError, + Spl\BadFunctionCallException, + Spl\BadMethodCallException, + Spl\DomainException, + Spl\InvalidArgumentException, + Spl\LengthException, + Spl\LogicException, + Spl\OutOfBoundsException, + Spl\OutOfRangeException, + Spl\OverflowException, + Spl\RangeException, + Spl\RuntimeException, + Spl\UnderflowException, + Spl\UnexpectedValueException +}; + +/** + * Error cases corresponding to the Spl Exception types. + * + * @phan-suppress PhanInvalidConstantExpression + */ +enum SplError : int implements Error { + use IsError; + + case BadFunctionCall = 1; + case BadMethodCall = 2; + case Domain = 3; + case InvalidArgument = 4; + case Length = 5; + case Logic = 6; + case OutOfBounds = 7; + case OutOfRange = 8; + case Overflow = 9; + case Range = 10; + case Runtime = 11; + case Underflow = 12; + case UnexpectedValue = 13; + + /** @see Error::MESSAGES */ + protected const MESSAGES = [ + self::class => [ + self::BadFunctionCall->value => "{__rootMessage__}", + self::BadMethodCall->value => "{__rootMessage__}", + self::Domain->value => "{__rootMessage__}", + self::InvalidArgument->value => "{__rootMessage__}", + self::Length->value => "{__rootMessage__}", + self::Logic->value => "{__rootMessage__}", + self::OutOfBounds->value => "{__rootMessage__}", + self::OutOfRange->value => "{__rootMessage__}", + self::Overflow->value => "{__rootMessage__}", + self::Range->value => "{__rootMessage__}", + self::Runtime->value => "{__rootMessage__}", + self::Underflow->value => "{__rootMessage__}", + self::UnexpectedValue->value => "{__rootMessage__}" + ] + ]; + + /** @see Error::exceptable() */ + public function exceptable(array $context = [], Throwable $previous = null) : Exceptable { + assert($this instanceof Error); + return match ($this) { + self::BadFunctionCall => BadFunctionCallException::from($this, $context, $previous), + self::BadMethodCall => BadMethodCallException::from($this, $context, $previous), + self::Domain => DomainException::from($this, $context, $previous), + self::InvalidArgument => InvalidArgumentException::from($this, $context, $previous), + self::Length => LengthException::from($this, $context, $previous), + self::Logic => LogicException::from($this, $context, $previous), + self::OutOfBounds => OutOfBoundsException::from($this, $context, $previous), + self::OutOfRange => OutOfRangeException::from($this, $context, $previous), + self::Overflow => OverflowException::from($this, $context, $previous), + self::Range => RangeException::from($this, $context, $previous), + self::Runtime => RuntimeException::from($this, $context, $previous), + self::Underflow => UnderflowException::from($this, $context, $previous), + self::UnexpectedValue => UnexpectedValueException::from($this, $context, $previous), + default => RuntimeException::from($this, $context, $previous) + }; + + // return match ($this) { + // self::BadFunctionCall => (fn () => new BadFunctionCallException($this, $context, $previous, 2))->call($e, $e), + // self::BadMethodCall => (fn () => new BadMethodCallException($this, $context, $previous, 2))->call($e, $e), + // self::Domain => (fn () => new DomainException($this, $context, $previous, 2))->call($e, $e), + // self::InvalidArgument => (fn () => new InvalidArgumentException($this, $context, $previous, 2))->call($e, $e), + // self::Length => (fn () => new LengthException($this, $context, $previous, 2))->call($e, $e), + // self::Logic => (fn () => new LogicException($this, $context, $previous, 2))->call($e, $e), + // self::OutOfBounds => (fn () => new OutOfBoundsException($this, $context, $previous, 2))->call($e, $e), + // self::OutOfRange => (fn () => new OutOfRangeException($this, $context, $previous, 2))->call($e, $e), + // self::Overflow => (fn () => new OverflowException($this, $context, $previous, 2))->call($e, $e), + // self::Range => (fn () => new RangeException($this, $context, $previous, 2))->call($e, $e), + // self::Runtime => (fn () => new RuntimeException($this, $context, $previous, 2))->call($e, $e), + // self::Underflow => (fn () => new UnderflowException($this, $context, $previous, 2))->call($e, $e), + // self::UnexpectedValue => (fn () => new UnexpectedValueException($this, $context, $previous, 2))->call($e, $e), + // default => RuntimeException::from($this, $context, $previous) + // }; + } +} diff --git a/src/Spl/UnderflowException.php b/src/Spl/UnderflowException.php index 095ac00..4e741c4 100644 --- a/src/Spl/UnderflowException.php +++ b/src/Spl/UnderflowException.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -24,7 +24,8 @@ use at\exceptable\ { Exceptable, - IsExceptable + IsExceptable, + Spl\SplError }; /** @@ -34,15 +35,5 @@ class UnderflowException extends SplUnderflowException implements Exceptable { use IsExceptable; - /** @var int Underflow. */ - public const UNDERFLOW = 0; - - /** @see IsExceptable::getInfo() */ - public const INFO = [ - self::UNDERFLOW => [ - "message" => "Underflow", - "formatKey" => "exceptable.spl.underflow", - "format" => "Underflow: {__rootMessage__}" - ] - ]; + public const DEFAULT_ERROR = SplError::Underflow; } diff --git a/src/Spl/UnexpectedValueException.php b/src/Spl/UnexpectedValueException.php index 5f7aa68..da517ec 100644 --- a/src/Spl/UnexpectedValueException.php +++ b/src/Spl/UnexpectedValueException.php @@ -2,7 +2,7 @@ /** * @package at.exceptable * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -24,7 +24,8 @@ use at\exceptable\ { Exceptable, - IsExceptable + IsExceptable, + Spl\SplError }; /** @@ -34,15 +35,5 @@ class UnexpectedValueException extends SplUnexpectedValueException implements Exceptable { use IsExceptable; - /** @var int Unexpected value. */ - public const UNEXPECTED_VALUE = 0; - - /** @see IsExceptable::getInfo() */ - public const INFO = [ - self::UNEXPECTED_VALUE => [ - "message" => "Unexpected value", - "formatKey" => "exceptable.spl.unexpectedvalue", - "format" => "Unexpected value: {__rootMessage__}" - ] - ]; + public const DEFAULT_ERROR = SplError::UnexpectedValue; } diff --git a/tests/ErrorCaseTest.php b/tests/ErrorCaseTest.php new file mode 100644 index 0000000..9d0eb0b --- /dev/null +++ b/tests/ErrorCaseTest.php @@ -0,0 +1,201 @@ + + * @copyright 2014 - 2023 + * @license GPL-3.0 (only) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License, version 3. + * The right to apply the terms of later versions of the GPL is RESERVED. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ +declare(strict_types = 1); + +namespace at\exceptable\Tests; + +use Exception, + ResourceBundle, + Throwable; + +use at\exceptable\ { + Exceptable, + ExceptableError, + IsExceptable, + Tests\TestCase +}; + +/** + * Basic tests for the default ErrorCase implementations. + * + * @covers at\exceptable\IsErrorCase + * @covers at\exceptable\IsBackedErrorCase + * + * This test case can (should) be extended to test other concrete implementations: + * - override errorCase() to provide the ErrorCase to test + * - override *Provider() methods to provide appropriate input and expectations + */ +class ErrorCaseTest extends TestCase { + + /** @see testExceptableFrom() */ + public function exceptableFromProvider() : array { + + } + + public function testExceptableFrom( + ErrorCase $error, + ? array $context, + ? Throwable $previous, + Exceptable $expected + ) { + $line = __LINE__ + 1; + $actual = $error->from($context, $previous); + + $this->assertExceptableIsExceptable($actual, $expected); + $this->assertExceptableOrigination($actual, __FILE__, $line); + $this->assertExceptableHasCode($actual, $expected->getCode()); + $this->assertExceptableHasMessage($actual, $expected->getMessage()); + $this->assertExceptableHasCase($actual, $error); + $this->assertExceptableHasContext($actual, $expected->getContext()); + $this->assertExceptableHasPrevious($actual, $expected->getPrevious()); + $this->assertExceptableHasRoot($actual, $expected->getPrevious() ?? $actual); + } + + /** + * Asserts test subject is an instance of Exceptable and of the given FQCN. + * + * @param mixed $actual Test subject + * @param string $fqcn Fully-qualified classname of the intended Exceptable + */ + protected function assertExceptableIsExceptable($actual, string $fqcn) : void { + $this->assertInstanceOf(Exceptable::class, $actual, "Exceptable is not Exceptable"); + $this->assertInstanceOf($fqcn, $actual, "Exceptable is not an instance of {$fqcn}"); + } + + /** + * Asserts test subject has the expected origin file and line number. + * + * @param mixed $actual Test subject + * @param string $file Expected filename + * @param int $line Expected line number + */ + protected function assertExceptableOrigination(Exceptable $actual, string $file, int $line) : void { + $this->assertSame( + $file, + $actual->getFile(), + "Exceptable does not report expected filename '{$file}'" + ); + $this->assertSame( + $line, + $actual->getLine(), + "Exceptable does not report expected line number '{$line}'" + ); + } + + /** + * Asserts test subject has the expected error case. + * + * @param mixed $actual Test subject + * @param int $case Expected error case + */ + protected function assertExceptableHasCase(Exceptable $actual, ErrorCase $case) : void { + $this->assertSame( + $code, + $actual->case(), + "Exceptable does not report expected case '{$case->name}'" + ); + } + + /** + * Asserts test subject has the expected code. + * + * @param mixed $actual Test subject + * @param int $code Expected exceptable code + */ + protected function assertExceptableHasCode(Exceptable $actual, int $code) : void { + $this->assertSame( + $code, + $actual->getCode(), + "Exceptable does not report expected code '{$code}'" + ); + } + + + /** + * Asserts test subject has the expected (possibly formatted) message. + * + * @param mixed $actual Test subject + * @param string #message Expected exceptable message + */ + protected function assertExceptableHasMessage(Exceptable $actual, string $message) : void { + $this->assertSame( + $message, + $actual->getMessage(), + "Exceptable does not report expected message '{$message}'" + ); + } + + /** + * Asserts test subject has the expected contextual information. + * + * @param mixed $actual Test subject + * @param ?array $context Expected contextual information + */ + protected function assertExceptableHasContext(Exceptable $actual, ? array $context) : void { + $actual = $actual->getContext(); + + $this->assertArrayHasKey( + "__rootMessage__", + $actual, + "getContext()[___rootMessage_] is missing" + ); + $this->assertIsString($actual["__rootMessage__"]); + + if (isset($context)) { + foreach ($context as $key => $value) { + $this->assertArrayHasKey($key, $actual, "getContext()[{$key}] is missing"); + + $this->assertSame( + $value, + $actual[$key], + "getContext()[{$key}] does not hold expected value ({$this->asString($value)})" + ); + } + } + } + + /** + * Asserts test subject has the expected previous Exception. + * + * @param mixed $actual Test subject + * @param ?Throwable $previous Expected previous exception + */ + protected function assertExceptableHasPrevious(Exceptable $actual, ?Throwable $previous) : void { + $message = isset($previous) ? + "getPrevious() does not report expected exception (" . get_class($previous) . ")" : + "getPrevious() reports a previous exception but none was expected"; + $this->assertSame($previous, $actual->getPrevious(), $message); + } + + /** + * Asserts test subject has the expected root Exception. + * + * @param mixed $actual Test subject + * @param ?Throwable $root Expected root (most-previous) exception + */ + protected function assertExceptableHasRoot(Exceptable $actual, Throwable $root) : void { + $fqcn = get_class($root); + $this->assertSame( + $root, + $actual->getRoot(), + "getPrevious() does not report expected root exception ({$fqcn})" + ); + } +} diff --git a/tests/HandlerTest.php b/tests/HandlerTest.php index 1983f9f..0561496 100644 --- a/tests/HandlerTest.php +++ b/tests/HandlerTest.php @@ -3,7 +3,7 @@ * @package at.exceptable * @subpackage tests * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2023 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it diff --git a/tests/IsExceptableTest.php b/tests/IsExceptableTest.php index b82c9f1..8d96fac 100644 --- a/tests/IsExceptableTest.php +++ b/tests/IsExceptableTest.php @@ -3,7 +3,7 @@ * @package at.exceptable * @subpackage tests * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2023 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it diff --git a/tests/TestCase.php b/tests/TestCase.php index f1d159b..89f5a7e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,7 +3,7 @@ * @package at.exceptable * @subpackage tests * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2023 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it diff --git a/tests/stubs.php b/tests/stubs.php index 6bfabbd..19f5514 100644 --- a/tests/stubs.php +++ b/tests/stubs.php @@ -3,7 +3,7 @@ * @package at.exceptable * @subpackage tests * @author Adrian - * @copyright 2014 - 2020 + * @copyright 2014 - 2023 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it From 6fd23b7f3920962ad6450677a1bf1f27e25c1ce3 Mon Sep 17 00:00:00 2001 From: Adrian Date: Sat, 2 Mar 2024 00:08:17 -0500 Subject: [PATCH 03/13] handler, result --- .phan/config.php | 5 +- README.md | 66 ++-- composer.json | 5 +- phpunit.xml | 12 +- src/Error.php | 25 +- src/Exceptable.php | 5 +- src/ExceptableError.php | 22 +- src/Handler.php | 490 ------------------------------ src/Handler/ErrorHandler.php | 36 +++ src/Handler/ErrorLogEntry.php | 38 +++ src/Handler/ExceptionHandler.php | 35 +++ src/Handler/ExceptionLogEntry.php | 47 +++ src/Handler/Handler.php | 435 ++++++++++++++++++++++++++ src/Handler/LogEntry.php | 63 ++++ src/Handler/Options.php | 34 +++ src/Handler/ShutdownHandler.php | 31 ++ src/IsError.php | 45 ++- src/IsExceptable.php | 89 +++--- src/Result.php | 137 +++++++++ src/Spl/SplError.php | 75 ++--- 20 files changed, 1039 insertions(+), 656 deletions(-) delete mode 100644 src/Handler.php create mode 100644 src/Handler/ErrorHandler.php create mode 100644 src/Handler/ErrorLogEntry.php create mode 100644 src/Handler/ExceptionHandler.php create mode 100644 src/Handler/ExceptionLogEntry.php create mode 100644 src/Handler/Handler.php create mode 100644 src/Handler/LogEntry.php create mode 100644 src/Handler/Options.php create mode 100644 src/Handler/ShutdownHandler.php create mode 100644 src/Result.php diff --git a/.phan/config.php b/.phan/config.php index caa872d..3f91365 100644 --- a/.phan/config.php +++ b/.phan/config.php @@ -1,6 +1,7 @@ ['src', 'vendor'], - 'exclude_analysis_directory_list' => ['vendor'] + "directory_list" => ["src", "vendor"], + "exclude_analysis_directory_list" => ["vendor"], + "exclude_file_list" => ["vendor/php-enspired/peekaboo/stubs/exceptable.php"] ]; diff --git a/README.md b/README.md index cd8a51c..b38eac8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![](https://img.shields.io/github/release/php-enspired/exceptable.svg) ![](https://img.shields.io/badge/PHP-7.4-blue.svg?colorB=8892BF) ![](https://img.shields.io/badge/PHP-8-blue.svg?colorB=8892BF) ![](https://img.shields.io/badge/license-GPL_3.0_only-blue.svg) +![](https://img.shields.io/github/release/php-enspired/exceptable.svg) ![](https://img.shields.io/badge/PHP-8.2-blue.svg?colorB=8892BF) ![](https://img.shields.io/badge/PHP-8-blue.svg?colorB=8892BF) ![](https://img.shields.io/badge/license-GPL_3.0_only-blue.svg) how exceptable! =============== @@ -10,7 +10,7 @@ Exceptables are easy to create and pass details to, they provide access to error dependencies ------------ -Requires php 7.4 or later. +Requires php 8.2 or later. ICU support requires the `intl` extension. @@ -24,58 +24,56 @@ a quick taste ```php [ - 'message' => 'unknown foo', - 'format' => "i don't know who, you think is foo, but it's not {foo}" - ] + case UnknownFoo = 1; + public const MESSAGES = [ + self::UnknownFoo->name => "i don't know who, you think is foo, but it's not {foo}" ]; } -throw new FooException(FooException::UNKNOWN_FOO); +(FooError::UnknownFoo)(["foo" => "foobedobedoo"]); // on your screen: -// Fatal error: Uncaught FooException: unknown foo in ... +// Fatal error: Uncaught at\exceptable\Spl\RuntimeException: i don't know who, you think is foo, but it's not foobedobedoo $handler = new Handler(); -$handler - ->onException(function($e) { error_log($e->getMessage()); return true; }) +$handler->onException(function($e) { error_log($e->getMessage()); return true; }) ->register(); -$context = ['foo' => 'foobedobedoo']; -throw new FooException(FooException::UNKNOWN_FOO, $context); +(FooError::UnknownFoo)(["foo" => "foobedobedoo"]); // in your error log: // i don't know who, you think is foo, but it's not foobedobedoo ``` see more in [the wiki](https://github.com/php-enspired/exceptable/wiki). -Version 4.0 is here! --------------------- +Version 5.0 +----------- -**Version 4.0** requires PHP 7.4 or greater. -- PHP 7.4 added typehints to some Throwable properties, which required changes to the `IsExceptable` trait. This means _Exceptable_ can no longer support PHP 7.3 or earlier - though that's fine, right? You've already upgraded your application to 8+ anyway, right? -- right? +**Version 5** requires PHP 8.2 or greater. +- ICU messaging system overhauled and published to its own package! +- Introduces _Error enums_, making errors into first-class citizens and opening up the ability to handle errors as values. + Also introduces a `Return` class, which lets you handle success/error values by returning them up the chain. +- Reworks and improves functionality for Exceptables and the Handler. -Version 3.0 is here! --------------------- +[Read the release notes.](https://github.com/php-enspired/exceptable/wiki/new-in-5.0) -**Version 3.0** requires PHP 7.3 or greater and introduces some exciting changes from version 2: -- Support* for ICU locales, message formats, and resource bundles!\ - \* _requires the intl extension._ -- Ready-to-extend (or just use) `Exceptable` classes based on the built-in SPL Exception classes! -- The generic `Exceptable` Exception base class has been removed. -- Introduces a "debug mode" for Handlers! -- Handlers are now Logger (e.g., Monolog)-aware! +Version 4.0 +----------- -[Read more about the 3.0 release](https://github.com/php-enspired/exceptable/wiki/new-in-3.0). +**Version 4.0** requires PHP 7.4 or greater. +- PHP 7.4 added typehints to some Throwable properties, which required changes to the `IsExceptable` trait. + This means _Exceptable_ can no longer support PHP 7.3 or earlier - though that's fine, right? + You've already upgraded your application to 8+ anyway, right? +- right? docs ---- diff --git a/composer.json b/composer.json index 8a1a074..8c46559 100644 --- a/composer.json +++ b/composer.json @@ -18,10 +18,11 @@ "source": "https://github.com/php-enspired/exceptable" }, "require": { - "php": "^8.2" + "php": "^8.2", + "php-enspired/peekaboo": "^1" }, "suggest": { - "ext-intl": "Adds support for localization and ICU message formatting." + "ext-intl": "support for localization and full ICU message formatting features" }, "require-dev": { "phan/phan": "^5.4.3", diff --git a/phpunit.xml b/phpunit.xml index 06aa979..604c611 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,11 +1,13 @@ - - + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" + colors="true" + cacheDirectory=".phpunit.cache"> + ./src - + diff --git a/src/Error.php b/src/Error.php index a14e242..b621b2f 100644 --- a/src/Error.php +++ b/src/Error.php @@ -19,12 +19,16 @@ declare(strict_types = 1); namespace at\exceptable; -use Throwable; +use Throwable, + UnitEnum; use at\peekaboo\HasMessages; /** Defines error cases for use by an Exceptable, or as a standalone error value. */ -interface Error extends HasMessages { +interface Error extends HasMessages, UnitEnum { + + /** @see Error::newExceptable() */ + public function __invoke(array $context = [], Throwable $previous = null) : Exceptable; /** * Gets the error code for this case. @@ -33,14 +37,6 @@ interface Error extends HasMessages { */ public function code() : int; - /** - * Creates an Exceptable from this Error case. - * - * @param array $context Additional exception context - * @param ?Throwable $previous Previous exception, if any - */ - public function exceptable(array $context = [], Throwable $previous = null) : Exceptable; - /** * Gets the error message for this case, using the given context. * @@ -48,4 +44,13 @@ public function exceptable(array $context = [], Throwable $previous = null) : Ex * @return string An error message */ public function message(array $context) : string; + + /** + * Creates an Exceptable from this Error case. + * + * @param array $context Additional exception context + * @param ?Throwable $previous Previous exception, if any + * @return Exceptable A new Exceptable using this Error case + */ + public function newExceptable(array $context = [], Throwable $previous = null) : Exceptable; } diff --git a/src/Exceptable.php b/src/Exceptable.php index 51bc730..e4bbdd9 100644 --- a/src/Exceptable.php +++ b/src/Exceptable.php @@ -36,6 +36,7 @@ * - implementations cannot extend from PDOException, * because it breaks the Throwable interface (its getCode() returns a string). * + * @property $error * @phan-suppress PhanCommentObjectInClassConstantType */ interface Exceptable extends Throwable { @@ -49,14 +50,12 @@ interface Exceptable extends Throwable { public const DEFAULT_ERROR = ExceptableError::UnknownError; /** - * Creates a new Exceptable from the given Error case. - * * @param ?Error $e The Error case to build from * @param array $context Additional exception context * @param ?Throwable $previous Previous exception, if any * @return Exceptable A new Exceptable on success */ - public static function from(Error $e = null, array $context = [], Throwable $previous = null) : Exceptable; + public function __construct(Error $e = null, array $context = [], Throwable $previous = null); /** * Gets contextual information about this Exceptable. diff --git a/src/ExceptableError.php b/src/ExceptableError.php index db1d029..4992ef6 100644 --- a/src/ExceptableError.php +++ b/src/ExceptableError.php @@ -20,9 +20,13 @@ namespace at\exceptable; +use Throwable; + use at\exceptable\ { Error, - IsError + IsError, + Spl\LogicException, + Spl\RuntimeException }; /** @@ -35,14 +39,26 @@ enum ExceptableError : int implements Error { case UnacceptableError = 0; case UncaughtException = 1; case UnknownError = 2; + case HandlerFailed = 3; /** @see MakesMessages::MESSAGES */ - protected const MESSAGES = [ + public const MESSAGES = [ self::class => [ self::UnacceptableError->name => "Invalid Error type '{type}' (expected enum implementing " . Error::class . ")", self::UncaughtException->name => "Uncaught Exception ({__rootType__}): {__rootMessage__}", - self::UnknownError->name => "{__rootMessage__}" + self::UnknownError->name => "{__rootMessage__}", + self::HandlerFailed->name => "ExceptionHandler ({type}) failed: {__rootMessage__}" ] ]; + + /** @see Error::exceptable() */ + public function newExceptable(array $context = [], Throwable $previous = null) : Exceptable { + assert($this instanceof Error); + return match ($this) { + self::UnacceptableError, self::HandlerFailed => new LogicException($this, $context, $previous), + self::UncaughtException, self::UnknownError => new RuntimeException($this, $context, $previous), + default => new RuntimeException($this, $context, $previous) + }; + } } diff --git a/src/Handler.php b/src/Handler.php deleted file mode 100644 index b56a0d1..0000000 --- a/src/Handler.php +++ /dev/null @@ -1,490 +0,0 @@ - - * @copyright 2014 - 2024 - * @license GPL-3.0 (only) - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License, version 3. - * The right to apply the terms of later versions of the GPL is RESERVED. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with this program. - * If not, see . - */ -declare(strict_types = 1); - -namespace at\exceptable; - -use ErrorException, - Exception, - Throwable; - -use at\exceptable\ExceptableError; - -use Psr\Log\ { - LoggerAwareInterface as LoggerAware, - LoggerInterface as Logger, - LogLevel -}; - -/** - * Exceptable Handler - */ -class Handler implements LoggerAware { - - /** @var bool Debug mode? */ - protected $debug = false; - - /** @var callable[][] List of registered error handlers, grouped by error type. */ - protected $errorHandlers = []; - - /** @var array[] List of errors/exceptions encountered in debug mode. */ - protected $errors = []; - - /** - * @var callable[][][] $exceptionHandlers List of registered exception handlers, - * indexed by Throwable FQCN and code to catch. - */ - protected $exceptionHandlers = []; - - /** @var Logger Logging mechanism. */ - protected $logger; - - /** @var array[] List of registered shutdown handlers. */ - protected $shutdownHandlers = []; - - /** @var bool Is this Handler currently registered (active)? */ - protected $registered = false; - - /** @var bool Ignore the error control operator? */ - protected $scream = false; - - /** @var int Error types which should be thrown as ErrorExceptions. */ - protected $throw = 0; - - /** - * Sets debug mode (tracks and traces all errors and exceptions). - * - * @param bool $debug Debug? - * @return Handler $this - */ - public function debug(bool $debug = true) : Handler { - $this->debug = $debug; - - return $this; - } - - /** - * Gets a list of errors/exceptions encountered while debug mode was active. - * - * @return array[] - */ - public function getDebugLog() : array { - return $this->errors; - } - - /** - * Registers this handler to invoke a callback, and then restores the previous handler(s). - * - * @param callable $callback The callback to execute - * @param mixed ...$arguments Arguments to pass to the callback - * @return mixed The value returned from the callback - */ - public function handle(callable $callback, ...$arguments) { - $registered = $this->registered; - if (! $registered) { - $this->register(); - } - - $value = $callback(...$arguments); - - if (! $registered) { - $this->unregister(); - } - - return $value; - } - - /** - * Handles php errors. - * - * @param int $c Error code - * @param string $m Error message - * @param string $f Error file - * @param int $l Error line - * @throws ErrorException If error severity matches $throw setting - * @return bool True if error handled; false if php's error handler should continue - */ - public function handleError(int $c, string $m, string $f, int $l) : bool { - $this->throwIfMatches($c, $m, $f, $l); - - $error = [ - "code" => $c, - "message" => $m, - "file" => $f, - "line" => $l - ]; - - if (! $this->scream && error_reporting() === 0) { - $this->logError(true, $error); - return true; - } - - foreach ($this->errorHandlers as $severity => $handlers) { - if (($c & $severity) === $c) { - foreach ($handlers as $handler) { - if ($handler($c, $m, $f, $l) === true) { - $this->logError(true, $error); - return true; - } - } - } - } - - $this->logError(false, $error); - return false; - } - - /** - * Handles uncaught exceptions. - * - * @param Throwable $e The uncaught exception - * @throws ExceptableError If no registered handler handles the exception - */ - public function handleException(Throwable $e) : void { - $type = get_class($e); - $code = $e->getCode(); - - // exact type and code - foreach ($this->exceptionHandlers[$type][$code] ?? [] as $handler) { - if ($this->runExceptionHandler($handler, $e)) { - return; - } - } - - // work up inheritance chain with catch-all code - do { - foreach ($this->exceptionHandlers[$type][0] ?? [] as $handler) { - if ($this->runExceptionHandler($handler, $e)) { - return; - } - } - - $type = get_parent_class($type); - } while (! empty($type)); - - // try any lowest-level catch-all handlers - foreach ($this->exceptionHandlers[Throwable::class][0] ?? [] as $handler) { - if ($this->runExceptionHandler($handler, $e)) { - return; - } - } - - $this->logException(false, $e); - throw new ExceptableError(ExceptableError::UNCAUGHT_EXCEPTION, [], $e); - } - - /** - * Handles shutdown sequence; - * triggers errorHandler() if shutdown was due to a fatal error. - */ - public function handleShutdown() : void { - if (! $this->registered) { - return; - } - - $e = error_get_last(); - if ($e && $e['type'] === E_ERROR) { - $this->handleError($e['type'], $e['message'], $e['file'], $e['line']); - } - - foreach ($this->shutdownHandlers as [$handler, $arguments]) { - $handler(...$arguments); - } - } - - /** - * Adds an error handler. - * @see $error_handler - * - * @param callable $handler The error handler to add - * @param int $types The error type(s) to trigger this handler - * (bitmask of E_* constants) - * @return Handler $this - */ - public function onError(callable $handler, int $types) : Handler { - $this->errorHandlers[$types][] = $handler; - - return $this; - } - - /** - * Adds an exception handler. - * @see $exception_handler - * - * @param callable $handler The exception handler to add - * @param string|null $catch Exception FQCN this handler should handle (defaults to any) - * @param int $code Exception code this handler should handle (defaults to any) - * @return Handler $this - */ - public function onException(callable $handler, string $catch = null, int $code = 0) : Handler { - $this->exceptionHandlers[$catch ?? Throwable::class][$code][] = $handler; - - return $this; - } - - /** - * Adds a shutdown handler. - * @see $callback - * - * @param callable $handler The shutdown handler to add - * @param mixed ...$arguments Optional agrs to pass to shutdown handler when invoked - * @return Handler $this - */ - public function onShutdown(callable $handler, ...$arguments) : Handler { - $this->shutdownHandlers[] = [$handler, $arguments]; - - return $this; - } - - /** - * Registers this Handler's error, exception, and shutdown handlers. - * - * @return Handler $this - */ - public function register() : Handler { - set_error_handler([$this, 'handleError'], -1); - set_exception_handler([$this, 'handleException']); - register_shutdown_function([$this, 'handleShutdown']); - $this->registered = true; - - return $this; - } - - /** - * Sets whether the error control operator should be ignored. - * - * @param bool $scream Ignore the error control operator? - * @return Handler $this - */ - public function scream(bool $scream) : Handler { - $this->scream = $scream; - - return $this; - } - - /** @see https://www.php-fig.org/psr/psr-3/#4-psrlogloggerawareinterface */ - public function setLogger(Logger $logger) : void { - $this->logger = $logger; - } - - /** - * Sets error types which should be thrown as ErrorExceptions. - * - * @param int $types The error types to be thrown - * (defaults to E_ERROR|E_WARNING; use 0 to stop throwing) - * @return Handler $this - */ - public function throwErrors(int $types = E_ERROR | E_WARNING) : Handler { - $this->throw = $types; - - return $this; - } - - /** - * Invokes a callback and handles any exceptions using registered handlers. - * - * Will throw ErrorExceptions during invocation even if the instance is not registered. - * - * @param callable $callback The callback to execute - * @param mixed ...$arguments Arguments to pass to the callback - * @throws ExceptableError If an exception is thrown and no registered handler handles it - * @return mixed The value returned from the callback on success - */ - public function try(callable $callback, ...$arguments) { - try { - $throwErrorExceptions = $this->throw > 0 && ! $this->registered; - if ($throwErrorExceptions) { - set_error_handler([$this, "throwIfMatches"], $this->throw); - } - return $callback(...$arguments); - } catch (Throwable $e) { - $this->handleException($e); - return null; - } finally { - if ($throwErrorExceptions) { - restore_error_handler(); - } - } - } - - /** - * Un-registers this Handler. - * - * @return Handler $this - */ - public function unregister() : Handler { - restore_error_handler(); - restore_exception_handler(); - // shutdown functions can't be unregistered; just have to flag them so they're non-ops :( - $this->registered = false; - - return $this; - } - - /** - * Determines target logging level for a given error code. - * - * @param int $code Error code - * @return string One of the LogLevel::* constants - */ - protected function getLogLevel(int $code) : string { - return [ - E_ERROR => LogLevel::CRITICAL, - E_USER_ERROR => LogLevel::CRITICAL, - E_WARNING => LogLevel::WARNING, - E_USER_WARNING => LogLevel::WARNING, - E_PARSE => LogLevel::ERROR, - E_NOTICE => LogLevel::NOTICE, - E_USER_NOTICE => LogLevel::NOTICE, - E_STRICT => LogLevel::DEBUG, - E_RECOVERABLE_ERROR => LogLevel::CRITICAL, - E_DEPRECATED => LogLevel::INFO, - E_USER_DEPRECATED => LogLevel::INFO - ][$code] ?? LogLevel::NOTICE; - } - - /** - * Logs an error according to debug settings and logger availability. - * - * The following information is passed to the logger: - * - float $time Unixtime error was logged, with microsecond precision - * - string $type Always "error" - * - bool $handled Was this error handled by a registered handler? - * - bool $controlled Was this error suppressed by the error control operator? - * - int $code Error code - * - string $message Error message - * - string $file File - * - int $line Line - * If debug mode is enabled, a backtrace is added as well: - * - array $trace Backtrace - * - * @param bool $handled Was this error handled by a registered handler? - * @param array $error Error details - */ - protected function logError(bool $handled, array $error) : void { - $error = [ - "time" => microtime(true), - "type" => "error", - "handled" => $handled, - "controled" => error_reporting() === 0 - ] + $error + [ - "code" => 0, - "message" => "unknown error", - "file" => "unknown", - "line" => 0 - ]; - - if ($this->debug) { - $error["trace"] = debug_backtrace(); - $this->errors[] = $error; - } - - if (isset($this->logger)) { - if ($this->debug) { - $this->logger->log(LogLevel::DEBUG, $error["message"], $error); - } - - if (! $handled) { - $this->logger->log( - $this->getLogLevel($error["code"]), - $error["message"], - $error - ); - } - } - } - - /** - * Logs an exception according to debug settings and logger availability. - * - * The following information is passed to the logger: - * - float $time Unixtime error was logged, with microsecond precision - * - string $type Always "exception" - * - bool $handled Was this error handled by a registered handler? - * - int $exception Exception - * - * @param bool $handled Was this exception handled by a registered handler? - * @param Throwable $e The exception instance - */ - protected function logException(bool $handled, Throwable $e) : void { - $error = [ - "time" => microtime(true), - "type" => "exception", - "handled" => $handled, - "exception" => $e - ]; - - if ($this->debug) { - $this->errors[] = $error; - } - - if (isset($this->logger)) { - if ($this->debug) { - $this->logger->log(LogLevel::DEBUG, $e->getMessage(), $error); - } - - if(! $handled) { - $this->logger->log(LogLevel::CRITICAL, $e->getMessage(), $error); - } - } - } - - /** - * Invokes an exception handler. - * - * @param callable $handler The handler to invoke - * @param Throwable $e The exception to handle - * @return bool True if handler ran successfully; false otherwise - * @throws ExceptableError INVALID_HANDLER if the handler errors - */ - protected function runExceptionHandler(callable $handler, Throwable $e) : bool { - try { - if ($handler($e) === true) { - if ($this->debug) { - $this->logException(true, $e); - } - - return true; - } - - return false; - } catch (Throwable $x) { - ExceptableError::throw( - ExceptableError::INVALID_HANDLER, - ["type" => gettype($handler), "unhandled" => $e], - $x - ); - } - } - - /** - * Throws an ErrorException if the given error code matches $throw setting. - * - * @param int $c Error code - * @param string $m Error message - * @param string $f Error file - * @param int $l Error line - * @throws ErrorException If error severity matches $throw setting - */ - protected function throwIfMatches(int $c, string $m, string $f, int $l) : void { - if (($c & $this->throw) === $c) { - throw new ErrorException($m, $c, $c, $f, $l); - } - } -} diff --git a/src/Handler/ErrorHandler.php b/src/Handler/ErrorHandler.php new file mode 100644 index 0000000..6daa760 --- /dev/null +++ b/src/Handler/ErrorHandler.php @@ -0,0 +1,36 @@ + + * @copyright 2014 - 2024 + * @license GPL-3.0 (only) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License, version 3. + * The right to apply the terms of later versions of the GPL is RESERVED. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ +declare(strict_types = 1); + +namespace at\exceptable\Handler; + +/** Interface for use with Handler->onError(). */ +interface ErrorHandler { + + /** + * Invoked by a registered Handler when an error is triggered. + * + * @param int $c Error code + * @param string $m Error message + * @param string $f Error file + * @param int $l Error line + * @return bool True if error handled; false if php's error handler should continue + */ + public function run(int $c, string $m, string $f, int $l) : bool; +} diff --git a/src/Handler/ErrorLogEntry.php b/src/Handler/ErrorLogEntry.php new file mode 100644 index 0000000..abfaf95 --- /dev/null +++ b/src/Handler/ErrorLogEntry.php @@ -0,0 +1,38 @@ + + * @copyright 2014 - 2024 + * @license GPL-3.0 (only) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License, version 3. + * The right to apply the terms of later versions of the GPL is RESERVED. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ +declare(strict_types = 1); + +namespace at\exceptable\Handler; + +use at\exceptable\Handler\LogEntry; + +/** Log entry for errors. */ +class ErrorLogEntry extends LogEntry { + + /** @var bool Was this error suppressed by the error control operator? */ + public bool $controlled; + + /** @var ?array[] Stack trace. */ + public ? array $trace = null; + + public function __construct(array $details) { + parent::__construct($details); + $this->controlled = error_reporting() === 0; + } +} diff --git a/src/Handler/ExceptionHandler.php b/src/Handler/ExceptionHandler.php new file mode 100644 index 0000000..97c8708 --- /dev/null +++ b/src/Handler/ExceptionHandler.php @@ -0,0 +1,35 @@ + + * @copyright 2014 - 2024 + * @license GPL-3.0 (only) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License, version 3. + * The right to apply the terms of later versions of the GPL is RESERVED. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ +declare(strict_types = 1); + +namespace at\exceptable\Handler; + +use Throwable; + +/** Interface for use with Handler->onException(). */ +interface ExceptionHandler { + + /** + * Invoked by a registered Handler when an exception goes uncaught. + * + * @param Throwable $t The uncaught exception + * @return bool True if exception handled successfully; false otherwise + */ + public function run(Throwable $t) : bool; +} diff --git a/src/Handler/ExceptionLogEntry.php b/src/Handler/ExceptionLogEntry.php new file mode 100644 index 0000000..aceac0c --- /dev/null +++ b/src/Handler/ExceptionLogEntry.php @@ -0,0 +1,47 @@ + + * @copyright 2014 - 2024 + * @license GPL-3.0 (only) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License, version 3. + * The right to apply the terms of later versions of the GPL is RESERVED. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ +declare(strict_types = 1); + +namespace at\exceptable\Handler; + +use Throwable; + +use at\exceptable\ { + Error, + Exceptable, + Handler\LogEntry +}; + +/** Log entry for exceptions. */ +class ExceptionLogEntry extends LogEntry { + + /** @var ?Error The Error case used by the exception, if any. */ + public ? Error $error = null; + + public function __construct( + /** @var Throwable The exception that was logged. */ + public Throwable $exception + ) { + parent::__construct(); + + if ($exception instanceof Exceptable) { + $this->error = $exception->error(); + } + } +} diff --git a/src/Handler/Handler.php b/src/Handler/Handler.php new file mode 100644 index 0000000..dcecb0f --- /dev/null +++ b/src/Handler/Handler.php @@ -0,0 +1,435 @@ + + * @copyright 2014 - 2024 + * @license GPL-3.0 (only) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License, version 3. + * The right to apply the terms of later versions of the GPL is RESERVED. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ +declare(strict_types = 1); + +namespace at\exceptable\Handler; + +use ErrorException, + Throwable; + +use at\exceptable\ { + ExceptableError, + Handler\ErrorHandler, + Handler\ErrorLogEntry, + Handler\ExceptionHandler, + Handler\ExceptionLogEntry, + Handler\LogEntry, + Handler\Options, + Handler\ShutdownHandler, + Spl\LogicException, + Spl\RuntimeException +}; + +use Psr\Log\ { + LoggerAwareInterface as LoggerAware, + LoggerInterface as Logger, + LogLevel +}; + +class Handler implements LoggerAware { + + /** @var ErrorHandler[][] Registered error handlers, grouped by error type(s) to handle. */ + protected array $errorHandlers = []; + + /** @var ExceptionHandler[][] Registered exception handlers, grouped by Throwable type and code to handle. */ + protected array $exceptionHandlers = []; + + /** @var LogEntry[] Runtime log of errors/exceptions. */ + protected array $log = []; + + /** Is this Handler registered (active)? */ + protected bool $registered = false; + + /** @var ShutdownHandler[] Registered shutdown handlers and their arguments. */ + protected array $shutdownHandlers = []; + + public function __construct( + /** @var Options Runtime options for this Handler. */ + public Options $options, + /** @var Logger Logging mechanism. */ + protected ? Logger $logger = null + ) {} + + /** + * Gets a log of errors/exceptions encountered while debug mode was active. + * + * @return LogEntry[] + */ + public function debugLog() : array { + return $this->log; + } + + /** + * Registers this handler to invoke a callback, and then restores the previous handler(s). + * + * Note, this method only registers the Handler for the duration of the callback; + * it does not try..catch (any exceptions thrown by the callback will be let go uncaught). + * @see Handler::try() + * + * @param callable $callback The callback to run + * @return mixed The value returned from the callback + */ + public function handle(callable $callback) { + $registered = $this->registered; + if (! $registered) { + $this->register(); + } + + $value = $callback(); + + if (! $registered) { + $this->unregister(); + } + + return $value; + } + + /** @see https://php.net/set_error_handler $callback */ + public function handleError(int $c, string $m, string $f, int $l) : bool { + $this->throwIfMatches($c, $m, $f, $l); + + $error = ["code" => $c, "message" => $m, "file" => $f, "line" => $l]; + if (! $this->options->scream && error_reporting() === 0) { + $this->logError(true, $error); + return true; + } + + foreach ($this->errorHandlers as $severity => $handlers) { + if (($c & $severity) === $c) { + foreach ($handlers as $handler) { + if ($handler->run($c, $m, $f, $l) === true) { + $this->logError(true, $error); + return true; + } + } + } + } + + $this->logError(false, $error); + return false; + } + + /** + * Handles uncaught exceptions. + * + * @phan-suppress PhanTypeNoPropertiesForeach + * $this->exceptionHandlers is an array of arrays of ExceptionHandlers. + * + * @param Throwable $t The uncaught exception + * @throws RuntimeException ExceptableError::UncaughtException If no registered handler handles the exception + */ + public function handleException(Throwable $t) : void { + $type = get_class($t); + $code = $t->getCode(); + + // exact type and code + if (! empty($this->exceptionHandlers[$type][$code])) { + foreach ($this->exceptionHandlers[$type][$code] as $handler) { + if ($this->runExceptionHandler($handler, $t)) { + $this->logException(true, $t); + return; + } + } + } + + // work up inheritance chain with catch-all code + if (! empty($this->exceptionHandlers[$type][0])) { + do { + foreach ($this->exceptionHandlers[$type][0] ?? [] as $handler) { + if ($this->runExceptionHandler($handler, $t)) { + $this->logException(true, $t); + return; + } + } + + $type = get_parent_class($type); + } while (! empty($type)); + } + + // try any lowest-level catch-all handlers + if (! empty($this->exceptionHandlers[Throwable::class][0])) { + foreach ($this->exceptionHandlers[Throwable::class][0] ?? [] as $handler) { + if ($this->runExceptionHandler($handler, $t)) { + $this->logException(true, $t); + return; + } + } + } + + $this->logException(false, $t); + throw (ExceptableError::UncaughtException)([], $t); + } + + /** + * Handles shutdown sequence; + * triggers errorHandler() if shutdown was due to a fatal error. + */ + public function handleShutdown() : void { + if (! $this->registered) { + return; + } + + // handle the last error, if it was the cause of the shutdown + $e = error_get_last(); + if ($e && $e['type'] === E_ERROR) { + $this->handleError($e['type'], $e['message'], $e['file'], $e['line']); + } + + foreach ($this->shutdownHandlers as $handler) { + $handler->run(); + } + } + + /** + * Adds an error handler. + * @see $error_handler + * + * @param ErrorHandler $handler The error handler to add + * @param int $types The error type(s) to trigger this handler (bitmask of E_* constants); defaults to all + * @return Handler $this + */ + public function onError(ErrorHandler $handler, int $types = -1) : Handler { + $this->errorHandlers[$types][] = $handler; + + return $this; + } + + /** + * Adds an exception handler. + * @see $exception_handler + * + * @param ExceptionHandler $handler The exception handler to add + * @param string $catch Exception FQCN this handler should handle; defaults to any + * @param int $code Exception code this handler should handle; defaults to any + * @return Handler $this + */ + public function onException(ExceptionHandler $handler, string $catch = Throwable::class, int $code = 0) : Handler { + // false positive (trouble with type annotation) + // @phan-suppress-next-line PhanTypeMismatchProperty + $this->exceptionHandlers[$catch][$code][] = $handler; + + return $this; + } + + /** + * Adds a shutdown handler. + * + * @param ShutdownHandler $handler The shutdown handler to add + * @return Handler $this + */ + public function onShutdown(ShutdownHandler $handler) : Handler { + $this->shutdownHandlers[] = $handler; + + return $this; + } + + /** + * Registers this Handler's error, exception, and shutdown handlers. + * + * @return Handler $this + */ + public function register() : Handler { + set_error_handler($this->handleError(...), -1); + set_exception_handler($this->handleException(...)); + register_shutdown_function($this->handleShutdown(...)); + $this->registered = true; + + return $this; + } + + /** @see Logger::setLogger */ + public function setLogger(Logger $logger) : void { + $this->logger = $logger; + } + + /** + * Registers this handler and tries to invoke a callback, and then restores the previous handler(s). + * + * @param callable $callback The callback to run + * @throws RuntimeException ExceptableError::UncaughtException + * if callback throws and no registered Handler handles it. + * @return mixed The value returned from the callback on success; null otherwise + */ + public function try(callable $callback) { + $registered = $this->registered; + if (! $registered) { + $this->register(); + } + + try { + $value = $callback(); + } catch (Throwable $t) { + $this->handleException($t); + } + + if (! $registered) { + $this->unregister(); + } + + return $value ?? null; + } + + /** + * Un-registers this Handler. + * + * @return Handler $this + */ + public function unregister() : Handler { + restore_error_handler(); + restore_exception_handler(); + // shutdown functions can't be unregistered; just have to flag them so they're non-ops :( + $this->registered = false; + + return $this; + } + + /** + * Determines target logging level for a given error code. + * + * @param int $code Error code + * @return string One of the LogLevel::* constants + */ + protected function getLogLevel(int $code) : string { + return match ($code) { + E_ERROR => LogLevel::CRITICAL, + E_USER_ERROR => LogLevel::CRITICAL, + E_WARNING => LogLevel::WARNING, + E_USER_WARNING => LogLevel::WARNING, + E_PARSE => LogLevel::ERROR, + E_NOTICE => LogLevel::NOTICE, + E_USER_NOTICE => LogLevel::NOTICE, + E_STRICT => LogLevel::DEBUG, + E_RECOVERABLE_ERROR => LogLevel::CRITICAL, + E_DEPRECATED => LogLevel::INFO, + E_USER_DEPRECATED => LogLevel::INFO, + default => LogLevel::NOTICE + }; + } + + /** + * Logs an error according to debug settings and logger availability. + * + * The following information is passed to the logger: + * - float $time Unixtime error was logged, with microsecond precision + * - string $type Always "error" + * - bool $handled Was this error handled by a registered handler? + * - bool $controlled Was this error suppressed by the error control operator? + * - int $code Error code + * - string $message Error message + * - string $file File + * - int $line Line + * If debug mode is enabled, a backtrace is added as well: + * - array $trace Backtrace + * + * @param bool $handled Was this error handled by a registered handler? + * @param array $details Error details + */ + protected function logError(bool $handled, array $details) : void { + $log = new ErrorLogEntry($details); + $log->handled = $handled; + + if ($this->options->debug) { + $log->trace = debug_backtrace(); + $this->log[] = $log; + } + + if (isset($this->logger)) { + if ($this->options->debug) { + $this->logger->log(LogLevel::DEBUG, $log->message, $log->toArray()); + } + + if (! $handled) { + $this->logger->log($this->getLogLevel($log->code), $log->message, $log->toArray()); + } + } + } + + /** + * Logs an exception according to debug settings and logger availability. + * + * The following information is passed to the logger: + * - float $time Unixtime error was logged, with microsecond precision + * - string $type Always "exception" + * - bool $handled Was this error handled by a registered handler? + * - int $exception Exception + * + * @param bool $handled Was this exception handled by a registered handler? + * @param Throwable $t The exception instance + */ + protected function logException(bool $handled, Throwable $t) : void { + $log = new ExceptionLogEntry($t); + $log->handled = $handled; + + if ($this->options->debug) { + $this->log[] = $log; + } + + if (isset($this->logger)) { + if ($this->options->debug) { + $this->logger->log(LogLevel::DEBUG, $t->getMessage(), $log->toArray()); + } + + if(! $handled) { + $this->logger->log(LogLevel::CRITICAL, $t->getMessage(), $log->toArray()); + } + } + } + + /** + * Invokes an exception handler. + * + * @param ExceptionHandler $handler The handler to invoke + * @param Throwable $t The exception to handle + * @return bool True if handler ran successfully; false otherwise + * @throws LogicException ExceptableError::HandlerFailed if the handler errors + */ + protected function runExceptionHandler(ExceptionHandler $handler, Throwable $t) : bool { + try { + if ($handler->run($t) === true) { + if ($this->options->debug) { + $this->logException(true, $t); + } + + return true; + } + + return false; + } catch (Throwable $x) { + throw (ExceptableError::HandlerFailed)( + ["type" => gettype($handler), "unhandled" => $t], + $x + ); + } + } + + /** + * Throws an ErrorException if the given error code matches $throw setting. + * + * @param int $c Error code + * @param string $m Error message + * @param string $f Error file + * @param int $l Error line + * @throws ErrorException If error severity matches $throw setting + */ + protected function throwIfMatches(int $c, string $m, string $f, int $l) : void { + if (($c & $this->options->throw) === $c) { + throw new ErrorException($m, $c, $c, $f, $l); + } + } +} diff --git a/src/Handler/LogEntry.php b/src/Handler/LogEntry.php new file mode 100644 index 0000000..8ec8a62 --- /dev/null +++ b/src/Handler/LogEntry.php @@ -0,0 +1,63 @@ + + * @copyright 2014 - 2024 + * @license GPL-3.0 (only) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License, version 3. + * The right to apply the terms of later versions of the GPL is RESERVED. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ +declare(strict_types = 1); + +namespace at\exceptable\Handler; + +/** Base class for error log entries. */ +abstract class LogEntry { + + /** @var int Error code. */ + public ? int $code = null; + + /** @var string Filename. */ + public ? string $file = null; + + /** @var bool Was this error handled by a registered handler? */ + public bool $handled = false; + + /** @var int Line number. */ + public ? int $line = null; + + /** @var string Error message. */ + public ? string $message = null; + + /** @var float Unixtime error was logged, with microsecond precision. */ + public float $time; + + /** @param array $details Log entry values to set, in property:value format */ + public function __construct(array $details = []) { + foreach ($details as $property => $value) { + if (property_exists($this, $property)) { + $this->$property = $value; + } + } + + $this->time = microtime(true); + } + + /** + * Casts log entry to array (for use with PSR-3) + * + * @return array + */ + public function toArray() : array { + return (array) $this; + } +} diff --git a/src/Handler/Options.php b/src/Handler/Options.php new file mode 100644 index 0000000..f95098d --- /dev/null +++ b/src/Handler/Options.php @@ -0,0 +1,34 @@ + + * @copyright 2014 - 2024 + * @license GPL-3.0 (only) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License, version 3. + * The right to apply the terms of later versions of the GPL is RESERVED. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ +declare(strict_types = 1); + +namespace at\exceptable\Handler; + +/** Options for the Handler class. */ +class Options { + + /** @var bool Debug mode? */ + public bool $debug = false; + + /** @var bool Ignore the error control operator? */ + public bool $scream = false; + + /** @var int Error types which should be thrown as ErrorExceptions. */ + public int $throw = 0; +} diff --git a/src/Handler/ShutdownHandler.php b/src/Handler/ShutdownHandler.php new file mode 100644 index 0000000..b0e50fe --- /dev/null +++ b/src/Handler/ShutdownHandler.php @@ -0,0 +1,31 @@ + + * @copyright 2014 - 2024 + * @license GPL-3.0 (only) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License, version 3. + * The right to apply the terms of later versions of the GPL is RESERVED. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ +declare(strict_types = 1); + +namespace at\exceptable\Handler; + +/** Interface for use with Handler->onShutdown(). */ +interface ShutdownHandler { + + /** + * Invoked by registered exceptable Handlers on php shutdown. + * Calling `exit;` from within this method will prevent further shutdown handlers from running. + */ + public function run() : void; +} diff --git a/src/IsError.php b/src/IsError.php index 983282f..efbb14f 100644 --- a/src/IsError.php +++ b/src/IsError.php @@ -20,8 +20,7 @@ namespace at\exceptable; use BackedEnum, - Throwable, - UnitEnum; + Throwable; use at\exceptable\ { Error, @@ -33,15 +32,24 @@ /** * Defines error cases for an Exceptable. * + * Implementing Enums may be integer-backed; these values will be used as error codes. + * Otherwise, error codes will be determined by the case's declaration order. + * * Implementing Enums may define MESSAGES to provide default message templates. - * If not defined, the case name will be used. + * Otherwise, messages will be built using the Error class and case name. */ trait isError { use MakesMessages; + /** @see Error::throw() */ + public function __invoke(array $context = [], Throwable $previous = null) : Exceptable { + assert($this instanceof Error); + return $this->adjustExceptable(new RuntimeException($this, $context, $previous), 2); + } + /** @see Error::code() */ public function code() : int { - assert($this instanceof UnitEnum); + assert($this instanceof Error); // return enum value if integers provided if ($this instanceof BackedEnum && is_int($this->value)) { @@ -58,7 +66,7 @@ public function code() : int { /** @see Error::message() */ public function message(array $context) : string { - assert($this instanceof UnitEnum); + assert($this instanceof Error); $error = static::class . ".{$this->name}"; $message = $this->makeMessage($this->name, $context); @@ -68,11 +76,30 @@ public function message(array $context) : string { } /** @see Error::throw() */ - public function exceptable(array $context = [], Throwable $previous = null) : Exceptable { + public function newExceptable(array $context = [], Throwable $previous = null) : Exceptable { assert($this instanceof Error); - return RuntimeException::from($this, $context, $previous); + return $this->adjustExceptable(new RuntimeException($this, $context, $previous), 1); } - /** @see UnitEnum::cases() */ - abstract public static function cases() : array; + /** + * Adjusts the Exceptable's $file and $line to reflect the location in code it was thrown from + * (vs. where it was actually instantiated). + * + * @param Exceptable $x The Exceptable to modify + * @return Exceptable The modified Exceptable + */ + private function adjustExceptable(Exceptable $x, int $adjust) : Exceptable { + (function () use ($x, $adjust) { + $frame = $x->getTrace()[$adjust] ?? null; + // no-op if no such frame + if (! empty($frame)) { + // @phan-suppress-next-line PhanUndeclaredProperty + $x->file = $frame["file"]; + // @phan-suppress-next-line PhanUndeclaredProperty + $x->line = $frame["line"]; + } + })->call($x, $x); + + return $x; + } } diff --git a/src/IsExceptable.php b/src/IsExceptable.php index 83b2c3a..7587593 100644 --- a/src/IsExceptable.php +++ b/src/IsExceptable.php @@ -34,29 +34,42 @@ */ trait IsExceptable { - /** @see Exceptable::from() */ - public static function from(Error $e = null, array $context = [], Throwable $previous = null) : Exceptable { - assert(is_a(static::class, Exceptable::class, true)); + /** @see Exceptable::__construct() */ + public function __construct( + protected ? Error $error = null, + protected array $context = [], + Throwable $previous = null + ) { + assert($this instanceof Exceptable); + // @phan-suppress-next-line PhanUndeclaredConstantOfClass - $e ??= static::DEFAULT_ERROR; - assert($e instanceof Error); - // @todo Is [2] correct? - // @phan-suppress-next-line PhanTypeInstantiateTraitStaticOrSelf - $ex = new static($e, $context, $previous, 2); - assert($ex instanceof Exceptable); - - return $ex; + $this->error ??= static::DEFAULT_ERROR; + + // if there's no previous exception, these won't be available to the message formatter. + if (! empty($previous)) { + $root = $this->findRoot($previous); + $context["__rootType__"] = $root::class; + $context["__rootMessage__"] = $root->getMessage(); + $context["__rootCode__"] = $root->getCode(); + } else { + $context["__rootType__"] = static::class; + $context["__rootMessage__"] = ""; + $context["__rootCode__"] = $this->error->code(); + } + + // @phan-suppress-next-line PhanTraitParentReference + parent::__construct($this->error->message($context), $this->error->code(), $previous); } /** * Finds the previous-most exception from the given exception. * - * @param Throwable $ex the exception to start from + * @param Throwable $t the exception to start from * @return Throwable The root exception (may be the same as the starting exception) */ - private static function findRoot(Throwable $ex) : Throwable { - $root = $ex; - while (($previous = $root->getPrevious()) !== null) { + private static function findRoot(Throwable $t) : Throwable { + $root = $t; + while (($previous = $root->getPrevious()) instanceof Throwable) { $root = $previous; } @@ -74,60 +87,32 @@ public function context() : array { /** @see Exceptable::error() */ public function error() : Error { + // @phan-suppress-next-line PhanTypeMismatchReturnNullable return $this->error; } /** @see Exceptable::has() */ - public function has(Error $e) : bool { - $ex = $this; - while ($ex instanceof Exceptable) { - if ($ex->error === $e) { + public function has(Error $error) : bool { + $t = $this; + while ($t instanceof Throwable) { + if ($t instanceof Exceptable && $t->error === $error) { return true; } - $ex = $ex->getPrevious(); + $t = $t->getPrevious(); } return false; } /** @see Exceptable::is() */ - public function is(Error $e) : bool { - return ($this->error ?? null) === $e; + public function is(Error $error) : bool { + return $this->error === $error; } /** @see Exceptable::root() */ public function root() : Throwable { assert($this instanceof Throwable); - - return static::findRoot($this); - } - - /** Nonpublic constructor. Use Exceptable::from(). */ - private function __construct( - protected Error $error, - protected array $context = [], - Throwable $previous = null, - int $adjust = 1 - ) { - assert($this instanceof Exceptable); - - if (! empty($previous)) { - $root = static::findRoot($previous); - $context["__rootType__"] = $root::class; - $context["__rootMessage__"] = $root->getMessage(); - $context["__rootCode__"] = $root->getCode(); - } - - // @phan-suppress-next-line PhanTraitParentReference - parent::__construct($this->error->message($context), $this->error->code(), $previous); - - $frame = $this->getTrace()[$adjust] ?? null; - if (! empty($frame)) { - // @phan-suppress-next-line PhanUndeclaredProperty - $this->file = $frame["file"]; - // @phan-suppress-next-line PhanUndeclaredProperty - $this->line = $frame["line"]; - } + return $this->findRoot($this); } } diff --git a/src/Result.php b/src/Result.php new file mode 100644 index 0000000..0e42983 --- /dev/null +++ b/src/Result.php @@ -0,0 +1,137 @@ + + * @copyright 2014 - 2024 + * @license GPL-3.0 (only) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License, version 3. + * The right to apply the terms of later versions of the GPL is RESERVED. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ +declare(strict_types = 1); + +namespace at\exceptable; + +use Throwable; + +use at\exceptable\ { + Error, + Exceptable, + ExceptableError +}; + +/** + * Implementation of the Result pattern: provides a [result, error] tuple + * for functional error handling without throwing exceptions. + */ +class Result { + + /** + * Factory: builds a Result for an error case. + * + * @param Error $error The error case + * @return Result + */ + public static function error(Error $error) : static { + return new static(null, $error); + } + + /** + * Factory: builds a Result for a return case. + * + * @param mixed $value The return value + * @return Result + */ + public static function return($value) : static { + return new static($value, null); + } + + /** + * Factory: invokes a callable and builds a Result from its return value or thrown exception. + * + * @param callable $callback The callable to invoke + * @return Result + */ + public static function try(callable $callback) : static { + try { + $result = $callback(); + if (! $result instanceof Result) { + $result = static::return($result); + } + + return $result; + } catch (Throwable $t) { + $error = ($t instanceof Exceptable) ? + $t->error() : + ExceptableError::UncaughtException; + + $result = static::error($error); + $result->exception = $t; + return $result; + } + } + + /** + * Invokes a callable that returns a Result and returns its return value (or throws its error value). + * + * @param callable $callback The callback to unpack + * @throws Throwable On error + * @return mixed The callback's return value on success + */ + public static function unpack(callable $callback) : mixed { + $result = $callback(); + if (! $result instanceof Result) { + return $result; + } + + if ($result->isError()) { + throw $result->exception(); + } + + return $result->value; + } + + /** @internal */ + private Throwable $exception; + + /** + * @param mixed $value The success value, if any + * @param ?Error $error The error value, if any + */ + private function __construct( + public readonly mixed $value = null, + public readonly ? Error $error = null + ) {} + + /** + * Is this an error Result? + * + * @return bool True if this is an error result; false otherwise + */ + public function isError() : bool { + return ! empty($this->error); + } + + /** + * Gets an exception for this Result, if it's an error result. + * + * @return Throwable|null + */ + public function exception() : ? Throwable { + if (! $this->isError()) { + return null; + } + + $this->exception ??= ($this->error)([]); + + return $this->exception; + } +} diff --git a/src/Spl/SplError.php b/src/Spl/SplError.php index 6b46d41..96f0b9d 100644 --- a/src/Spl/SplError.php +++ b/src/Spl/SplError.php @@ -64,59 +64,42 @@ enum SplError : int implements Error { case UnexpectedValue = 13; /** @see Error::MESSAGES */ - protected const MESSAGES = [ + public const MESSAGES = [ self::class => [ - self::BadFunctionCall->value => "{__rootMessage__}", - self::BadMethodCall->value => "{__rootMessage__}", - self::Domain->value => "{__rootMessage__}", - self::InvalidArgument->value => "{__rootMessage__}", - self::Length->value => "{__rootMessage__}", - self::Logic->value => "{__rootMessage__}", - self::OutOfBounds->value => "{__rootMessage__}", - self::OutOfRange->value => "{__rootMessage__}", - self::Overflow->value => "{__rootMessage__}", - self::Range->value => "{__rootMessage__}", - self::Runtime->value => "{__rootMessage__}", - self::Underflow->value => "{__rootMessage__}", - self::UnexpectedValue->value => "{__rootMessage__}" + self::BadFunctionCall->name => "{__rootMessage__}", + self::BadMethodCall->name => "{__rootMessage__}", + self::Domain->name => "{__rootMessage__}", + self::InvalidArgument->name => "{__rootMessage__}", + self::Length->name => "{__rootMessage__}", + self::Logic->name => "{__rootMessage__}", + self::OutOfBounds->name => "{__rootMessage__}", + self::OutOfRange->name => "{__rootMessage__}", + self::Overflow->name => "{__rootMessage__}", + self::Range->name => "{__rootMessage__}", + self::Runtime->name => "{__rootMessage__}", + self::Underflow->name => "{__rootMessage__}", + self::UnexpectedValue->name => "{__rootMessage__}" ] ]; /** @see Error::exceptable() */ - public function exceptable(array $context = [], Throwable $previous = null) : Exceptable { + public function newExceptable(array $context = [], Throwable $previous = null) : Exceptable { assert($this instanceof Error); return match ($this) { - self::BadFunctionCall => BadFunctionCallException::from($this, $context, $previous), - self::BadMethodCall => BadMethodCallException::from($this, $context, $previous), - self::Domain => DomainException::from($this, $context, $previous), - self::InvalidArgument => InvalidArgumentException::from($this, $context, $previous), - self::Length => LengthException::from($this, $context, $previous), - self::Logic => LogicException::from($this, $context, $previous), - self::OutOfBounds => OutOfBoundsException::from($this, $context, $previous), - self::OutOfRange => OutOfRangeException::from($this, $context, $previous), - self::Overflow => OverflowException::from($this, $context, $previous), - self::Range => RangeException::from($this, $context, $previous), - self::Runtime => RuntimeException::from($this, $context, $previous), - self::Underflow => UnderflowException::from($this, $context, $previous), - self::UnexpectedValue => UnexpectedValueException::from($this, $context, $previous), - default => RuntimeException::from($this, $context, $previous) + self::BadFunctionCall => new BadFunctionCallException($this, $context, $previous), + self::BadMethodCall => new BadMethodCallException($this, $context, $previous), + self::Domain => new DomainException($this, $context, $previous), + self::InvalidArgument => new InvalidArgumentException($this, $context, $previous), + self::Length => new LengthException($this, $context, $previous), + self::Logic => new LogicException($this, $context, $previous), + self::OutOfBounds => new OutOfBoundsException($this, $context, $previous), + self::OutOfRange => new OutOfRangeException($this, $context, $previous), + self::Overflow => new OverflowException($this, $context, $previous), + self::Range => new RangeException($this, $context, $previous), + self::Runtime => new RuntimeException($this, $context, $previous), + self::Underflow => new UnderflowException($this, $context, $previous), + self::UnexpectedValue => new UnexpectedValueException($this, $context, $previous), + default => new RuntimeException($this, $context, $previous) }; - - // return match ($this) { - // self::BadFunctionCall => (fn () => new BadFunctionCallException($this, $context, $previous, 2))->call($e, $e), - // self::BadMethodCall => (fn () => new BadMethodCallException($this, $context, $previous, 2))->call($e, $e), - // self::Domain => (fn () => new DomainException($this, $context, $previous, 2))->call($e, $e), - // self::InvalidArgument => (fn () => new InvalidArgumentException($this, $context, $previous, 2))->call($e, $e), - // self::Length => (fn () => new LengthException($this, $context, $previous, 2))->call($e, $e), - // self::Logic => (fn () => new LogicException($this, $context, $previous, 2))->call($e, $e), - // self::OutOfBounds => (fn () => new OutOfBoundsException($this, $context, $previous, 2))->call($e, $e), - // self::OutOfRange => (fn () => new OutOfRangeException($this, $context, $previous, 2))->call($e, $e), - // self::Overflow => (fn () => new OverflowException($this, $context, $previous, 2))->call($e, $e), - // self::Range => (fn () => new RangeException($this, $context, $previous, 2))->call($e, $e), - // self::Runtime => (fn () => new RuntimeException($this, $context, $previous, 2))->call($e, $e), - // self::Underflow => (fn () => new UnderflowException($this, $context, $previous, 2))->call($e, $e), - // self::UnexpectedValue => (fn () => new UnexpectedValueException($this, $context, $previous, 2))->call($e, $e), - // default => RuntimeException::from($this, $context, $previous) - // }; } } From ac5bab0b8de3999ce83cff59d2757ada8b761720 Mon Sep 17 00:00:00 2001 From: Adrian Date: Sat, 2 Mar 2024 00:17:20 -0500 Subject: [PATCH 04/13] error value example --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index b38eac8..cad6811 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,40 @@ $handler->onException(function($e) { error_log($e->getMessage()); return true; } // i don't know who, you think is foo, but it's not foobedobedoo ``` +errors as values +---------------- +```php +name => "ooh noooooooooooooooooo!"]; +} + +function foo(bool $fail) : Result { + return $fail ? + Result::error(FooError::TheyToldMeToDoIt) : + Result::value("woooooooooooooooooo hoo!"); +} + +$result = foo($falseOrTrueIsUpToYou); +if ($result->isError()) { + echo $result->error->message(); + // outputs "ooh noooooooooooooooooo!" +} else { + echo $result->value; + // outputs "woooooooooooooooooo hoo!" +} +``` + see more in [the wiki](https://github.com/php-enspired/exceptable/wiki). Version 5.0 From 44335f467ea5e074b949c44d8d4033ba772432eb Mon Sep 17 00:00:00 2001 From: Adrian Date: Sat, 2 Mar 2024 00:26:58 -0500 Subject: [PATCH 05/13] readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index cad6811..5577fe4 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ Version 5.0 **Version 5** requires PHP 8.2 or greater. - ICU messaging system overhauled and published to its own package! + Check out [php-enspired/peekaboo](https://github.com/php-enspired/peekaboo) - using _exceptable_ means you get it for free, so take advantage! - Introduces _Error enums_, making errors into first-class citizens and opening up the ability to handle errors as values. Also introduces a `Return` class, which lets you handle success/error values by returning them up the chain. - Reworks and improves functionality for Exceptables and the Handler. From 74e8a7f0d28c69d7cd16658cf1bad323edfa905e Mon Sep 17 00:00:00 2001 From: Adrian Date: Mon, 4 Mar 2024 23:55:02 -0500 Subject: [PATCH 06/13] no result class --- README.md | 41 +++++++----- src/Error.php | 7 ++ src/Exceptable.php | 13 +--- src/ExceptableError.php | 18 +++--- src/IsError.php | 16 ++++- src/IsExceptable.php | 42 +++++++----- src/Result.php | 137 ---------------------------------------- src/Spl/SplError.php | 33 +++++----- 8 files changed, 96 insertions(+), 211 deletions(-) delete mode 100644 src/Result.php diff --git a/README.md b/README.md index 5577fe4..9c20868 100644 --- a/README.md +++ b/README.md @@ -55,36 +55,48 @@ $handler->onException(function($e) { error_log($e->getMessage()); return true; } errors as values ---------------- + +Having errors available to your application as normal values also makes _not_ throwing exceptions a viable solution. The _Result pattern_, for example, is a functional programming approach to error handling that treats error conditions as normal, expected return values. This can encourage you to consider how to handle error cases more carefully and closer to the source, as well as being a benefit to static analysis and comprehensibility in general. See [Larry Garfield's excellent article](https://peakd.com/hive-168588/@crell/much-ado-about-null) for more. + ```php name => "ooh noooooooooooooooooo!"]; + public const MESSAGES = [ + self::TheyToldMeToDoIt->name => "ooh noooooooooooooooooo!" + ]; } -function foo(bool $fail) : Result { +function foo(bool $fail) : string|FooError { return $fail ? - Result::error(FooError::TheyToldMeToDoIt) : - Result::value("woooooooooooooooooo hoo!"); + FooError::TheyToldMeToDoIt : + "woooooooooooooooooo hoo!"; } -$result = foo($falseOrTrueIsUpToYou); -if ($result->isError()) { - echo $result->error->message(); +$bool = maybeTrueMaybeFalse(); +$result = foo($bool); +if ($result instanceof FooError) { + echo $result->message(); // outputs "ooh noooooooooooooooooo!" -} else { - echo $result->value; - // outputs "woooooooooooooooooo hoo!" + + $bool = !! $bool; + $newResult = foo($bool); } + +echo $newResult; +// outputs "woooooooooooooooooo hoo!" +``` +...and if you want to make _everybody_ mad, you can still throw them. +```php +throw $result(["yes" => "i know i'm horrible"]); ``` see more in [the wiki](https://github.com/php-enspired/exceptable/wiki). @@ -94,9 +106,8 @@ Version 5.0 **Version 5** requires PHP 8.2 or greater. - ICU messaging system overhauled and published to its own package! - Check out [php-enspired/peekaboo](https://github.com/php-enspired/peekaboo) - using _exceptable_ means you get it for free, so take advantage! -- Introduces _Error enums_, making errors into first-class citizens and opening up the ability to handle errors as values. - Also introduces a `Return` class, which lets you handle success/error values by returning them up the chain. + Check out [php-enspired/peekaboo](https://packagist.org/packages/php-enspired/peekaboo) - using _exceptable_ means you get it for free, so take advantage! +- Introduces the _Error_ interface for enums, making errors into first-class citizens and opening up the ability to handle errors as values. - Reworks and improves functionality for Exceptables and the Handler. [Read the release notes.](https://github.com/php-enspired/exceptable/wiki/new-in-5.0) diff --git a/src/Error.php b/src/Error.php index b621b2f..ea58963 100644 --- a/src/Error.php +++ b/src/Error.php @@ -37,6 +37,13 @@ public function __invoke(array $context = [], Throwable $previous = null) : Exce */ public function code() : int; + /** + * Gets the fully qualified name of the proper Exceptable class to throw this Error as. + * + * @return string Exceptable FQCN + */ + public function exceptableType() : string; + /** * Gets the error message for this case, using the given context. * diff --git a/src/Exceptable.php b/src/Exceptable.php index e4bbdd9..1ab9732 100644 --- a/src/Exceptable.php +++ b/src/Exceptable.php @@ -22,10 +22,7 @@ use Throwable; -use at\exceptable\ { - Error, - ExceptableError -}; +use at\exceptable\Error; /** * Augmented interface for exceptional exceptions. @@ -41,14 +38,6 @@ */ interface Exceptable extends Throwable { - /** - * The default (0) Error for this Exceptable. - * - * @var Error - * @todo Add type constraint once php 8.2 support is dropped. - */ - public const DEFAULT_ERROR = ExceptableError::UnknownError; - /** * @param ?Error $e The Error case to build from * @param array $context Additional exception context diff --git a/src/ExceptableError.php b/src/ExceptableError.php index 4992ef6..7d80fac 100644 --- a/src/ExceptableError.php +++ b/src/ExceptableError.php @@ -20,8 +20,6 @@ namespace at\exceptable; -use Throwable; - use at\exceptable\ { Error, IsError, @@ -36,29 +34,29 @@ enum ExceptableError : int implements Error { use IsError; - case UnacceptableError = 0; - case UncaughtException = 1; - case UnknownError = 2; + case UnknownError = 0; + case UnacceptableError = 1; + case UncaughtException = 2; case HandlerFailed = 3; /** @see MakesMessages::MESSAGES */ public const MESSAGES = [ self::class => [ + self::UnknownError->name => "{__rootMessage__}", self::UnacceptableError->name => "Invalid Error type '{type}' (expected enum implementing " . Error::class . ")", self::UncaughtException->name => "Uncaught Exception ({__rootType__}): {__rootMessage__}", - self::UnknownError->name => "{__rootMessage__}", self::HandlerFailed->name => "ExceptionHandler ({type}) failed: {__rootMessage__}" ] ]; /** @see Error::exceptable() */ - public function newExceptable(array $context = [], Throwable $previous = null) : Exceptable { + public function exceptableType() : string { assert($this instanceof Error); return match ($this) { - self::UnacceptableError, self::HandlerFailed => new LogicException($this, $context, $previous), - self::UncaughtException, self::UnknownError => new RuntimeException($this, $context, $previous), - default => new RuntimeException($this, $context, $previous) + self::UnacceptableError, self::HandlerFailed => LogicException::class, + self::UncaughtException, self::UnknownError => RuntimeException::class, + default => RuntimeException::class }; } } diff --git a/src/IsError.php b/src/IsError.php index efbb14f..8c589b0 100644 --- a/src/IsError.php +++ b/src/IsError.php @@ -24,6 +24,7 @@ use at\exceptable\ { Error, + Exceptable, Spl\RuntimeException }; @@ -44,7 +45,10 @@ trait isError { /** @see Error::throw() */ public function __invoke(array $context = [], Throwable $previous = null) : Exceptable { assert($this instanceof Error); - return $this->adjustExceptable(new RuntimeException($this, $context, $previous), 2); + $x = $this->exceptableType(); + assert(is_a($x, Exceptable::class, true)); + + return $this->adjustExceptable(new $x($this, $context, $previous), 1); } /** @see Error::code() */ @@ -64,6 +68,11 @@ public function code() : int { } } + /** @see Error::exceptableType() */ + public function exceptableType() : string { + return RuntimeException::class; + } + /** @see Error::message() */ public function message(array $context) : string { assert($this instanceof Error); @@ -78,7 +87,10 @@ public function message(array $context) : string { /** @see Error::throw() */ public function newExceptable(array $context = [], Throwable $previous = null) : Exceptable { assert($this instanceof Error); - return $this->adjustExceptable(new RuntimeException($this, $context, $previous), 1); + $x = $this->exceptableType(); + assert(is_a($x, Exceptable::class, true)); + + return $this->adjustExceptable(new $x($this, $context, $previous), 1); } /** diff --git a/src/IsExceptable.php b/src/IsExceptable.php index 7587593..317c34e 100644 --- a/src/IsExceptable.php +++ b/src/IsExceptable.php @@ -34,6 +34,21 @@ */ trait IsExceptable { + /** + * Finds the previous-most exception from the given exception. + * + * @param Throwable $t the exception to start from + * @return Throwable The root exception (may be the same as the starting exception) + */ + private static function findRoot(Throwable $t) : Throwable { + $root = $t; + while (($previous = $root->getPrevious()) instanceof Throwable) { + $root = $previous; + } + + return $root; + } + /** @see Exceptable::__construct() */ public function __construct( protected ? Error $error = null, @@ -42,8 +57,7 @@ public function __construct( ) { assert($this instanceof Exceptable); - // @phan-suppress-next-line PhanUndeclaredConstantOfClass - $this->error ??= static::DEFAULT_ERROR; + $this->error ??= $this->defaultError(); // if there's no previous exception, these won't be available to the message formatter. if (! empty($previous)) { @@ -61,21 +75,6 @@ public function __construct( parent::__construct($this->error->message($context), $this->error->code(), $previous); } - /** - * Finds the previous-most exception from the given exception. - * - * @param Throwable $t the exception to start from - * @return Throwable The root exception (may be the same as the starting exception) - */ - private static function findRoot(Throwable $t) : Throwable { - $root = $t; - while (($previous = $root->getPrevious()) instanceof Throwable) { - $root = $previous; - } - - return $root; - } - /** @see Exceptable::context() */ public function context() : array { assert($this instanceof Throwable); @@ -115,4 +114,13 @@ public function root() : Throwable { assert($this instanceof Throwable); return $this->findRoot($this); } + + /** + * Gets the default (code 0) Error case for this Exceptable. + * + * @return Error + */ + protected function defaultError() : Error { + return ExceptableError::UnknownError; + } } diff --git a/src/Result.php b/src/Result.php deleted file mode 100644 index 0e42983..0000000 --- a/src/Result.php +++ /dev/null @@ -1,137 +0,0 @@ - - * @copyright 2014 - 2024 - * @license GPL-3.0 (only) - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License, version 3. - * The right to apply the terms of later versions of the GPL is RESERVED. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with this program. - * If not, see . - */ -declare(strict_types = 1); - -namespace at\exceptable; - -use Throwable; - -use at\exceptable\ { - Error, - Exceptable, - ExceptableError -}; - -/** - * Implementation of the Result pattern: provides a [result, error] tuple - * for functional error handling without throwing exceptions. - */ -class Result { - - /** - * Factory: builds a Result for an error case. - * - * @param Error $error The error case - * @return Result - */ - public static function error(Error $error) : static { - return new static(null, $error); - } - - /** - * Factory: builds a Result for a return case. - * - * @param mixed $value The return value - * @return Result - */ - public static function return($value) : static { - return new static($value, null); - } - - /** - * Factory: invokes a callable and builds a Result from its return value or thrown exception. - * - * @param callable $callback The callable to invoke - * @return Result - */ - public static function try(callable $callback) : static { - try { - $result = $callback(); - if (! $result instanceof Result) { - $result = static::return($result); - } - - return $result; - } catch (Throwable $t) { - $error = ($t instanceof Exceptable) ? - $t->error() : - ExceptableError::UncaughtException; - - $result = static::error($error); - $result->exception = $t; - return $result; - } - } - - /** - * Invokes a callable that returns a Result and returns its return value (or throws its error value). - * - * @param callable $callback The callback to unpack - * @throws Throwable On error - * @return mixed The callback's return value on success - */ - public static function unpack(callable $callback) : mixed { - $result = $callback(); - if (! $result instanceof Result) { - return $result; - } - - if ($result->isError()) { - throw $result->exception(); - } - - return $result->value; - } - - /** @internal */ - private Throwable $exception; - - /** - * @param mixed $value The success value, if any - * @param ?Error $error The error value, if any - */ - private function __construct( - public readonly mixed $value = null, - public readonly ? Error $error = null - ) {} - - /** - * Is this an error Result? - * - * @return bool True if this is an error result; false otherwise - */ - public function isError() : bool { - return ! empty($this->error); - } - - /** - * Gets an exception for this Result, if it's an error result. - * - * @return Throwable|null - */ - public function exception() : ? Throwable { - if (! $this->isError()) { - return null; - } - - $this->exception ??= ($this->error)([]); - - return $this->exception; - } -} diff --git a/src/Spl/SplError.php b/src/Spl/SplError.php index 96f0b9d..e7717cb 100644 --- a/src/Spl/SplError.php +++ b/src/Spl/SplError.php @@ -20,11 +20,8 @@ namespace at\exceptable\Spl; -use Throwable; - use at\exceptable\ { Error, - Exceptable, IsError, Spl\BadFunctionCallException, Spl\BadMethodCallException, @@ -83,23 +80,23 @@ enum SplError : int implements Error { ]; /** @see Error::exceptable() */ - public function newExceptable(array $context = [], Throwable $previous = null) : Exceptable { + public function exceptableType() : string { assert($this instanceof Error); return match ($this) { - self::BadFunctionCall => new BadFunctionCallException($this, $context, $previous), - self::BadMethodCall => new BadMethodCallException($this, $context, $previous), - self::Domain => new DomainException($this, $context, $previous), - self::InvalidArgument => new InvalidArgumentException($this, $context, $previous), - self::Length => new LengthException($this, $context, $previous), - self::Logic => new LogicException($this, $context, $previous), - self::OutOfBounds => new OutOfBoundsException($this, $context, $previous), - self::OutOfRange => new OutOfRangeException($this, $context, $previous), - self::Overflow => new OverflowException($this, $context, $previous), - self::Range => new RangeException($this, $context, $previous), - self::Runtime => new RuntimeException($this, $context, $previous), - self::Underflow => new UnderflowException($this, $context, $previous), - self::UnexpectedValue => new UnexpectedValueException($this, $context, $previous), - default => new RuntimeException($this, $context, $previous) + self::BadFunctionCall => BadFunctionCallException::class, + self::BadMethodCall => BadMethodCallException::class, + self::Domain => DomainException::class, + self::InvalidArgument => InvalidArgumentException::class, + self::Length => LengthException::class, + self::Logic => LogicException::class, + self::OutOfBounds => OutOfBoundsException::class, + self::OutOfRange => OutOfRangeException::class, + self::Overflow => OverflowException::class, + self::Range => RangeException::class, + self::Runtime => RuntimeException::class, + self::Underflow => UnderflowException::class, + self::UnexpectedValue => UnexpectedValueException::class, + default => RuntimeException::class }; } } From 830d650c6566b62b3778b4a17f8c14ac2c134808 Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 5 Mar 2024 23:57:21 -0500 Subject: [PATCH 07/13] error test, spl error test --- phpunit.xml | 6 +- src/IsError.php | 6 +- src/IsExceptable.php | 14 +- .../{ErrorCaseTest.php => ErrorTestCase.php} | 58 +++---- tests/HandlerTest.php | 2 +- tests/IsExceptableTest.php | 2 +- tests/SplErrorTest.php | 143 ++++++++++++++++++ 7 files changed, 181 insertions(+), 50 deletions(-) rename tests/{ErrorCaseTest.php => ErrorTestCase.php} (78%) create mode 100644 tests/SplErrorTest.php diff --git a/phpunit.xml b/phpunit.xml index 604c611..0758c6a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,9 +5,5 @@ xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" colors="true" cacheDirectory=".phpunit.cache"> - - - ./src - - + diff --git a/src/IsError.php b/src/IsError.php index 8c589b0..66be4ee 100644 --- a/src/IsError.php +++ b/src/IsError.php @@ -48,7 +48,7 @@ public function __invoke(array $context = [], Throwable $previous = null) : Exce $x = $this->exceptableType(); assert(is_a($x, Exceptable::class, true)); - return $this->adjustExceptable(new $x($this, $context, $previous), 1); + return $this->adjustExceptable(new $x($this, $context, $previous), 0); } /** @see Error::code() */ @@ -78,7 +78,7 @@ public function message(array $context) : string { assert($this instanceof Error); $error = static::class . ".{$this->name}"; - $message = $this->makeMessage($this->name, $context); + $message = $this->makeMessage(static::class . ".{$this->name}", $context); return empty($message) ? $error : "{$error}: {$message}"; @@ -90,7 +90,7 @@ public function newExceptable(array $context = [], Throwable $previous = null) : $x = $this->exceptableType(); assert(is_a($x, Exceptable::class, true)); - return $this->adjustExceptable(new $x($this, $context, $previous), 1); + return $this->adjustExceptable(new $x($this, $context, $previous), 0); } /** diff --git a/src/IsExceptable.php b/src/IsExceptable.php index 317c34e..1b8273b 100644 --- a/src/IsExceptable.php +++ b/src/IsExceptable.php @@ -62,17 +62,17 @@ public function __construct( // if there's no previous exception, these won't be available to the message formatter. if (! empty($previous)) { $root = $this->findRoot($previous); - $context["__rootType__"] = $root::class; - $context["__rootMessage__"] = $root->getMessage(); - $context["__rootCode__"] = $root->getCode(); + $this->context["__rootType__"] = $root::class; + $this->context["__rootMessage__"] = $root->getMessage(); + $this->context["__rootCode__"] = $root->getCode(); } else { - $context["__rootType__"] = static::class; - $context["__rootMessage__"] = ""; - $context["__rootCode__"] = $this->error->code(); + $this->context["__rootType__"] = static::class; + $this->context["__rootMessage__"] = ""; + $this->context["__rootCode__"] = $this->error->code(); } // @phan-suppress-next-line PhanTraitParentReference - parent::__construct($this->error->message($context), $this->error->code(), $previous); + parent::__construct($this->error->message($this->context), $this->error->code(), $previous); } /** @see Exceptable::context() */ diff --git a/tests/ErrorCaseTest.php b/tests/ErrorTestCase.php similarity index 78% rename from tests/ErrorCaseTest.php rename to tests/ErrorTestCase.php index 9d0eb0b..ce9ee08 100644 --- a/tests/ErrorCaseTest.php +++ b/tests/ErrorTestCase.php @@ -3,7 +3,7 @@ * @package at.exceptable * @subpackage tests * @author Adrian - * @copyright 2014 - 2023 + * @copyright 2014 - 2024 * @license GPL-3.0 (only) * * This program is free software: you can redistribute it and/or modify it @@ -26,44 +26,41 @@ Throwable; use at\exceptable\ { + Error, Exceptable, - ExceptableError, IsExceptable, Tests\TestCase }; /** - * Basic tests for the default ErrorCase implementations. + * Basic tests for the default Error implementations. * - * @covers at\exceptable\IsErrorCase - * @covers at\exceptable\IsBackedErrorCase + * @covers at\exceptable\IsError * - * This test case can (should) be extended to test other concrete implementations: - * - override errorCase() to provide the ErrorCase to test + * Base class to test implementations of Error. + * - override error() to provide the Error to test * - override *Provider() methods to provide appropriate input and expectations */ -class ErrorCaseTest extends TestCase { +abstract class ErrorTestCase extends TestCase { - /** @see testExceptableFrom() */ - public function exceptableFromProvider() : array { + abstract public static function newExceptableProvider() : array; - } - - public function testExceptableFrom( - ErrorCase $error, + /** @dataProvider newExceptableProvider */ + public function testNewExceptable( + Error $error, ? array $context, ? Throwable $previous, Exceptable $expected ) { $line = __LINE__ + 1; - $actual = $error->from($context, $previous); + $actual = $error($context, $previous); - $this->assertExceptableIsExceptable($actual, $expected); + $this->assertExceptableIsExceptable($actual, get_class($expected)); $this->assertExceptableOrigination($actual, __FILE__, $line); $this->assertExceptableHasCode($actual, $expected->getCode()); $this->assertExceptableHasMessage($actual, $expected->getMessage()); - $this->assertExceptableHasCase($actual, $error); - $this->assertExceptableHasContext($actual, $expected->getContext()); + $this->assertExceptableHasError($actual, $error); + $this->assertExceptableHasContext($actual, $expected->context()); $this->assertExceptableHasPrevious($actual, $expected->getPrevious()); $this->assertExceptableHasRoot($actual, $expected->getPrevious() ?? $actual); } @@ -103,13 +100,13 @@ protected function assertExceptableOrigination(Exceptable $actual, string $file, * Asserts test subject has the expected error case. * * @param mixed $actual Test subject - * @param int $case Expected error case + * @param Error $rttot Expected error case */ - protected function assertExceptableHasCase(Exceptable $actual, ErrorCase $case) : void { + protected function assertExceptableHasError(Exceptable $actual, Error $error) : void { $this->assertSame( - $code, - $actual->case(), - "Exceptable does not report expected case '{$case->name}'" + $error, + $actual->error(), + "Exceptable does not report expected error case '{$error->name}'" ); } @@ -127,7 +124,6 @@ protected function assertExceptableHasCode(Exceptable $actual, int $code) : void ); } - /** * Asserts test subject has the expected (possibly formatted) message. * @@ -149,23 +145,19 @@ protected function assertExceptableHasMessage(Exceptable $actual, string $messag * @param ?array $context Expected contextual information */ protected function assertExceptableHasContext(Exceptable $actual, ? array $context) : void { - $actual = $actual->getContext(); + $actual = $actual->context(); - $this->assertArrayHasKey( - "__rootMessage__", - $actual, - "getContext()[___rootMessage_] is missing" - ); + $this->assertArrayHasKey("__rootMessage__", $actual, "context()[__rootMessage_] is missing"); $this->assertIsString($actual["__rootMessage__"]); if (isset($context)) { foreach ($context as $key => $value) { - $this->assertArrayHasKey($key, $actual, "getContext()[{$key}] is missing"); + $this->assertArrayHasKey($key, $actual, "context()[{$key}] is missing"); $this->assertSame( $value, $actual[$key], - "getContext()[{$key}] does not hold expected value ({$this->asString($value)})" + "context()[{$key}] does not hold expected value ({$this->asString($value)})" ); } } @@ -194,7 +186,7 @@ protected function assertExceptableHasRoot(Exceptable $actual, Throwable $root) $fqcn = get_class($root); $this->assertSame( $root, - $actual->getRoot(), + $actual->root(), "getPrevious() does not report expected root exception ({$fqcn})" ); } diff --git a/tests/HandlerTest.php b/tests/HandlerTest.php index 0561496..a485d66 100644 --- a/tests/HandlerTest.php +++ b/tests/HandlerTest.php @@ -51,7 +51,7 @@ * * @covers at\exceptable\Handler */ -class HandlerTest extends TestCase { +abstract class HandlerTest extends TestCase { /** @var array[] List of reported error handling function invocations. */ protected static $registered = []; diff --git a/tests/IsExceptableTest.php b/tests/IsExceptableTest.php index 8d96fac..11a142d 100644 --- a/tests/IsExceptableTest.php +++ b/tests/IsExceptableTest.php @@ -41,7 +41,7 @@ * - override exceptableFQCN() method to provide the name of the exceptable to test * - override *Provider methods to provide appropriate input and expectations */ -class IsExceptableTest extends TestCase { +abstract class IsExceptableTest extends TestCase { /** * Path to resource bundle used for this test. diff --git a/tests/SplErrorTest.php b/tests/SplErrorTest.php new file mode 100644 index 0000000..1da6074 --- /dev/null +++ b/tests/SplErrorTest.php @@ -0,0 +1,143 @@ + + * @copyright 2014 - 2024 + * @license GPL-3.0 (only) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License, version 3. + * The right to apply the terms of later versions of the GPL is RESERVED. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ +declare(strict_types = 1); + +namespace at\exceptable\Tests; + +use Exception; + +use at\exceptable\ { + Spl\BadFunctionCallException, + Spl\BadMethodCallException, + Spl\DomainException, + Spl\InvalidArgumentException, + Spl\LengthException, + Spl\LogicException, + Spl\OutOfBoundsException, + Spl\OutOfRangeException, + Spl\OverflowException, + Spl\RangeException, + Spl\RuntimeException, + Spl\SplError, + Spl\UnderflowException, + Spl\UnexpectedValueException, + Tests\ErrorTestCase +}; + +/** + * Basic tests for the default Error implementations. + * + * @covers at\exceptable\IsError + * @covers at\exceptable\Spl\SplError + * + * Base class to test implementations of Error. + * - override error() to provide the Error to test + * - override *Provider() methods to provide appropriate input and expectations + */ +class SplErrorTest extends ErrorTestCase { + + public static function newExceptableProvider() : array { + try { + $context = ["this" => "is only a test"]; + $previous = new Exception("this is the root exception"); + return [ + [ + SplError::BadFunctionCall, + $context, + $previous, + new BadFunctionCallException(SplError::BadFunctionCall, $context, $previous) + ], + [ + SplError::BadMethodCall, + $context, + $previous, + new BadMethodCallException(SplError::BadMethodCall, $context, $previous) + ], + [ + SplError::Domain, + $context, + $previous, + new DomainException(SplError::Domain, $context, $previous) + ], + [ + SplError::InvalidArgument, + $context, + $previous, + new InvalidArgumentException(SplError::InvalidArgument, $context, $previous) + ], + [ + SplError::Length, + $context, + $previous, + new LengthException(SplError::Length, $context, $previous) + ], + [ + SplError::Logic, + $context, + $previous, + new LogicException(SplError::Logic, $context, $previous) + ], + [ + SplError::OutOfBounds, + $context, + $previous, + new OutOfBoundsException(SplError::OutOfBounds, $context, $previous) + ], + [ + SplError::OutOfRange, + $context, + $previous, + new OutOfRangeException(SplError::OutOfRange, $context, $previous) + ], + [ + SplError::Overflow, + $context, + $previous, + new OverflowException(SplError::Overflow, $context, $previous) + ], + [ + SplError::Range, + $context, + $previous, + new RangeException(SplError::Range, $context, $previous) + ], + [ + SplError::Runtime, + $context, + $previous, + new RuntimeException(SplError::Runtime, $context, $previous) + ], + [ + SplError::Underflow, + $context, + $previous, + new UnderflowException(SplError::Underflow, $context, $previous) + ], + [ + SplError::UnexpectedValue, + $context, + $previous, + new UnexpectedValueException(SplError::UnexpectedValue, $context, $previous + ) + ] + ]; + } catch (\Throwable $t) { echo $t; var_dump($t->context()); exit; } + } +} From a5f3ec99c6f0b9ec55e7fa41fdf81c9ba80995da Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 6 Mar 2024 23:49:46 -0500 Subject: [PATCH 08/13] tests --- tests/ErrorTestCase.php | 12 +++++ tests/ExceptableErrorTest.php | 99 +++++++++++++++++++++++++++++++++++ tests/SplErrorTest.php | 37 +++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 tests/ExceptableErrorTest.php diff --git a/tests/ErrorTestCase.php b/tests/ErrorTestCase.php index ce9ee08..7a6f869 100644 --- a/tests/ErrorTestCase.php +++ b/tests/ErrorTestCase.php @@ -43,8 +43,20 @@ */ abstract class ErrorTestCase extends TestCase { + abstract public static function codeProvider() : array; + abstract public static function messageProvider() : array; abstract public static function newExceptableProvider() : array; + /** @dataProvider codeProvider */ + public function testCode(Error $error, int $expected) { + $this->assertSame($error->code(), $expected, "Error returns wrong error code"); + } + + /** @dataProvider messageProvider */ + public function testMessage(Error $error, array $context, string $expected) { + $this->assertSame($expected, $error->message($context), "Error returns wrong error message"); + } + /** @dataProvider newExceptableProvider */ public function testNewExceptable( Error $error, diff --git a/tests/ExceptableErrorTest.php b/tests/ExceptableErrorTest.php new file mode 100644 index 0000000..7c34036 --- /dev/null +++ b/tests/ExceptableErrorTest.php @@ -0,0 +1,99 @@ + + * @copyright 2014 - 2024 + * @license GPL-3.0 (only) + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License, version 3. + * The right to apply the terms of later versions of the GPL is RESERVED. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + */ +declare(strict_types = 1); + +namespace at\exceptable\Tests; + +use Exception; + +use at\exceptable\ { + ExceptableError, + Spl\LogicException, + Spl\RuntimeException, + Tests\ErrorTestCase +}; + +class ExceptableErrorTest extends ErrorTestCase { + + public static function codeProvider() : array { + return [ + [ExceptableError::UnknownError, 0], + [ExceptableError::UnacceptableError, 1], + [ExceptableError::UncaughtException, 2], + [ExceptableError::HandlerFailed, 3] + ]; + } + + public static function messageProvider() : array { + return [ + [ + ExceptableError::UnknownError, + ["__rootMessage__" => "hello, world"], + "at\\exceptable\\ExceptableError.UnknownError: hello, world" + ], + [ + ExceptableError::UnacceptableError, + ["type" => "Foo"], + "at\\exceptable\\ExceptableError.UnacceptableError:" . + " Invalid Error type 'Foo' (expected enum implementing at\\exceptable\\Error)" + ], + [ + ExceptableError::UncaughtException, + ["__rootType__" => "FooException", "__rootMessage__" => "hello, world"], + "at\\exceptable\\ExceptableError.UncaughtException: Uncaught Exception (FooException): hello, world" + ], + [ + ExceptableError::HandlerFailed, + ["type" => "BadHandler", "__rootMessage__" => "hello, world"], + "at\\exceptable\\ExceptableError.HandlerFailed: ExceptionHandler (BadHandler) failed: hello, world" + ] + ]; + } + + public static function newExceptableProvider() : array { + $t = new Exception("hello, world"); + return [ + [ + ExceptableError::UnknownError, + [], + $t, + new RuntimeException(ExceptableError::UnknownError, [], $t) + ], + [ + ExceptableError::UnacceptableError, + ["type" => "Foo"], + null, + new LogicException(ExceptableError::UnacceptableError, ["type" => "Foo"]) + ], + [ + ExceptableError::UncaughtException, + [], + $t, + new RuntimeException(ExceptableError::UncaughtException, [], $t) + ], + [ + ExceptableError::HandlerFailed, + ["type" => "BadHandler"], + $t, + new LogicException(ExceptableError::HandlerFailed, ["type" => "BadHandler"], $t) + ] + ]; + } +} diff --git a/tests/SplErrorTest.php b/tests/SplErrorTest.php index 1da6074..1b1a619 100644 --- a/tests/SplErrorTest.php +++ b/tests/SplErrorTest.php @@ -53,6 +53,43 @@ */ class SplErrorTest extends ErrorTestCase { + public static function codeProvider() : array { + return [ + [SplError::BadFunctionCall, 1], + [SplError::BadMethodCall, 2], + [SplError::Domain, 3], + [SplError::InvalidArgument, 4], + [SplError::Length, 5], + [SplError::Logic, 6], + [SplError::OutOfBounds, 7], + [SplError::OutOfRange, 8], + [SplError::Overflow, 9], + [SplError::Range, 10], + [SplError::Runtime, 11], + [SplError::Underflow, 12], + [SplError::UnexpectedValue, 13] + ]; + } + + public static function messageProvider() : array { + $context = ["__rootMessage__" => "hello, world"]; + return [ + [SplError::BadFunctionCall, $context, "at\\exceptable\\Spl\\SplError.BadFunctionCall: hello, world"], + [SplError::BadMethodCall, $context, "at\\exceptable\\Spl\\SplError.BadMethodCall: hello, world"], + [SplError::Domain, $context, "at\\exceptable\\Spl\\SplError.Domain: hello, world"], + [SplError::InvalidArgument, $context, "at\\exceptable\\Spl\\SplError.InvalidArgument: hello, world"], + [SplError::Length, $context, "at\\exceptable\\Spl\\SplError.Length: hello, world"], + [SplError::Logic, $context, "at\\exceptable\\Spl\\SplError.Logic: hello, world"], + [SplError::OutOfBounds, $context, "at\\exceptable\\Spl\\SplError.OutOfBounds: hello, world"], + [SplError::OutOfRange, $context, "at\\exceptable\\Spl\\SplError.OutOfRange: hello, world"], + [SplError::Overflow, $context, "at\\exceptable\\Spl\\SplError.Overflow: hello, world"], + [SplError::Range, $context, "at\\exceptable\\Spl\\SplError.Range: hello, world"], + [SplError::Runtime, $context, "at\\exceptable\\Spl\\SplError.Runtime: hello, world"], + [SplError::Underflow, $context, "at\\exceptable\\Spl\\SplError.Underflow: hello, world"], + [SplError::UnexpectedValue, $context, "at\\exceptable\\Spl\\SplError.UnexpectedValue: hello, world"] + ]; + } + public static function newExceptableProvider() : array { try { $context = ["this" => "is only a test"]; From 304442fe2bb68d642b43509dbd0dc8445d4df47e Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 7 Mar 2024 19:58:25 -0500 Subject: [PATCH 09/13] more tests --- .gitignore | 1 + src/ExceptableError.php | 2 +- tests/ErrorTestCase.php | 25 ++++++++++++++----------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index da57c2a..86342c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ composer.lock vendor/* .phpunit.result.cache +.phpunit.cache/* diff --git a/src/ExceptableError.php b/src/ExceptableError.php index 7d80fac..4c39b34 100644 --- a/src/ExceptableError.php +++ b/src/ExceptableError.php @@ -44,7 +44,7 @@ enum ExceptableError : int implements Error { self::class => [ self::UnknownError->name => "{__rootMessage__}", self::UnacceptableError->name => - "Invalid Error type '{type}' (expected enum implementing " . Error::class . ")", + "Invalid Error type ''{type}'' (expected enum implementing " . Error::class . ")", self::UncaughtException->name => "Uncaught Exception ({__rootType__}): {__rootMessage__}", self::HandlerFailed->name => "ExceptionHandler ({type}) failed: {__rootMessage__}" ] diff --git a/tests/ErrorTestCase.php b/tests/ErrorTestCase.php index 7a6f869..3060fe8 100644 --- a/tests/ErrorTestCase.php +++ b/tests/ErrorTestCase.php @@ -64,17 +64,20 @@ public function testNewExceptable( ? Throwable $previous, Exceptable $expected ) { - $line = __LINE__ + 1; - $actual = $error($context, $previous); - - $this->assertExceptableIsExceptable($actual, get_class($expected)); - $this->assertExceptableOrigination($actual, __FILE__, $line); - $this->assertExceptableHasCode($actual, $expected->getCode()); - $this->assertExceptableHasMessage($actual, $expected->getMessage()); - $this->assertExceptableHasError($actual, $error); - $this->assertExceptableHasContext($actual, $expected->context()); - $this->assertExceptableHasPrevious($actual, $expected->getPrevious()); - $this->assertExceptableHasRoot($actual, $expected->getPrevious() ?? $actual); + // we're testing both __invoke() and newExceptable() - both should behave identically + foreach ([$error, $error->newExceptable(...)] as $method) { + $line = __LINE__ + 1; + $actual = $method($context, $previous); + + $this->assertExceptableIsExceptable($actual, get_class($expected)); + $this->assertExceptableOrigination($actual, __FILE__, $line); + $this->assertExceptableHasCode($actual, $expected->getCode()); + $this->assertExceptableHasMessage($actual, $expected->getMessage()); + $this->assertExceptableHasError($actual, $error); + $this->assertExceptableHasContext($actual, $expected->context()); + $this->assertExceptableHasPrevious($actual, $expected->getPrevious()); + $this->assertExceptableHasRoot($actual, $expected->getPrevious() ?? $actual); + } } /** From b8315949c204c122586d7905d528bc04e18aa66b Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 8 Mar 2024 14:56:43 -0500 Subject: [PATCH 10/13] tests --- src/IsError.php | 42 ++++++++++++++++++++++++++++++++--- tests/ExceptableErrorTest.php | 5 +++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/IsError.php b/src/IsError.php index 66be4ee..5a1ad61 100644 --- a/src/IsError.php +++ b/src/IsError.php @@ -20,6 +20,7 @@ namespace at\exceptable; use BackedEnum, + ResourceBundle, Throwable; use at\exceptable\ { @@ -77,9 +78,9 @@ public function exceptableType() : string { public function message(array $context) : string { assert($this instanceof Error); - $error = static::class . ".{$this->name}"; - $message = $this->makeMessage(static::class . ".{$this->name}", $context); - return empty($message) ? + $error = $this->messageKey(); + $message = $this->makeMessage($error, $context); + return (empty($message) || $this->isDefaultFormat($message)) ? $error : "{$error}: {$message}"; } @@ -114,4 +115,39 @@ private function adjustExceptable(Exceptable $x, int $adjust) : Exceptable { return $x; } + + /** + * Is the given message identical to the default message format string, and does it include formatting tokens + * (i.e., were replacements expected but none were actually made)? + * + * @param string $message The message to inspect + * @return bool True if the message is identical to the default format string; false otherwise + */ + private function isDefaultFormat(string $message) : bool { + if (preg_match("(\{.*\})", $message) < 1) { + return false; + } + + $defaultMessage = static::messageBundle(); + foreach (explode(".", $this->messageKey()) as $next) { + if (! $defaultMessage instanceof ResourceBundle) { + return false; + } + + $defaultMessage = $defaultMessage->get($next); + } + + return $message === $defaultMessage; + } + + /** + * Gets a key to look up this Error's message. + * + * @return string @see MessageRegistry::messageFrom() $key + */ + private function messageKey() : string { + assert($this instanceof Error); + + return static::class . ".{$this->name}"; + } } diff --git a/tests/ExceptableErrorTest.php b/tests/ExceptableErrorTest.php index 7c34036..1130e09 100644 --- a/tests/ExceptableErrorTest.php +++ b/tests/ExceptableErrorTest.php @@ -43,6 +43,11 @@ public static function codeProvider() : array { public static function messageProvider() : array { return [ + [ + ExceptableError::UnknownError, + [], + "at\\exceptable\\ExceptableError.UnknownError" + ], [ ExceptableError::UnknownError, ["__rootMessage__" => "hello, world"], From 07279a3625c8fff01955a34f619e0d514c407d11 Mon Sep 17 00:00:00 2001 From: Adrian Date: Sun, 10 Mar 2024 20:50:01 -0400 Subject: [PATCH 11/13] more tests --- src/Error.php | 4 +- src/IsError.php | 32 +- src/IsExceptable.php | 30 +- tests/ErrorTestCase.php | 162 ++++---- tests/ExceptableErrorTest.php | 35 +- tests/IsExceptableTest.php | 718 ---------------------------------- tests/SplErrorTest.php | 60 +-- 7 files changed, 177 insertions(+), 864 deletions(-) delete mode 100644 tests/IsExceptableTest.php diff --git a/src/Error.php b/src/Error.php index ea58963..3d1fc5d 100644 --- a/src/Error.php +++ b/src/Error.php @@ -47,10 +47,10 @@ public function exceptableType() : string; /** * Gets the error message for this case, using the given context. * - * @param array $context Exception context + * @param ?array $context Exception context * @return string An error message */ - public function message(array $context) : string; + public function message(array $context = []) : string; /** * Creates an Exceptable from this Error case. diff --git a/src/IsError.php b/src/IsError.php index 5a1ad61..4a83c67 100644 --- a/src/IsError.php +++ b/src/IsError.php @@ -69,16 +69,29 @@ public function code() : int { } } + /** + * Gets the full human-readable name for this Error. + * + * This is expected to be usable as the message $key. + * + * @return string Error class and name + */ + final public function errorName() : string { + assert($this instanceof Error); + + return static::class . ".{$this->name}"; + } + /** @see Error::exceptableType() */ public function exceptableType() : string { return RuntimeException::class; } /** @see Error::message() */ - public function message(array $context) : string { + public function message(array $context = []) : string { assert($this instanceof Error); - $error = $this->messageKey(); + $error = $this->errorName(); $message = $this->makeMessage($error, $context); return (empty($message) || $this->isDefaultFormat($message)) ? $error : @@ -129,7 +142,7 @@ private function isDefaultFormat(string $message) : bool { } $defaultMessage = static::messageBundle(); - foreach (explode(".", $this->messageKey()) as $next) { + foreach (explode(".", $this->errorName()) as $next) { if (! $defaultMessage instanceof ResourceBundle) { return false; } @@ -137,17 +150,6 @@ private function isDefaultFormat(string $message) : bool { $defaultMessage = $defaultMessage->get($next); } - return $message === $defaultMessage; - } - - /** - * Gets a key to look up this Error's message. - * - * @return string @see MessageRegistry::messageFrom() $key - */ - private function messageKey() : string { - assert($this instanceof Error); - - return static::class . ".{$this->name}"; + return $message === strtr($defaultMessage, ["''" => "'"]); } } diff --git a/src/IsExceptable.php b/src/IsExceptable.php index 1b8273b..a423a83 100644 --- a/src/IsExceptable.php +++ b/src/IsExceptable.php @@ -34,21 +34,6 @@ */ trait IsExceptable { - /** - * Finds the previous-most exception from the given exception. - * - * @param Throwable $t the exception to start from - * @return Throwable The root exception (may be the same as the starting exception) - */ - private static function findRoot(Throwable $t) : Throwable { - $root = $t; - while (($previous = $root->getPrevious()) instanceof Throwable) { - $root = $previous; - } - - return $root; - } - /** @see Exceptable::__construct() */ public function __construct( protected ? Error $error = null, @@ -123,4 +108,19 @@ public function root() : Throwable { protected function defaultError() : Error { return ExceptableError::UnknownError; } + + /** + * Finds the previous-most exception from the given exception. + * + * @param Throwable $t the exception to start from + * @return Throwable The root exception (may be the same as the starting exception) + */ + private function findRoot(Throwable $t) : Throwable { + $root = $t; + while (($previous = $root->getPrevious()) instanceof Throwable) { + $root = $previous; + } + + return $root; + } } diff --git a/tests/ErrorTestCase.php b/tests/ErrorTestCase.php index 3060fe8..c51810b 100644 --- a/tests/ErrorTestCase.php +++ b/tests/ErrorTestCase.php @@ -21,7 +21,8 @@ namespace at\exceptable\Tests; -use Exception, +use BackedEnum, + Exception, ResourceBundle, Throwable; @@ -33,28 +34,82 @@ }; /** - * Basic tests for the default Error implementations. + * Basic tests for Error implementations. * * @covers at\exceptable\IsError * - * Base class to test implementations of Error. + * Extend this class to test your Error. * - override error() to provide the Error to test * - override *Provider() methods to provide appropriate input and expectations */ abstract class ErrorTestCase extends TestCase { - abstract public static function codeProvider() : array; + /** @return array[] @see ErrorTestCase::testExceptableType() */ + abstract public static function exceptableTypeProvider() : array; + + /** @return array[] @see ErrorTestCase::testMessage() */ abstract public static function messageProvider() : array; + + /** @return array[] @see ErrorTestCase::testNewExceptable() */ abstract public static function newExceptableProvider() : array; - /** @dataProvider codeProvider */ - public function testCode(Error $error, int $expected) { + /** @return string Fully qualified classname of Error under test. */ + abstract protected static function errorType() : string; + + public static function errorProvider() : array { + return array_map( + fn ($error) => [$error], + static::errorType()::cases() + ); + } + + /** @dataProvider errorProvider */ + public function testCode(Error $error) { + if ($error instanceof BackedEnum) { + $expected = $error->value; + } else { + foreach ($error::cases() as $code => $case) { + if ($case === $error) { + $expected = $code + 1; + break; + } + } + } + $this->assertSame($error->code(), $expected, "Error returns wrong error code"); } - /** @dataProvider messageProvider */ - public function testMessage(Error $error, array $context, string $expected) { - $this->assertSame($expected, $error->message($context), "Error returns wrong error message"); + /** @dataProvider errorProvider */ + public function testErrorName(Error $error) : void { + $expected = $error::class . ".{$error->name}"; + $this->assertSame($expected, $error->errorName(), "Error does not report expected error name '{$expected}'"); + } + + /** @dataProvider exceptableTypeProvider */ + public function testExceptableType(Error $error, string $expected) : void { + $this->assertSame($expected, $error->exceptableType()); + } + + /** + * @dataProvider messageProvider + * + * @param Error $error The Error instance to test + * @param array $context Contextual information for the message + * @param string $expected The expected message + * @param bool $isContextRequired Does omitting context result in an invalid message? + */ + public function testMessage(Error $error, array $context, string $expected, bool $isContextRequired) { + $errorName = $error->errorName(); + + $this->assertSame( + "{$errorName}: {$expected}", + $error->message($context), + "Error does return expected message with context" + ); + + if ($isContextRequired) { + $this->assertSame($errorName, $error->message(), "Error does not return expected message without context"); + } } /** @dataProvider newExceptableProvider */ @@ -73,31 +128,24 @@ public function testNewExceptable( $this->assertExceptableOrigination($actual, __FILE__, $line); $this->assertExceptableHasCode($actual, $expected->getCode()); $this->assertExceptableHasMessage($actual, $expected->getMessage()); + $this->assertExceptableIsError($actual, $error); $this->assertExceptableHasError($actual, $error); + if ($previous instanceof Exceptable) { + $this->assertExceptableHasError($previous->error()); + } $this->assertExceptableHasContext($actual, $expected->context()); $this->assertExceptableHasPrevious($actual, $expected->getPrevious()); $this->assertExceptableHasRoot($actual, $expected->getPrevious() ?? $actual); } } - /** - * Asserts test subject is an instance of Exceptable and of the given FQCN. - * - * @param mixed $actual Test subject - * @param string $fqcn Fully-qualified classname of the intended Exceptable - */ + /** Asserts test subject is an instance of Exceptable and of the given FQCN. */ protected function assertExceptableIsExceptable($actual, string $fqcn) : void { $this->assertInstanceOf(Exceptable::class, $actual, "Exceptable is not Exceptable"); $this->assertInstanceOf($fqcn, $actual, "Exceptable is not an instance of {$fqcn}"); } - /** - * Asserts test subject has the expected origin file and line number. - * - * @param mixed $actual Test subject - * @param string $file Expected filename - * @param int $line Expected line number - */ + /** Asserts test subject has the expected origin file and line number. */ protected function assertExceptableOrigination(Exceptable $actual, string $file, int $line) : void { $this->assertSame( $file, @@ -111,26 +159,7 @@ protected function assertExceptableOrigination(Exceptable $actual, string $file, ); } - /** - * Asserts test subject has the expected error case. - * - * @param mixed $actual Test subject - * @param Error $rttot Expected error case - */ - protected function assertExceptableHasError(Exceptable $actual, Error $error) : void { - $this->assertSame( - $error, - $actual->error(), - "Exceptable does not report expected error case '{$error->name}'" - ); - } - - /** - * Asserts test subject has the expected code. - * - * @param mixed $actual Test subject - * @param int $code Expected exceptable code - */ + /** Asserts test subject has the expected code. */ protected function assertExceptableHasCode(Exceptable $actual, int $code) : void { $this->assertSame( $code, @@ -139,12 +168,7 @@ protected function assertExceptableHasCode(Exceptable $actual, int $code) : void ); } - /** - * Asserts test subject has the expected (possibly formatted) message. - * - * @param mixed $actual Test subject - * @param string #message Expected exceptable message - */ + /** Asserts test subject has the expected (possibly formatted) message. */ protected function assertExceptableHasMessage(Exceptable $actual, string $message) : void { $this->assertSame( $message, @@ -153,12 +177,7 @@ protected function assertExceptableHasMessage(Exceptable $actual, string $messag ); } - /** - * Asserts test subject has the expected contextual information. - * - * @param mixed $actual Test subject - * @param ?array $context Expected contextual information - */ + /** Asserts test subject has the expected contextual information. */ protected function assertExceptableHasContext(Exceptable $actual, ? array $context) : void { $actual = $actual->context(); @@ -178,12 +197,7 @@ protected function assertExceptableHasContext(Exceptable $actual, ? array $conte } } - /** - * Asserts test subject has the expected previous Exception. - * - * @param mixed $actual Test subject - * @param ?Throwable $previous Expected previous exception - */ + /** Asserts test subject has the expected previous Exception. */ protected function assertExceptableHasPrevious(Exceptable $actual, ?Throwable $previous) : void { $message = isset($previous) ? "getPrevious() does not report expected exception (" . get_class($previous) . ")" : @@ -191,12 +205,7 @@ protected function assertExceptableHasPrevious(Exceptable $actual, ?Throwable $p $this->assertSame($previous, $actual->getPrevious(), $message); } - /** - * Asserts test subject has the expected root Exception. - * - * @param mixed $actual Test subject - * @param ?Throwable $root Expected root (most-previous) exception - */ + /** Asserts test subject has the expected root Exception. */ protected function assertExceptableHasRoot(Exceptable $actual, Throwable $root) : void { $fqcn = get_class($root); $this->assertSame( @@ -205,4 +214,25 @@ protected function assertExceptableHasRoot(Exceptable $actual, Throwable $root) "getPrevious() does not report expected root exception ({$fqcn})" ); } + + /** Asserts test subject has the given Error case. */ + protected function assertExceptableHasError(Exceptable $actual, Error $error) : void { + $this->assertTrue( + $actual->has($error), + "Exceptable->has() does not have expected Error {$error->errorName()}" + ); + } + + /** Asserts test subject matches the expected Error case. */ + protected function assertExceptableIsError(Exceptable $actual, Error $error) : void { + $this->assertSame( + $actual->error(), + $error, + "Exceptable does not match expected Error {$error->errorName()}" + ); + $this->assertTrue( + $actual->is($error), + "Exceptable->is() does not match expected Error {$error->errorName()}" + ); + } } diff --git a/tests/ExceptableErrorTest.php b/tests/ExceptableErrorTest.php index 1130e09..d1ed5ca 100644 --- a/tests/ExceptableErrorTest.php +++ b/tests/ExceptableErrorTest.php @@ -32,42 +32,35 @@ class ExceptableErrorTest extends ErrorTestCase { - public static function codeProvider() : array { + public static function exceptableTypeProvider() : array { return [ - [ExceptableError::UnknownError, 0], - [ExceptableError::UnacceptableError, 1], - [ExceptableError::UncaughtException, 2], - [ExceptableError::HandlerFailed, 3] + [ExceptableError::UnknownError, RuntimeException::class], + [ExceptableError::UnacceptableError, LogicException::class], + [ExceptableError::UncaughtException, RuntimeException::class], + [ExceptableError::HandlerFailed, LogicException::class] ]; } public static function messageProvider() : array { return [ - [ - ExceptableError::UnknownError, - [], - "at\\exceptable\\ExceptableError.UnknownError" - ], - [ - ExceptableError::UnknownError, - ["__rootMessage__" => "hello, world"], - "at\\exceptable\\ExceptableError.UnknownError: hello, world" - ], + [ExceptableError::UnknownError, ["__rootMessage__" => "hello, world"], "hello, world", true], [ ExceptableError::UnacceptableError, ["type" => "Foo"], - "at\\exceptable\\ExceptableError.UnacceptableError:" . - " Invalid Error type 'Foo' (expected enum implementing at\\exceptable\\Error)" + "Invalid Error type 'Foo' (expected enum implementing at\\exceptable\\Error)", + true ], [ ExceptableError::UncaughtException, ["__rootType__" => "FooException", "__rootMessage__" => "hello, world"], - "at\\exceptable\\ExceptableError.UncaughtException: Uncaught Exception (FooException): hello, world" + "Uncaught Exception (FooException): hello, world", + true ], [ ExceptableError::HandlerFailed, ["type" => "BadHandler", "__rootMessage__" => "hello, world"], - "at\\exceptable\\ExceptableError.HandlerFailed: ExceptionHandler (BadHandler) failed: hello, world" + "ExceptionHandler (BadHandler) failed: hello, world", + true ] ]; } @@ -101,4 +94,8 @@ public static function newExceptableProvider() : array { ] ]; } + + protected static function errorType() : string { + return ExceptableError::class; + } } diff --git a/tests/IsExceptableTest.php b/tests/IsExceptableTest.php deleted file mode 100644 index 11a142d..0000000 --- a/tests/IsExceptableTest.php +++ /dev/null @@ -1,718 +0,0 @@ - - * @copyright 2014 - 2023 - * @license GPL-3.0 (only) - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License, version 3. - * The right to apply the terms of later versions of the GPL is RESERVED. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; - * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - * See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with this program. - * If not, see . - */ -declare(strict_types = 1); - -namespace at\exceptable\Tests; - -use Exception, - ResourceBundle, - Throwable; - -use at\exceptable\ { - Exceptable, - ExceptableError, - IsExceptable, - Tests\TestCase -}; - -/** - * Basic tests for the default Exceptable implementation (the IsExceptable trait). - * - * @covers at\exceptable\IsExceptable - * - * This test case can (should) be extended to test other concrete implementations: - * - override exceptableFQCN() method to provide the name of the exceptable to test - * - override *Provider methods to provide appropriate input and expectations - */ -abstract class IsExceptableTest extends TestCase { - - /** - * Path to resource bundle used for this test. - * This is specific to the base test suite and MUST NOT be used by child tests. - * - * @var string - */ - private const RESOURCE_BUNDLE = __DIR__ . "/../resources/language/"; - - /** - * @dataProvider newExceptableProvider - * - * @param int $code Exceptable code to test - * @param ?array $context Contextual information to provide - * @param ?Throwable $previous Previous exception to provide - * @param string $message Expected exceprable message - */ - public function testNewExceptable( - int $code, - ?array $context, - ?Throwable $previous, - string $message - ) : void { - $fqcn = $this->exceptableFQCN(); - - if (isset($previous)) { - $line = __LINE__ + 1; - $actual = new $fqcn($code, $context, $previous); - } elseif (isset($context)) { - $line = __LINE__ + 1; - $actual = new $fqcn($code, $context); - } else { - $line = __LINE__ + 1; - $actual = new $fqcn($code); - } - - $this->assertIsExceptable($actual, $fqcn); - $this->assertOrigination($actual, __FILE__, $line); - $this->assertHasCode($actual, $code); - $this->assertHasMessage($actual, $message); - $this->assertHasContext($actual, $context); - $this->assertHasPrevious($actual, $previous); - $this->assertHasRoot($actual, $previous ?? $actual); - } - - /** - * @see ::testNewExceptable() - * @dataProvider newExceptableProvider - */ - public function testCreateExceptable( - int $code, - ?array $context, - ?Throwable $previous, - string $message - ) : void { - $fqcn = $this->exceptableFQCN(); - - if (isset($previous)) { - $line = __LINE__ + 1; - $actual = $fqcn::create($code, $context, $previous); - } elseif (isset($context)) { - $line = __LINE__ + 1; - $actual = $fqcn::create($code, $context); - } else { - $line = __LINE__ + 1; - $actual = $fqcn::create($code); - } - - $this->assertIsExceptable($actual, $fqcn); - $this->assertOrigination($actual, __FILE__, $line); - $this->assertHasCode($actual, $code); - $this->assertHasMessage($actual, $message); - $this->assertHasContext($actual, $context); - $this->assertHasPrevious($actual, $previous); - $this->assertHasRoot($actual, $previous ?? $actual); - } - - /** - * @see ::testNewExceptable() - * @dataProvider newExceptableProvider - */ - public function testThrowExceptable( - int $code, - ?array $context, - ?Throwable $previous, - string $message - ) : void { - try { - $fqcn = $this->exceptableFQCN(); - - $actual = null; - if (isset($previous)) { - $line = __LINE__ + 1; - $fqcn::throw($code, $context, $previous); - } elseif (isset($context)) { - $line = __LINE__ + 1; - $fqcn::throw($code, $context); - } else { - $line = __LINE__ + 1; - $fqcn::throw($code); - } - } catch (Exceptable $e) { - $actual = $e; - } - - $this->assertNotNull($actual, "throw() did not throw an Exceptable"); - - $this->assertIsExceptable($actual, $fqcn); - $this->assertOrigination($actual, __FILE__, $line); - $this->assertHasCode($actual, $code); - $this->assertHasMessage($actual, $message); - $this->assertHasContext($actual, $context); - $this->assertHasPrevious($actual, $previous); - $this->assertHasRoot($actual, $previous ?? $actual); - } - - /** - * @return array[] Testcases - @see ::testNewExceptable() - */ - public function newExceptableProvider() : array { - return [ - "UNKNOWN_FOO with code only" => [ - TestExceptable::UNKNOWN_FOO, - null, - null, - "unknown foo" - ], - "UNKNOWN_FOO with context" => [ - TestExceptable::UNKNOWN_FOO, - ["foo" => "foobedobedoo"], - null, - "i don't know who, you think is foo, but it's not foobedobedoo" - ], - "UNKNOWN_FOO with context and previous exception" => [ - TestExceptable::UNKNOWN_FOO, - ["foo" => "foobedobedoo"], - new Exception("it's not just you"), - "i don't know who, you think is foo, but it's not foobedobedoo" - ], - "UNKNOWN_FOO with previous exception" => [ - TestExceptable::UNKNOWN_FOO, - null, - new Exception("it's not just you"), - "unknown foo" - ], - - "TOO_MUCH_FOO with code only" => [ - TestExceptable::TOO_MUCH_FOO, - null, - null, - "too much foo" - ], - "TOO_MUCH_FOO with context" => [ - TestExceptable::TOO_MUCH_FOO, - ["count" => 42], - null, - "too much foo is bad for you (got 42 foo)" - ], - "TOO_MUCH_FOO with context and previous exception" => [ - TestExceptable::TOO_MUCH_FOO, - ["count" => 42], - new Exception("it's not just you"), - "too much foo is bad for you (got 42 foo)" - ], - "TOO_MUCH_FOO with previous exception" => [ - TestExceptable::TOO_MUCH_FOO, - null, - new Exception("it's not just you"), - "too much foo" - ] - ]; - } - - /** - * @dataProvider infoProvider - * - * @param int $code Known exceptable code to get info for - * @param array $expected Information expected to be returned - */ - public function testGetInfo(int $code, array $expected) : void { - $fqcn = $this->exceptableFQCN(); - - $actual = $fqcn::getInfo($code); - - $this->assertIsArray($actual, "getInfo() did not return an array"); - - $this->assertArrayHasKey("code", $actual, "getInfo()[code] is missing"); - $this->assertIsInt($actual["code"], "getInfo()[code] is not a integer"); - - $this->assertArrayHasKey("message", $actual, "getInfo()[message] is missing"); - $this->assertIsString($actual["message"], "getInfo()[message] is not a string"); - - $this->assertArrayHasKey("format", $actual, "getInfo()[format] is missing"); - if (isset($actual["format"])) { - $this->assertIsString($actual["format"], "getInfo()[format] is not a string|null"); - } - - // these are the required keys and will fail if expectations are not provided - $expected += ["code" => $code, "message" => null, "format" => null]; - foreach ($expected as $key => $expectedValue) { - $this->assertArrayHasKey($key, $actual, "getInfo()[{$key}] is missing"); - $this->assertSame( - $expectedValue, - $actual[$key], - "getInfo()[{$key}] does not match expected value {$this->asString($expectedValue)}" - ); - } - } - - /** - * @dataProvider infoProvider - * - * @param int $code Known exceptable code to get info for - */ - public function testHasInfo(int $code) : void { - $fqcn = $this->exceptableFQCN(); - - $this->assertTrue($fqcn::hasInfo($code), "{$fqcn} reports it has no info for code {$code}"); - } - - /** - * @return array[] Testcases - @see testGetInfo() - */ - public function infoProvider() : array { - return [ - "TestExceptable::UNKNOWN_FOO" => [ - TestExceptable::UNKNOWN_FOO, - TestExceptable::INFO[TestExceptable::UNKNOWN_FOO] - ], - "TestExceptable::TOO_MUCH_FOO" => [ - TestExceptable::TOO_MUCH_FOO, - TestExceptable::INFO[TestExceptable::TOO_MUCH_FOO] - ] - ]; - } - - /** - * @dataProvider badInfoProvider - * - * @param int $code Unknown exceptable code to get info for - */ - public function testGetBadInfo(int $code) : void { - $fqcn = $this->exceptableFQCN(); - - $this->expectThrowable( - new ExceptableError(ExceptableError::NO_SUCH_CODE, ["code" => $code]), - self::EXPECT_THROWABLE_CODE | self::EXPECT_THROWABLE_MESSAGE - ); - - $fqcn::getInfo($code); - } - - /** - * @dataProvider badInfoProvider - * - * @param int $code Known exceptable code to get info for - */ - public function testNotHasInfo(int $code) : void { - $fqcn = $this->exceptableFQCN(); - - $this->assertFalse($fqcn::hasInfo($code), "{$fqcn} reports it has info for code {$code}"); - } - - /** - * @return array[] Testcases - @see testGetBadInfo() - */ - public function badInfoProvider() : array { - return [[66]]; - } - - /** - * @dataProvider isProvider - * - * @param int $code Unknown exceptable code to get info for - */ - public function testIs(int $code, Throwable $e, bool $expected) : void { - $fqcn = $this->exceptableFQCN(); - - $e_code = get_class($e) . "::{$e->getCode()}"; - - if ($expected) { - $this->assertTrue( - $fqcn::is($e, $code), - "{$e_code} does not match expected {$fqcn}::{$code}" - ); - - return; - } - - $this->assertFalse($fqcn::is($e, $code), "{$e_code} matches unexpected {$fqcn}::{$code}"); - } - - /** - * @return array[] Testcases - @see testIs() - */ - public function isProvider() : array { - return [ - "same class and code" => [ - TestExceptable::UNKNOWN_FOO, - new TestExceptable(TestExceptable::UNKNOWN_FOO), - true - ], - "same class, different code" => [ - TestExceptable::UNKNOWN_FOO, - new TestExceptable(TestExceptable::TOO_MUCH_FOO), - false - ], - "subclass, same code" => [ - TestExceptable::UNKNOWN_FOO, - new class (TestExceptable::UNKNOWN_FOO) extends TestExceptable {}, - false - ], - "non-exceptable, same code" => [ - TestExceptable::UNKNOWN_FOO, - new Exception("", TestExceptable::TOO_MUCH_FOO), - false - ], - "non-exceptable, different code" => [ - TestExceptable::UNKNOWN_FOO, - new Exception("", 66), - false - ] - ]; - } - - /** - * @dataProvider localizationProvider - * - * @param string $locale Locale to test - * @param string $resource_bundle ICU resource bundle directory - */ - public function testLocalization(string $locale, string $resource_bundle) : void { - if (! extension_loaded("intl")) { - $this->markTestSkipped("ext/intl is not loaded"); - return; - } - - $fqcn = $this->exceptableFQCN(); - - // assure clean state (localization not initialized) - $this->setNonpublicStaticProperty($fqcn, "locale", null); - $this->setNonpublicStaticProperty($fqcn, "messages", null); - - $messages = new ResourceBundle($locale, $resource_bundle); - $fqcn::localize($locale, $messages); - - $this->assertSame( - $this->getNonpublicStaticProperty($fqcn, "locale"), - $locale, - "\$locale does not contain expected locale '{$locale}'" - ); - $this->assertSame( - $this->getNonpublicStaticProperty($fqcn, "messages"), - $messages, - "\$messages does not contain expected message bundle" - ); - } - - /** - * @return array[] Testcases - @see testLocalization() - */ - public function localizationProvider() : array { - return [ - "root" => ["en", self::RESOURCE_BUNDLE], - "other" => ["ko_KR", self::RESOURCE_BUNDLE] - ]; - } - - /** - * @dataProvider localizedMessagesProvider - * - * @param ?string $locale Locale to test - * @param ?string $resource_bundle ICU resource bundle directory - * @param int $code Exceptable code to test - * @param ?array $context Contextual information - * @param ?Throwable $previous Previous exception to provide - * @param string $expected Expected message (localized, formatted) - */ - public function testLocalizedMessages( - ?string $locale, - ?string $resource_bundle, - int $code, - ?array $context, - ?Throwable $previous, - string $expected - ) : void { - $fqcn = $this->exceptableFQCN(); - - // assure clean state (localization not initialized) - $this->setNonpublicStaticProperty($fqcn, "locale", null); - $this->setNonpublicStaticProperty($fqcn, "messages", null); - - if (isset($locale, $resource_bundle)) { - if (! extension_loaded("intl")) { - $this->markTestSkipped("ext/intl is not loaded"); - return; - } - - $fqcn::localize($locale, new ResourceBundle($locale, $resource_bundle)); - } - - $this->assertSame( - $expected, - $fqcn::create($code, $context, $previous)->getMessage(), - "getMessage() does not report expected localized message" - ); - } - - /** - * ko_KR was chosen at random; I don't speak Korean nor do I have a translator. - * If someone would like to provide better (or more) translations, that would be welcome. - * - * @return array[] Testcases - @see testLocalizedMessages() - */ - public function localizedMessagesProvider() : array { - return [ - "no localization, no context" => [ - null, - null, - TestExceptable::UNKNOWN_FOO, - null, - null, - "unknown foo" - ], - "no localization, context" => [ - null, - null, - TestExceptable::UNKNOWN_FOO, - ["foo" => "foobedobedoo"], - null, - "i don't know who, you think is foo, but it's not foobedobedoo" - ], - - "root localization, no context" => [ - "en", - self::RESOURCE_BUNDLE, - TestExceptable::UNKNOWN_FOO, - null, - null, - "unknown foo" - ], - "root localization, wrong context" => [ - "en", - self::RESOURCE_BUNDLE, - TestExceptable::UNKNOWN_FOO, - ["bar" => "none"], - null, - "unknown foo" - ], - "root localization, context" => [ - "en", - self::RESOURCE_BUNDLE, - TestExceptable::UNKNOWN_FOO, - ["foo" => "foobedobedoo"], - null, - "i don't know who, you think is foo, but it's not foobedobedoo" - ], - - "other localization, no context" => [ - "ko_KR", - self::RESOURCE_BUNDLE, - TestExceptable::UNKNOWN_FOO, - null, - null, - "unknown foo" - ], - "other localization, wrong context" => [ - "ko_KR", - self::RESOURCE_BUNDLE, - TestExceptable::UNKNOWN_FOO, - ["bar" => "none"], - null, - "unknown foo" - ], - "other localization, context" => [ - "ko_KR", - self::RESOURCE_BUNDLE, - TestExceptable::UNKNOWN_FOO, - ["foo" => "foobedobedoo"], - null, - "나는 당신이 foo 라고 생각하는 사람을 모르지만 foobedobedoo 가 아닙니다" - ], - - "no localization, complex format" => [ - null, - null, - TestExceptable::TOO_MUCH_FOO, - ["count" => 42], - null, - "too much foo is bad for you (got 42 foo)" - ], - "root localization, complex format" => [ - "en", - self::RESOURCE_BUNDLE, - TestExceptable::TOO_MUCH_FOO, - ["count" => 42], - null, - "too much foo is bad for you (got forty-two foo)" - ], - "other localization, complex format" => [ - "ko_KR", - self::RESOURCE_BUNDLE, - TestExceptable::TOO_MUCH_FOO, - ["count" => 42], - null, - "너무 많은 foo는 당신에게 나쁩니다 (사십이 foo를 얻었습니다)" - ], - - "unsupported locale, no context" => [ - "nd_ZW", - self::RESOURCE_BUNDLE, - TestExceptable::UNKNOWN_FOO, - null, - null, - "unknown foo" - ], - "unsupported locale, with localization and context" => [ - "nd_ZW", - self::RESOURCE_BUNDLE, - TestExceptable::UNKNOWN_FOO, - ["foo" => "foobedobedoo"], - null, - "i don't know who, you think is foo, but it's not foobedobedoo" - ] - ]; - } - - /** - * Asserts test subject is an instance of Exceptable and of the given FQCN. - * - * @param mixed $actual Test subject - * @param string $fqcn Fully-qualified classname of the intended Exceptable - */ - protected function assertIsExceptable($actual, string $fqcn) : void { - $this->assertInstanceOf(Exceptable::class, $actual, "Exceptable is not Exceptable"); - $this->assertInstanceOf($fqcn, $actual, "Exceptable is not an instance of {$fqcn}"); - } - - /** - * Asserts test subject has the expected origin file and line number. - * - * @param mixed $actual Test subject - * @param string $file Expected filename - * @param int $line Expected line number - */ - protected function assertOrigination(Exceptable $actual, string $file, int $line) : void { - $this->assertSame( - $file, - $actual->getFile(), - "Exceptable does not report expected filename ('{$file}')" - ); - $this->assertSame( - $line, - $actual->getLine(), - "Exceptable does not report expected line number ({$line})" - ); - } - - /** - * Asserts test subject has the expected code. - * - * @param mixed $actual Test subject - * @param int $code Expected exceptable code - */ - protected function assertHasCode(Exceptable $actual, int $code) : void { - $this->assertTrue($actual::hasInfo($code), "Exceptable does not understand code {$code}"); - $this->assertSame( - $code, - $actual->getCode(), - "Exceptable does not report expected code ({$code})" - ); - } - - /** - * Asserts test subject has the expected (possibly formatted) message. - * - * @param mixed $actual Test subject - * @param string #message Expected exceptable message - */ - protected function assertHasMessage(Exceptable $actual, string $message) : void { - $this->assertSame( - $message, - $actual->getMessage(), - "Exceptable does not report expected message ('{$message}')" - ); - } - - /** - * Asserts test subject has the expected contextual information. - * - * @param mixed $actual Test subject - * @param ?array $context Expected contextual information - */ - protected function assertHasContext(Exceptable $actual, ?array $context) : void { - $actual = $actual->getContext(); - - $this->assertArrayHasKey( - "__rootMessage__", - $actual, - "getContext()[___rootMessage_] is missing" - ); - $this->assertIsString($actual["__rootMessage__"]); - - if (isset($context)) { - foreach ($context as $key => $value) { - $this->assertArrayHasKey($key, $actual, "getContext()[{$key}] is missing"); - - $this->assertSame( - $value, - $actual[$key], - "getContext()[{$key}] does not hold expected value ({$this->asString($value)})" - ); - } - } - } - - /** - * Asserts test subject has the expected previous Exception. - * - * @param mixed $actual Test subject - * @param ?Throwable $previous Expected previous exception - */ - protected function assertHasPrevious(Exceptable $actual, ?Throwable $previous) : void { - $message = isset($previous) ? - "getPrevious() does not report expected exception (" . get_class($previous) . ")" : - "getPrevious() reports a previous exception but none was expected"; - $this->assertSame($previous, $actual->getPrevious(), $message); - } - - /** - * Asserts test subject has the expected root Exception. - * - * @param mixed $actual Test subject - * @param ?Throwable $root Expected root (most-previous) exception - */ - protected function assertHasRoot(Exceptable $actual, Throwable $root) : void { - $fqcn = get_class($root); - $this->assertSame( - $root, - $actual->getRoot(), - "getPrevious() does not report expected root exception ({$fqcn})" - ); - } - - /** - * The Fully-qualified Exceptable classname for this test. - * - * @return string - */ - protected function exceptableFQCN() : string { - return TestExceptable::class; - } -} - -/** Default test class. */ -class TestExceptable extends Exception implements Exceptable { - use IsExceptable; - - const UNKNOWN_FOO = 1; - const TOO_MUCH_FOO = 2; - - const INFO = [ - self::UNKNOWN_FOO => [ - "message" => "unknown foo", - "format" => "i don't know who, you think is foo, but it's not {foo}", - "formatKey" => "exceptable.tests.testexceptable.unknownfoo" - ], - self::TOO_MUCH_FOO => [ - "message" => "too much foo", - "format" => "too much foo is bad for you (got {count} foo)", - "formatKey" => "exceptable.tests.testexceptable.toomuchfoo" - ] - ]; -} diff --git a/tests/SplErrorTest.php b/tests/SplErrorTest.php index 1b1a619..5e3eea8 100644 --- a/tests/SplErrorTest.php +++ b/tests/SplErrorTest.php @@ -53,45 +53,44 @@ */ class SplErrorTest extends ErrorTestCase { - public static function codeProvider() : array { + public static function exceptableTypeProvider() : array { return [ - [SplError::BadFunctionCall, 1], - [SplError::BadMethodCall, 2], - [SplError::Domain, 3], - [SplError::InvalidArgument, 4], - [SplError::Length, 5], - [SplError::Logic, 6], - [SplError::OutOfBounds, 7], - [SplError::OutOfRange, 8], - [SplError::Overflow, 9], - [SplError::Range, 10], - [SplError::Runtime, 11], - [SplError::Underflow, 12], - [SplError::UnexpectedValue, 13] + [SplError::BadFunctionCall, BadFunctionCallException::class], + [SplError::BadMethodCall, BadMethodCallException::class], + [SplError::Domain, DomainException::class], + [SplError::InvalidArgument, InvalidArgumentException::class], + [SplError::Length, LengthException::class], + [SplError::Logic, LogicException::class], + [SplError::OutOfBounds, OutOfBoundsException::class], + [SplError::OutOfRange, OutOfRangeException::class], + [SplError::Overflow, OverflowException::class], + [SplError::Range, RangeException::class], + [SplError::Runtime, RuntimeException::class], + [SplError::Underflow, UnderflowException::class], + [SplError::UnexpectedValue, UnexpectedValueException::class] ]; } public static function messageProvider() : array { $context = ["__rootMessage__" => "hello, world"]; return [ - [SplError::BadFunctionCall, $context, "at\\exceptable\\Spl\\SplError.BadFunctionCall: hello, world"], - [SplError::BadMethodCall, $context, "at\\exceptable\\Spl\\SplError.BadMethodCall: hello, world"], - [SplError::Domain, $context, "at\\exceptable\\Spl\\SplError.Domain: hello, world"], - [SplError::InvalidArgument, $context, "at\\exceptable\\Spl\\SplError.InvalidArgument: hello, world"], - [SplError::Length, $context, "at\\exceptable\\Spl\\SplError.Length: hello, world"], - [SplError::Logic, $context, "at\\exceptable\\Spl\\SplError.Logic: hello, world"], - [SplError::OutOfBounds, $context, "at\\exceptable\\Spl\\SplError.OutOfBounds: hello, world"], - [SplError::OutOfRange, $context, "at\\exceptable\\Spl\\SplError.OutOfRange: hello, world"], - [SplError::Overflow, $context, "at\\exceptable\\Spl\\SplError.Overflow: hello, world"], - [SplError::Range, $context, "at\\exceptable\\Spl\\SplError.Range: hello, world"], - [SplError::Runtime, $context, "at\\exceptable\\Spl\\SplError.Runtime: hello, world"], - [SplError::Underflow, $context, "at\\exceptable\\Spl\\SplError.Underflow: hello, world"], - [SplError::UnexpectedValue, $context, "at\\exceptable\\Spl\\SplError.UnexpectedValue: hello, world"] + [SplError::BadFunctionCall, $context, "hello, world", true], + [SplError::BadMethodCall, $context, "hello, world", true], + [SplError::Domain, $context, "hello, world", true], + [SplError::InvalidArgument, $context, "hello, world", true], + [SplError::Length, $context, "hello, world", true], + [SplError::Logic, $context, "hello, world", true], + [SplError::OutOfBounds, $context, "hello, world", true], + [SplError::OutOfRange, $context, "hello, world", true], + [SplError::Overflow, $context, "hello, world", true], + [SplError::Range, $context, "hello, world", true], + [SplError::Runtime, $context, "hello, world", true], + [SplError::Underflow, $context, "hello, world", true], + [SplError::UnexpectedValue, $context, "hello, world", true] ]; } public static function newExceptableProvider() : array { - try { $context = ["this" => "is only a test"]; $previous = new Exception("this is the root exception"); return [ @@ -175,6 +174,9 @@ public static function newExceptableProvider() : array { ) ] ]; - } catch (\Throwable $t) { echo $t; var_dump($t->context()); exit; } + } + + protected static function errorType() : string { + return SplError::class; } } From daa92d8232c1e2107e0f597cf1c9f539ee0dc1ae Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 13 Mar 2024 21:49:52 -0400 Subject: [PATCH 12/13] handler tests --- README.md | 22 +++++++--- src/Error.php | 2 +- tests/ErrorTestCase.php | 2 +- tests/HandlerTest.php | 95 +++++++++++++++++++++-------------------- tests/stubs.php | 12 +++--- 5 files changed, 71 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 9c20868..5770021 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ a quick taste use at\exceptable\ { Error, - Handler, + Handler\ExceptionHandler, + Handler\Handler, IsError }; @@ -45,8 +46,13 @@ enum FooError : int implements Error { // Fatal error: Uncaught at\exceptable\Spl\RuntimeException: i don't know who, you think is foo, but it's not foobedobedoo $handler = new Handler(); -$handler->onException(function($e) { error_log($e->getMessage()); return true; }) - ->register(); +$handler->onException(new class() implements ExceptionHandler { + public function run(Throwable $t) { + error_log($t->getMessage()); + return true; + } + }); +$handler->register(); (FooError::UnknownFoo)(["foo" => "foobedobedoo"]); // in your error log: @@ -56,7 +62,7 @@ $handler->onException(function($e) { error_log($e->getMessage()); return true; } errors as values ---------------- -Having errors available to your application as normal values also makes _not_ throwing exceptions a viable solution. The _Result pattern_, for example, is a functional programming approach to error handling that treats error conditions as normal, expected return values. This can encourage you to consider how to handle error cases more carefully and closer to the source, as well as being a benefit to static analysis and comprehensibility in general. See [Larry Garfield's excellent article](https://peakd.com/hive-168588/@crell/much-ado-about-null) for more. +Having errors available to your application as normal values also makes _not_ throwing exceptions a viable solution. The _Result pattern_, for example, is a functional programming approach to error handling that treats error conditions as normal, expected return values. This can encourage you to consider how to handle error cases more carefully and closer to their source, as well as being a benefit to static analysis and comprehensibility in general. See [Larry Garfield's excellent article](https://peakd.com/hive-168588/@crell/much-ado-about-null) for more. ```php message(); // outputs "ooh noooooooooooooooooo!" - $bool = !! $bool; - $newResult = foo($bool); + $bool = ! $bool; + $result = foo($bool); } -echo $newResult; +echo $result; // outputs "woooooooooooooooooo hoo!" ``` ...and if you want to make _everybody_ mad, you can still throw them. @@ -108,7 +114,9 @@ Version 5.0 - ICU messaging system overhauled and published to its own package! Check out [php-enspired/peekaboo](https://packagist.org/packages/php-enspired/peekaboo) - using _exceptable_ means you get it for free, so take advantage! - Introduces the _Error_ interface for enums, making errors into first-class citizens and opening up the ability to handle errors as values. + Adds an `SplError` enum for php's built-in exception types. - Reworks and improves functionality for Exceptables and the Handler. + Error / Exception / Shutdown Handlers now have explicit interfaces, as do debug log entries. [Read the release notes.](https://github.com/php-enspired/exceptable/wiki/new-in-5.0) diff --git a/src/Error.php b/src/Error.php index 3d1fc5d..0db9f31 100644 --- a/src/Error.php +++ b/src/Error.php @@ -47,7 +47,7 @@ public function exceptableType() : string; /** * Gets the error message for this case, using the given context. * - * @param ?array $context Exception context + * @param array $context Exception context * @return string An error message */ public function message(array $context = []) : string; diff --git a/tests/ErrorTestCase.php b/tests/ErrorTestCase.php index c51810b..01e8c47 100644 --- a/tests/ErrorTestCase.php +++ b/tests/ErrorTestCase.php @@ -104,7 +104,7 @@ public function testMessage(Error $error, array $context, string $expected, bool $this->assertSame( "{$errorName}: {$expected}", $error->message($context), - "Error does return expected message with context" + "Error does not return expected message with context" ); if ($isContextRequired) { diff --git a/tests/HandlerTest.php b/tests/HandlerTest.php index a485d66..efff6d5 100644 --- a/tests/HandlerTest.php +++ b/tests/HandlerTest.php @@ -21,7 +21,8 @@ namespace at\exceptable\Tests; -use ErrorException, +use Closure, + ErrorException, Exception, ResourceBundle, Throwable; @@ -29,7 +30,9 @@ use at\exceptable\ { Exceptable, ExceptableError, - Handler, + Handler\ExceptionHandler, + Handler\Handler, + Handler\Options, IsExceptable, Tests\TestCase }; @@ -51,7 +54,7 @@ * * @covers at\exceptable\Handler */ -abstract class HandlerTest extends TestCase { +class HandlerTest extends TestCase { /** @var array[] List of reported error handling function invocations. */ protected static $registered = []; @@ -83,38 +86,38 @@ protected function setUp() : void { } public function testDebugModeDisabledByDefault() : void { - $handler = new Handler(); + $handler = new Handler(new Options()); $handler->onException($this->exceptionHandler(true)); $handler->try($this->throwCallback(new Exception("should not be logged"))); - $off = $handler->getDebugLog(); - $this->assertIsArray($off, "getDebugLog() did not return an array"); + $off = $handler->debugLog(); + $this->assertIsArray($off, "debugLog() did not return an array"); $this->assertEmpty($off, "debug mode logged items by default"); } public function testDebugModeLogsWhenEnabled() : void { - $handler = new Handler(); + $handler = new Handler(new Options()); $handler->onException($this->exceptionHandler(true)); - $handler->debug(); + $handler->options->debug = true; $e = new Exception("should be logged"); $handler->try($this->throwCallback($e)); $this->assertExceptionLogged($handler, 0, $e, true); } public function testDebugModeStopsLoggingWhenDisabled() : void { - $handler = new Handler(); + $handler = new Handler(new Options()); $handler->onException($this->exceptionHandler(true)); - $handler->debug(true); - $handler->debug(false); + $handler->options->debug = true; + $handler->options->debug = false; $handler->try($this->throwCallback(new Exception("should not be logged"))); - $this->assertEmpty($handler->getDebugLog(), "debug mode logged items after disabled"); + $this->assertEmpty($handler->debugLog(), "debug mode logged items after disabled"); } public function testHandleException() : void { $e = new Exception("should be handled in the end"); - (new Handler()) + (new Handler(new Options())) ->onException($this->exceptionHandler(false)) ->onException($this->exceptionHandler(true), Mismatched::class) ->onException($this->exceptionHandler(true)) @@ -127,18 +130,18 @@ public function testHandleException() : void { public function testHandleExceptionFailure() : void { $e = new Exception("this one slips through"); $this->expectThrowable( - new ExceptableError(ExceptableError::UNCAUGHT_EXCEPTION, [], $e), + (ExceptableError::UncaughtException)([], $e), self::EXPECT_THROWABLE_CODE | self::EXPECT_THROWABLE_MESSAGE ); - $h = (new Handler()) + $h = (new Handler(new Options())) ->onException($this->exceptionHandler(false)) ->onException($this->exceptionHandler(true), Mismatched::class) ->handleException($e); } public function testLogsError() : void { - $handler = new Handler(); + $handler = new Handler(new Options()); $logger = new TestLogger(); $handler->setLogger($logger); @@ -154,11 +157,11 @@ public function testLogsError() : void { } public function testLogsDebugError() : void { - $handler = new Handler(); + $handler = new Handler(new Options()); $logger = new TestLogger(); $handler->setLogger($logger); - $handler->debug(); + $handler->options->debug = true; $code = E_WARNING; $message = "Warning: example"; @@ -180,7 +183,7 @@ public function testLogsException() : void { } public function testRegister() : void { - $handler = (new Handler())->register(); + $handler = (new Handler(new Options()))->register(); $this->assertTrue( $this->getNonpublicProperty($handler, "registered"), @@ -195,8 +198,8 @@ public function testRegister() : void { $registered, "set_error_handler() was not invoked" ); - $this->assertSame( - [$handler, "handleError"], + $this->assertEquals( + $handler->handleError(...), $registered["set_error_handler"][1][0], "register() did not register Handler->handleError()" ); @@ -211,8 +214,8 @@ public function testRegister() : void { $registered, "set_exception_handler() was not invoked" ); - $this->assertSame( - [$handler, "handleException"], + $this->assertEquals( + $handler->handleException(...), $registered["set_exception_handler"][1][0], "register() did not register Handler->handleException()" ); @@ -222,8 +225,8 @@ public function testRegister() : void { $registered, "register_shutdown_function() was not invoked" ); - $this->assertSame( - [$handler, "handleShutdown"], + $this->assertEquals( + $handler->handleShutdown(...), $registered["register_shutdown_function"][1][0], "register() did not register Handler->handleShutdown()" ); @@ -235,14 +238,14 @@ public function testRegister() : void { public function testTryCallback() : void { $e = new Exception("should be handled"); - (new Handler())->onException($this->exceptionHandler(true)) + (new Handler(new Options()))->onException($this->exceptionHandler(true)) ->try($this->throwCallback($e)); $this->assertExceptionHandled(0, $e, true); } public function testUnregister() : void { - $handler = (new Handler())->register(); + $handler = (new Handler(new Options()))->register(); // clear registry self::$registered = []; $handler->unregister(); @@ -326,7 +329,7 @@ protected function assertErrorLogged( array $error, bool $success ) : void { - $log = $handler->getDebugLog(); + $log = $handler->debugLog(); $this->assertIsArray($log); $this->assertArrayHasKey($i); $logItem = $log[$i]; @@ -402,26 +405,18 @@ protected function assertExceptionLogged( Throwable $e, bool $success ) : void { - $log = $handler->getDebugLog(); - $this->assertArrayHasKey($i, $log, "no item [{$i}] was logged"); + $log = $handler->debugLog(); + $this->assertArrayHasKey($i, $log, "no LogEntry [{$i}] was logged"); $logItem = $log[$i]; - $this->assertArrayHasKey("time", $logItem, "[time] is missing"); - $this->assertIsFloat($logItem["time"], "[time] did not log as microtime"); - - $this->assertArrayHasKey("type", $logItem, "[type] is missing"); - $this->assertSame("exception", $logItem["type"], "[type] was not logged as 'exception'"); - - $this->assertArrayHasKey("handled", $logItem, "[handled] is missing"); + $this->assertIsFloat($logItem->time, "LogEntry->time did not log as microtime"); $success ? - $this->assertTrue($logItem["handled"], "[handled] was not logged as true") : - $this->assertFalse($logItem["handled"], "[handled] was not logged as false"); - - $this->assertArrayHasKey("exception", $logItem, "[exception] is missing"); + $this->assertTrue($logItem->handled, "LogEntry->handled was not logged as true") : + $this->assertFalse($logItem->handled, "LogEntry->handled was not logged as false"); $this->assertSame( $e, - $logItem["exception"], - "[exception] did not log expected exception '{$this->asString($e)}'" + $logItem->exception, + "LogEntry->exception did not log expected exception '{$this->asString($e)}'" ); } @@ -442,13 +437,19 @@ protected function errorHandler(bool $success) : callable { * Makes an exception handler and logs usage. * * @param bool $success Should handler succeed? - * @return callable + * @return ExceptionHandler */ - protected function exceptionHandler(bool $success) : callable { - return function (Throwable $e) use ($success) { - $this->stack[] = [$e, $success]; + protected function exceptionHandler(bool $success) : ExceptionHandler { + $callback = function (Throwable $t) use ($success) : bool { + $this->stack[] = [$t, $success]; return $success; }; + return new class($callback) implements ExceptionHandler { + public function __construct(protected Closure $callback) {} + public function run(Throwable $t) : bool { + return ($this->callback)($t); + } + }; } /** diff --git a/tests/stubs.php b/tests/stubs.php index 19f5514..280f16a 100644 --- a/tests/stubs.php +++ b/tests/stubs.php @@ -19,7 +19,7 @@ */ declare(strict_types = 1); -namespace at\exceptable; +namespace at\exceptable\Handler; use at\exceptable\Tests\HandlerTest; @@ -29,7 +29,7 @@ * @see https://php.net/register_shutdown_function */ function register_shutdown_function(callable $callback, ...$args) : void { - HandlerTest::notify(explode("\\", __FUNCTION__)[2], $callback, $args); + HandlerTest::notify(explode("\\", __FUNCTION__)[3], $callback, $args); } } @@ -39,7 +39,7 @@ function register_shutdown_function(callable $callback, ...$args) : void { * @see https://php.net/restore_error_handler */ function restore_error_handler() { - HandlerTest::notify(explode("\\", __FUNCTION__)[2]); + HandlerTest::notify(explode("\\", __FUNCTION__)[3]); return true; } } @@ -50,7 +50,7 @@ function restore_error_handler() { * @see https://php.net/restore_exception_handler */ function restore_exception_handler() { - HandlerTest::notify(explode("\\", __FUNCTION__)[2]); + HandlerTest::notify(explode("\\", __FUNCTION__)[3]); return true; } } @@ -61,7 +61,7 @@ function restore_exception_handler() { * @see https://php.net/set_error_handler */ function set_error_handler(callable $error_handler, int $error_types = E_ALL | E_STRICT) { - HandlerTest::notify(explode("\\", __FUNCTION__)[2], $error_handler, $error_types); + HandlerTest::notify(explode("\\", __FUNCTION__)[3], $error_handler, $error_types); return null; } } @@ -72,7 +72,7 @@ function set_error_handler(callable $error_handler, int $error_types = E_ALL | E * @see https://php.net/set_exception_handler */ function set_exception_handler(callable $exception_handler) { - HandlerTest::notify(explode("\\", __FUNCTION__)[2], $exception_handler); + HandlerTest::notify(explode("\\", __FUNCTION__)[3], $exception_handler); return null; } } From 375e5a518a8fa2585a6d99ed5e6de19cf1af4e1d Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 13 Mar 2024 22:29:40 -0400 Subject: [PATCH 13/13] update icu resources --- resources/language/ko_KR.res | Bin 332 -> 0 bytes resources/language/ko_KR.txt | 10 ---------- resources/language/root.res | Bin 740 -> 776 bytes resources/language/root.txt | 32 ++++++++++++++++++++------------ 4 files changed, 20 insertions(+), 22 deletions(-) delete mode 100644 resources/language/ko_KR.res delete mode 100644 resources/language/ko_KR.txt diff --git a/resources/language/ko_KR.res b/resources/language/ko_KR.res deleted file mode 100644 index 97988f4fef18589ec446130f5bca458c0bbec3a4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 332 zcmX|-ze)o^5XQef1nuciT33_Z$jJJW?L&5k4DJ}8{a$FG?B%EoE zz+nUt8w*7tg)d>}w`X*QnH^?+Gqbyb_k}rl39zT*jewDwsuGpk24tGiENy3Ep7F9t zvpTkM<@H6VTlVm%sgXpdkvWeV#=shLmW0PXOo49i+6lhhcxWF44H&AR-5+Oy&q8JU zufCoYoqnD5n|qrVk59Saa>8Db2HAS~=0@(z0jnP~w`&8zb6)$O__yPw?2zsDP72Qo z7C9&6#sm>8RuH2FjV9ISEd2D}JGHrUP>#yGq3>McI|J0Pj%6sMslq%CfL%qNe;|ta GEuKFXgJFaK diff --git a/resources/language/ko_KR.txt b/resources/language/ko_KR.txt deleted file mode 100644 index c5eb0cd..0000000 --- a/resources/language/ko_KR.txt +++ /dev/null @@ -1,10 +0,0 @@ -ko_KR { - exceptable { - tests { - testexceptable { - unknownfoo { "나는 당신이 foo 라고 생각하는 사람을 모르지만 {foo} 가 아닙니다" } - toomuchfoo { "너무 많은 foo는 당신에게 나쁩니다 ({count,spellout} foo를 얻었습니다)" } - } - } - } -} diff --git a/resources/language/root.res b/resources/language/root.res index c0f206c778db62d86e793af60bc677b9de01976e..2301294424febeadda817a000e458705bc8e6f8c 100644 GIT binary patch literal 776 zcma)2v2GJV5Ph~HrAkMa=88xmHBt&hgTWS-92ps$h5~JF$LAA!yQlS@V`Swk_yjt4 zGzg)zNOV**$PEn@EhQ4~_6{(Df}7jb%)B@Ab|%56;~S{IE+(?*b?J5XVj^Czfn=ZF zfah&{4k7{&g9?YMRLY<(jP!qd-aC(}eP-RI?YDtSe-YbEs;X=rnzGzkJWy5}@dK3` z(SNjlR2u$Gr=HS-%BF!8_mnYUd?sP;bd2u1Lgf}iyHqCEcYRhBVgp8EvoOcVWqFEm z6~?oktE^r${zTbKFkXtE8Fz_EWkX&F2heS1YGqYQ8bqHfQwcV}t!*DGq!5${$l<^N zpm=K-W<3(z#G2T1G6YvRVP-}~9&wG^jMsQd^Q?Aco{_hpDP;6&sxWwv5| zPF{k)wHvkFZjW2#rq9WL>y5TU>Mf~f_*Na&e0*a))6aPl9Mg8^tK<3=!oAcJr{qek z7xt@A5Y2g)asGmsB_rpZ5mEFHcQ&eU5qW(o*Zc9@AF#*zA)eq4I;=az5f1PIFYyX* h@E#xV5ufoDU+@jzvEMl&{%`A2agEL98hH4Q=Lh_Ty2$_l literal 740 zcma))y-wUf6oro$Bwm0Am?CIFp^T7{GNMTl1hfcQUVB(OYi7`T%#T*vQSl~hiYTwZ zW1xK%qmN;W&9GruIW1^U4E`}N!CBGMz? z-sbiyWRu$VsPaygyUyyWNXyI>wVliPv}yA)&3$3jx;$&AWk}!k!8V~$wC~x_&78k+ z7v3v)f64v`SJIKZjI3^zW3Loc!8o`aU%(lSwRl@CSs649c81RD1}lABf5TrZQ%$rW zT7n&@CdZuh8CinvsA09#xxT