diff --git a/docs/component/type.md b/docs/component/type.md index ee753958..bf342125 100644 --- a/docs/component/type.md +++ b/docs/component/type.md @@ -63,10 +63,10 @@ #### `Interfaces` -- [TypeInterface](./../../src/Psl/Type/TypeInterface.php#L14) +- [TypeInterface](./../../src/Psl/Type/TypeInterface.php#L13) #### `Classes` -- [Type](./../../src/Psl/Type/Type.php#L15) +- [Type](./../../src/Psl/Type/Type.php#L14) diff --git a/src/Psl/Internal/Loader.php b/src/Psl/Internal/Loader.php index 8c384e2b..bc3c9a92 100644 --- a/src/Psl/Internal/Loader.php +++ b/src/Psl/Internal/Loader.php @@ -690,7 +690,6 @@ final class Loader 'Psl\\Type\\Internal\\LiteralScalarType' => 'Psl/Type/Internal/LiteralScalarType.php', 'Psl\\Type\\Internal\\BackedEnumType' => 'Psl/Type/Internal/BackedEnumType.php', 'Psl\\Type\\Internal\\UnitEnumType' => 'Psl/Type/Internal/UnitEnumType.php', - 'Psl\\Type\\Exception\\TypeTrace' => 'Psl/Type/Exception/TypeTrace.php', 'Psl\\Type\\Exception\\AssertException' => 'Psl/Type/Exception/AssertException.php', 'Psl\\Type\\Exception\\CoercionException' => 'Psl/Type/Exception/CoercionException.php', 'Psl\\Type\\Exception\\Exception' => 'Psl/Type/Exception/Exception.php', diff --git a/src/Psl/Type/Exception/AssertException.php b/src/Psl/Type/Exception/AssertException.php index a8521d49..19335c71 100644 --- a/src/Psl/Type/Exception/AssertException.php +++ b/src/Psl/Type/Exception/AssertException.php @@ -5,6 +5,8 @@ namespace Psl\Type\Exception; use Psl\Str; +use Psl\Vec; +use Throwable; use function get_debug_type; @@ -12,9 +14,24 @@ final class AssertException extends Exception { private string $expected; - public function __construct(string $actual, string $expected, TypeTrace $typeTrace) + /** + * @param list $paths + */ + private function __construct(string $actual, string $expected, array $paths = [], ?Throwable $previous = null) { - parent::__construct(Str\format('Expected "%s", got "%s".', $expected, $actual), $actual, $typeTrace); + $first = $previous instanceof Exception ? $previous->getFirstFailingActualType() : $actual; + + parent::__construct( + Str\format( + 'Expected "%s", got "%s"%s.', + $expected, + $first, + $paths ? ' at path "' . Str\join($paths, '.') . '"' : '' + ), + $actual, + $paths, + $previous + ); $this->expected = $expected; } @@ -27,8 +44,11 @@ public function getExpectedType(): string public static function withValue( mixed $value, string $expected_type, - TypeTrace $trace + ?string $path = null, + ?Throwable $previous = null ): self { - return new self(get_debug_type($value), $expected_type, $trace); + $paths = $previous instanceof Exception ? [$path, ...$previous->getPaths()] : [$path]; + + return new self(get_debug_type($value), $expected_type, Vec\filter_nulls($paths), $previous); } } diff --git a/src/Psl/Type/Exception/CoercionException.php b/src/Psl/Type/Exception/CoercionException.php index 46651dd4..3b8b5c3a 100644 --- a/src/Psl/Type/Exception/CoercionException.php +++ b/src/Psl/Type/Exception/CoercionException.php @@ -5,6 +5,7 @@ namespace Psl\Type\Exception; use Psl\Str; +use Psl\Vec; use Throwable; use function get_debug_type; @@ -13,18 +14,24 @@ final class CoercionException extends Exception { private string $target; - public function __construct(string $actual, string $target, TypeTrace $typeTrace, string $additionalInfo = '') + /** + * @param list $paths + */ + private function __construct(string $actual, string $target, array $paths = [], ?Throwable $previous = null) { + $first = $previous instanceof Exception ? $previous->getFirstFailingActualType() : $actual; + parent::__construct( Str\format( - 'Could not coerce "%s" to type "%s"%s%s', - $actual, + 'Could not coerce "%s" to type "%s"%s%s.', + $first, $target, - $additionalInfo ? ': ' : '.', - $additionalInfo + $paths ? ' at path "' . Str\join($paths, '.') . '"' : '', + $previous && !$previous instanceof self ? ': ' . $previous->getMessage() : '', ), $actual, - $typeTrace, + $paths, + $previous ); $this->target = $target; @@ -38,22 +45,11 @@ public function getTargetType(): string public static function withValue( mixed $value, string $target, - TypeTrace $typeTrace + ?string $path = null, + ?Throwable $previous = null ): self { - return new self(get_debug_type($value), $target, $typeTrace); - } + $paths = $previous instanceof Exception ? [$path, ...$previous->getPaths()] : [$path]; - public static function withConversionFailureOnValue( - mixed $value, - string $target, - TypeTrace $typeTrace, - Throwable $failure, - ): self { - return new self( - get_debug_type($value), - $target, - $typeTrace, - $failure->getMessage() - ); + return new self(get_debug_type($value), $target, Vec\filter_nulls($paths), $previous); } } diff --git a/src/Psl/Type/Exception/Exception.php b/src/Psl/Type/Exception/Exception.php index 3fff9f8a..a6251e6f 100644 --- a/src/Psl/Type/Exception/Exception.php +++ b/src/Psl/Type/Exception/Exception.php @@ -5,21 +5,41 @@ namespace Psl\Type\Exception; use Psl\Exception\RuntimeException; +use Throwable; abstract class Exception extends RuntimeException implements ExceptionInterface { - private TypeTrace $typeTrace; private string $actual; - public function __construct( + /** + * @var list + */ + private array $paths; + + private string $first; + + /** + * @param list $paths + */ + protected function __construct( string $message, string $actual, - TypeTrace $typeTrace + array $paths, + ?Throwable $previous = null ) { - parent::__construct($message); + parent::__construct($message, 0, $previous); - $this->actual = $actual; - $this->typeTrace = $typeTrace; + $this->paths = $paths; + $this->first = $previous instanceof self ? $previous->first : $actual; + $this->actual = $actual; + } + + /** + * @return list + */ + public function getPaths(): array + { + return $this->paths; } public function getActualType(): string @@ -27,8 +47,8 @@ public function getActualType(): string return $this->actual; } - public function getTypeTrace(): TypeTrace + public function getFirstFailingActualType(): string { - return $this->typeTrace; + return $this->first; } } diff --git a/src/Psl/Type/Exception/TypeTrace.php b/src/Psl/Type/Exception/TypeTrace.php deleted file mode 100644 index 39391f5e..00000000 --- a/src/Psl/Type/Exception/TypeTrace.php +++ /dev/null @@ -1,34 +0,0 @@ - $frames - */ - private array $frames = []; - - public function withFrame(string $frame): self - { - $self = clone $this; - $self->frames[] = $frame; - - return $self; - } - - /** - * @return list - * - * @psalm-mutation-free - */ - public function getFrames(): array - { - return $this->frames; - } -} diff --git a/src/Psl/Type/Internal/BackedEnumType.php b/src/Psl/Type/Internal/BackedEnumType.php index 51729b11..7905181b 100644 --- a/src/Psl/Type/Internal/BackedEnumType.php +++ b/src/Psl/Type/Internal/BackedEnumType.php @@ -43,13 +43,13 @@ public function coerce(mixed $value): BackedEnum foreach (($this->enum)::cases() as $case) { if (Type\string()->matches($case->value)) { - $string_value = Type\string()->withTrace($this->getTrace())->coerce($value); + $string_value = Type\string()->coerce($value); if ($string_value === $case->value) { return $case; } } else { - $integer_value = Type\int()->withTrace($this->getTrace())->coerce($value); + $integer_value = Type\int()->coerce($value); if ($integer_value === $case->value) { return $case; @@ -57,7 +57,7 @@ public function coerce(mixed $value): BackedEnum } } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -73,7 +73,7 @@ public function assert(mixed $value): BackedEnum return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/BoolType.php b/src/Psl/Type/Internal/BoolType.php index d6756b7f..810c03b7 100644 --- a/src/Psl/Type/Internal/BoolType.php +++ b/src/Psl/Type/Internal/BoolType.php @@ -42,7 +42,7 @@ public function coerce(mixed $value): bool return true; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -56,7 +56,7 @@ public function assert(mixed $value): bool return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/ClassStringType.php b/src/Psl/Type/Internal/ClassStringType.php index 91b30321..04da6325 100644 --- a/src/Psl/Type/Internal/ClassStringType.php +++ b/src/Psl/Type/Internal/ClassStringType.php @@ -50,7 +50,7 @@ public function coerce(mixed $value): string return $value; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -66,7 +66,7 @@ public function assert(mixed $value): string return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/ConvertedType.php b/src/Psl/Type/Internal/ConvertedType.php index 7316df8a..04836a2b 100644 --- a/src/Psl/Type/Internal/ConvertedType.php +++ b/src/Psl/Type/Internal/ConvertedType.php @@ -48,15 +48,29 @@ public function coerce(mixed $value): mixed return $value; } - $coercedInput = $this->from->coerce($value); + $action = 0; try { + $coercedInput = $this->from->coerce($value); + $action++; $converted = ($this->converter)($coercedInput); + $action++; + return $this->into->coerce($converted); } catch (Throwable $failure) { - throw CoercionException::withConversionFailureOnValue($value, $this->toString(), $this->getTrace(), $failure); + throw CoercionException::withValue( + $value, + match ($action) { + 0 => $this->from->toString(), + default => $this->into->toString(), + }, + match ($action) { + 0 => PathExpression::coerceInput($value, $this->from->toString()), + 1 => PathExpression::convert($coercedInput ?? null, $this->into->toString()), + default => PathExpression::coerceOutput($converted ?? null, $this->into->toString()), + }, + $failure + ); } - - return $this->into->coerce($converted); } /** diff --git a/src/Psl/Type/Internal/DictType.php b/src/Psl/Type/Internal/DictType.php index 98c635b1..648368a3 100644 --- a/src/Psl/Type/Internal/DictType.php +++ b/src/Psl/Type/Internal/DictType.php @@ -7,6 +7,7 @@ use Psl\Type; use Psl\Type\Exception\AssertException; use Psl\Type\Exception\CoercionException; +use Throwable; use function is_array; use function is_iterable; @@ -39,21 +40,38 @@ public function __construct( public function coerce(mixed $value): array { if (! is_iterable($value)) { - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } - $trace = $this->getTrace(); - $key_type = $this->key_type->withTrace($trace->withFrame('dict<' . $this->key_type->toString() . ', _>')); - $value_type = $this->value_type->withTrace($trace->withFrame('dict<_, ' . $this->value_type->toString() . '>')); - $result = []; - - /** - * @var Tk $k - * @var Tv $v - */ - foreach ($value as $k => $v) { - $result[$key_type->coerce($k)] = $value_type->coerce($v); + $key_type = $this->key_type; + $value_type = $this->value_type; + + $k = $v = null; + $trying_key = true; + $iterating = true; + + try { + /** + * @var Tk $k + * @var Tv $v + */ + foreach ($value as $k => $v) { + $iterating = false; + $trying_key = true; + $k_result = $key_type->coerce($k); + $trying_key = false; + $v_result = $value_type->coerce($v); + + $result[$k_result] = $v_result; + $iterating = true; + } + } catch (Throwable $e) { + throw match (true) { + $iterating => CoercionException::withValue(null, $this->toString(), PathExpression::iteratorError($k), $e), + $trying_key => CoercionException::withValue($k, $this->toString(), PathExpression::iteratorKey($k), $e), + !$trying_key => CoercionException::withValue($v, $this->toString(), PathExpression::path($k), $e) + }; } return $result; @@ -69,25 +87,34 @@ public function coerce(mixed $value): array public function assert(mixed $value): array { if (! is_array($value)) { - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } - $trace = $this->getTrace(); - $key_type = $this->key_type->withTrace( - $trace->withFrame('dict<' . $this->key_type->toString() . ', _>') - ); - $value_type = $this->value_type->withTrace( - $trace->withFrame('dict<_, ' . $this->value_type->toString() . '>') - ); - $result = []; - - /** - * @var Tk $k - * @var Tv $v - */ - foreach ($value as $k => $v) { - $result[$key_type->assert($k)] = $value_type->assert($v); + $key_type = $this->key_type; + $value_type = $this->value_type; + + $k = $v = null; + $trying_key = true; + + try { + /** + * @var Tk $k + * @var Tv $v + */ + foreach ($value as $k => $v) { + $trying_key = true; + $k_result = $key_type->assert($k); + $trying_key = false; + $v_result = $value_type->assert($v); + + $result[$k_result] = $v_result; + } + } catch (AssertException $e) { + throw match ($trying_key) { + true => AssertException::withValue($k, $this->toString(), PathExpression::iteratorKey($k), $e), + false => AssertException::withValue($v, $this->toString(), PathExpression::path($k), $e) + }; } return $result; diff --git a/src/Psl/Type/Internal/F32Type.php b/src/Psl/Type/Internal/F32Type.php index d897c29d..94dfc10f 100644 --- a/src/Psl/Type/Internal/F32Type.php +++ b/src/Psl/Type/Internal/F32Type.php @@ -39,15 +39,13 @@ public function matches(mixed $value): bool */ public function coerce(mixed $value): float { - $float = Type\float() - ->withTrace($this->getTrace()->withFrame($this->toString())) - ->coerce($value); + $float = Type\float()->coerce($value); if ($float >= MATH\FLOAT32_MIN && $float <= MATH\FLOAT32_MAX) { return $float; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -67,7 +65,7 @@ public function assert(mixed $value): float return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/F64Type.php b/src/Psl/Type/Internal/F64Type.php index 6305a8d8..f9362c69 100644 --- a/src/Psl/Type/Internal/F64Type.php +++ b/src/Psl/Type/Internal/F64Type.php @@ -38,9 +38,7 @@ public function matches(mixed $value): bool */ public function coerce(mixed $value): float { - return Type\float() - ->withTrace($this->getTrace()->withFrame($this->toString())) - ->coerce($value); + return Type\float()->coerce($value); } /** diff --git a/src/Psl/Type/Internal/FloatType.php b/src/Psl/Type/Internal/FloatType.php index a9cfe551..8cd79699 100644 --- a/src/Psl/Type/Internal/FloatType.php +++ b/src/Psl/Type/Internal/FloatType.php @@ -55,7 +55,7 @@ public function coerce(mixed $value): float } } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -69,7 +69,7 @@ public function assert(mixed $value): float return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/I16Type.php b/src/Psl/Type/Internal/I16Type.php index bf49e56b..a49c4b12 100644 --- a/src/Psl/Type/Internal/I16Type.php +++ b/src/Psl/Type/Internal/I16Type.php @@ -39,15 +39,13 @@ public function matches(mixed $value): bool */ public function coerce(mixed $value): int { - $integer = Type\int() - ->withTrace($this->getTrace()->withFrame($this->toString())) - ->coerce($value); + $integer = Type\int()->coerce($value); if ($integer >= Math\INT16_MIN && $integer <= MATH\INT16_MAX) { return $integer; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -67,7 +65,7 @@ public function assert(mixed $value): int return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/I32Type.php b/src/Psl/Type/Internal/I32Type.php index c0e14623..dfb13a58 100644 --- a/src/Psl/Type/Internal/I32Type.php +++ b/src/Psl/Type/Internal/I32Type.php @@ -40,15 +40,13 @@ public function matches(mixed $value): bool */ public function coerce(mixed $value): int { - $integer = Type\int() - ->withTrace($this->getTrace()->withFrame($this->toString())) - ->coerce($value); + $integer = Type\int()->coerce($value); if ($integer >= Math\INT32_MIN && $integer <= MATH\INT32_MAX) { return $integer; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -68,7 +66,7 @@ public function assert(mixed $value): int return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/I64Type.php b/src/Psl/Type/Internal/I64Type.php index 54b41454..f3cb0e08 100644 --- a/src/Psl/Type/Internal/I64Type.php +++ b/src/Psl/Type/Internal/I64Type.php @@ -38,9 +38,7 @@ public function matches(mixed $value): bool */ public function coerce(mixed $value): int { - return Type\int() - ->withTrace($this->getTrace()->withFrame($this->toString())) - ->coerce($value); + return Type\int()->coerce($value); } /** @@ -60,7 +58,7 @@ public function assert(mixed $value): int return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/I8Type.php b/src/Psl/Type/Internal/I8Type.php index 6e1f3e9e..71fc2cfc 100644 --- a/src/Psl/Type/Internal/I8Type.php +++ b/src/Psl/Type/Internal/I8Type.php @@ -39,15 +39,13 @@ public function matches(mixed $value): bool */ public function coerce(mixed $value): int { - $integer = Type\int() - ->withTrace($this->getTrace()->withFrame($this->toString())) - ->coerce($value); + $integer = Type\int()->coerce($value); if ($integer >= Math\INT8_MIN && $integer <= Math\INT8_MAX) { return $integer; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -67,7 +65,7 @@ public function assert(mixed $value): int return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/InstanceOfType.php b/src/Psl/Type/Internal/InstanceOfType.php index 6673a192..05d32ba8 100644 --- a/src/Psl/Type/Internal/InstanceOfType.php +++ b/src/Psl/Type/Internal/InstanceOfType.php @@ -50,7 +50,7 @@ public function coerce(mixed $value): object return $value; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -66,7 +66,7 @@ public function assert(mixed $value): object return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/IntType.php b/src/Psl/Type/Internal/IntType.php index 69252aaa..5fbc27cd 100644 --- a/src/Psl/Type/Internal/IntType.php +++ b/src/Psl/Type/Internal/IntType.php @@ -64,7 +64,7 @@ public function coerce(mixed $value): int } } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -78,7 +78,7 @@ public function assert(mixed $value): int return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/IntersectionType.php b/src/Psl/Type/Internal/IntersectionType.php index dfe26636..9f6f2a46 100644 --- a/src/Psl/Type/Internal/IntersectionType.php +++ b/src/Psl/Type/Internal/IntersectionType.php @@ -68,7 +68,7 @@ public function coerce(mixed $value): mixed // ignore } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -85,7 +85,7 @@ public function assert(mixed $value): mixed /** @var Tl&Tr */ return $this->right_type->assert($value); } catch (AssertException) { - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } } diff --git a/src/Psl/Type/Internal/IterableType.php b/src/Psl/Type/Internal/IterableType.php index 6c7203a6..f817dc57 100644 --- a/src/Psl/Type/Internal/IterableType.php +++ b/src/Psl/Type/Internal/IterableType.php @@ -9,6 +9,7 @@ use Psl\Type; use Psl\Type\Exception\AssertException; use Psl\Type\Exception\CoercionException; +use Throwable; use function is_iterable; @@ -40,28 +41,39 @@ public function __construct( public function coerce(mixed $value): iterable { if (is_iterable($value)) { - $key_trace = $this->getTrace() - ->withFrame(Str\format('iterable<%s, _>', $this->key_type->toString())); - $value_trace = $this->getTrace() - ->withFrame(Str\format('iterable<_, %s>', $this->value_type->toString())); - /** @var Type\Type $key_type */ - $key_type = $this->key_type->withTrace($key_trace); + $key_type = $this->key_type; /** @var Type\Type $value_type_speec */ - $value_type = $this->value_type->withTrace($value_trace); + $value_type = $this->value_type; /** @var list $entries */ $entries = []; - /** - * @var Tk $k - * @var Tv $v - */ - foreach ($value as $k => $v) { - $entries[] = [ - $key_type->coerce($k), - $value_type->coerce($v), - ]; + $k = $v = null; + $trying_key = true; + $iterating = true; + + try { + /** + * @var Tk $k + * @var Tv $v + */ + foreach ($value as $k => $v) { + $iterating = false; + $trying_key = true; + $k_result = $key_type->coerce($k); + $trying_key = false; + $v_result = $value_type->coerce($v); + + $entries[] = [$k_result, $v_result]; + $iterating = true; + } + } catch (Throwable $e) { + throw match (true) { + $iterating => CoercionException::withValue(null, $this->toString(), PathExpression::iteratorError($k), $e), + $trying_key => CoercionException::withValue($k, $this->toString(), PathExpression::iteratorKey($k), $e), + !$trying_key => CoercionException::withValue($v, $this->toString(), PathExpression::path($k), $e) + }; } /** @var iterable */ @@ -72,7 +84,7 @@ public function coerce(mixed $value): iterable })); } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -85,28 +97,35 @@ public function coerce(mixed $value): iterable public function assert(mixed $value): iterable { if (is_iterable($value)) { - $key_trace = $this->getTrace() - ->withFrame(Str\format('iterable<%s, _>', $this->key_type->toString())); - $value_trace = $this->getTrace() - ->withFrame(Str\format('iterable<_, %s>', $this->value_type->toString())); - /** @var Type\Type $key_type */ - $key_type = $this->key_type->withTrace($key_trace); + $key_type = $this->key_type; /** @var Type\Type $value_type */ - $value_type = $this->value_type->withTrace($value_trace); + $value_type = $this->value_type; /** @var list $entries */ $entries = []; - /** - * @var Tk $k - * @var Tv $v - */ - foreach ($value as $k => $v) { - $entries[] = [ - $key_type->assert($k), - $value_type->assert($v), - ]; + $k = $v = null; + $trying_key = true; + + try { + /** + * @var Tk $k + * @var Tv $v + */ + foreach ($value as $k => $v) { + $trying_key = true; + $k_result = $key_type->assert($k); + $trying_key = false; + $v_result = $value_type->assert($v); + + $entries[] = [$k_result, $v_result]; + } + } catch (AssertException $e) { + throw match ($trying_key) { + true => AssertException::withValue($k, $this->toString(), PathExpression::iteratorKey($k), $e), + false => AssertException::withValue($v, $this->toString(), PathExpression::path($k), $e) + }; } /** @var iterable */ @@ -117,7 +136,7 @@ public function assert(mixed $value): iterable })); } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/LiteralScalarType.php b/src/Psl/Type/Internal/LiteralScalarType.php index 586f4add..d90a3448 100644 --- a/src/Psl/Type/Internal/LiteralScalarType.php +++ b/src/Psl/Type/Internal/LiteralScalarType.php @@ -47,44 +47,44 @@ public function coerce(mixed $value): string|int|float|bool } if (Type\string()->matches($this->value)) { - $coerced_value = Type\string()->withTrace($this->getTrace())->coerce($value); + $coerced_value = Type\string()->coerce($value); if ($this->value === $coerced_value) { /** @var T $coerced_value */ return $coerced_value; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } if (Type\int()->matches($this->value)) { - $coerced_value = Type\int()->withTrace($this->getTrace())->coerce($value); + $coerced_value = Type\int()->coerce($value); if ($this->value === $coerced_value) { /** @var T $coerced_value */ return $coerced_value; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } if (Type\float()->matches($this->value)) { - $coerced_value = Type\float()->withTrace($this->getTrace())->coerce($value); + $coerced_value = Type\float()->coerce($value); if ($this->value === $coerced_value) { /** @var T $coerced_value */ return $coerced_value; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @var bool $literal_value */ $literal_value = $this->value; - $coerced_value = Type\bool()->withTrace($this->getTrace())->coerce($value); + $coerced_value = Type\bool()->coerce($value); if ($literal_value === $coerced_value) { /** @var T $coerced_value */ return $coerced_value; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -99,7 +99,7 @@ public function assert(mixed $value): string|int|float|bool return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/MapType.php b/src/Psl/Type/Internal/MapType.php index 6e22172c..4e0912fe 100644 --- a/src/Psl/Type/Internal/MapType.php +++ b/src/Psl/Type/Internal/MapType.php @@ -10,6 +10,7 @@ use Psl\Type; use Psl\Type\Exception\AssertException; use Psl\Type\Exception\CoercionException; +use Throwable; use function is_iterable; use function is_object; @@ -42,37 +43,47 @@ public function __construct( public function coerce(mixed $value): Collection\MapInterface { if (is_iterable($value)) { - $key_trace = $this->getTrace()->withFrame( - Str\format('%s<%s, _>', Collection\MapInterface::class, $this->key_type->toString()) - ); - - $value_trace = $this->getTrace()->withFrame( - Str\format('%s<_, %s>', Collection\MapInterface::class, $this->value_type->toString()) - ); - /** @var Type\Type $key_type */ - $key_type = $this->key_type->withTrace($key_trace); + $key_type = $this->key_type; /** @var Type\Type $value_type */ - $value_type = $this->value_type->withTrace($value_trace); + $value_type = $this->value_type; /** @var list $entries */ $entries = []; - /** - * @var Tk $k - * @var Tv $v - */ - foreach ($value as $k => $v) { - $entries[] = [ - $key_type->coerce($k), - $value_type->coerce($v), - ]; + + + $k = $v = null; + $trying_key = true; + $iterating = true; + + try { + /** + * @var Tk $k + * @var Tv $v + */ + foreach ($value as $k => $v) { + $iterating = false; + $trying_key = true; + $k_result = $key_type->coerce($k); + $trying_key = false; + $v_result = $value_type->coerce($v); + + $entries[] = [$k_result, $v_result]; + $iterating = true; + } + } catch (Throwable $e) { + throw match (true) { + $iterating => CoercionException::withValue(null, $this->toString(), PathExpression::iteratorError($k), $e), + $trying_key => CoercionException::withValue($k, $this->toString(), PathExpression::iteratorKey($k), $e), + !$trying_key => CoercionException::withValue($v, $this->toString(), PathExpression::path($k), $e) + }; } /** @var Collection\Map */ return new Collection\Map(Dict\from_entries($entries)); } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -85,38 +96,42 @@ public function coerce(mixed $value): Collection\MapInterface public function assert(mixed $value): Collection\MapInterface { if (is_object($value) && $value instanceof Collection\MapInterface) { - $key_trace = $this->getTrace()->withFrame( - Str\format('%s<%s, _>', Collection\MapInterface::class, $this->key_type->toString()) - ); - - $value_trace = $this->getTrace()->withFrame( - Str\format('%s<_, %s>', Collection\MapInterface::class, $this->value_type->toString()) - ); - /** @var Type\Type $key_type */ - $key_type = $this->key_type->withTrace($key_trace); + $key_type = $this->key_type; /** @var Type\Type $value_type */ - $value_type = $this->value_type->withTrace($value_trace); + $value_type = $this->value_type; /** @var list $entries */ $entries = []; - /** - * @var Tk $k - * @var Tv $v - */ - foreach ($value as $k => $v) { - $entries[] = [ - $key_type->assert($k), - $value_type->assert($v), - ]; + $k = $v = null; + $trying_key = true; + + try { + /** + * @var Tk $k + * @var Tv $v + */ + foreach ($value as $k => $v) { + $trying_key = true; + $k_result = $key_type->assert($k); + $trying_key = false; + $v_result = $value_type->assert($v); + + $entries[] = [$k_result, $v_result]; + } + } catch (AssertException $e) { + throw match ($trying_key) { + true => AssertException::withValue($k, $this->toString(), PathExpression::iteratorKey($k), $e), + false => AssertException::withValue($v, $this->toString(), PathExpression::path($k), $e) + }; } /** @var Collection\Map */ return new Collection\Map(Dict\from_entries($entries)); } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/MixedDictType.php b/src/Psl/Type/Internal/MixedDictType.php index b964752a..a63d7056 100644 --- a/src/Psl/Type/Internal/MixedDictType.php +++ b/src/Psl/Type/Internal/MixedDictType.php @@ -7,6 +7,7 @@ use Psl\Type; use Psl\Type\Exception\AssertException; use Psl\Type\Exception\CoercionException; +use Throwable; /** * @extends Type\Type> @@ -23,7 +24,7 @@ final class MixedDictType extends Type\Type public function coerce(mixed $value): array { if (! is_iterable($value)) { - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } if (is_array($value)) { @@ -33,18 +34,29 @@ public function coerce(mixed $value): array $result = []; $key_type = Type\array_key(); - $key_type = $key_type->withTrace($this->getTrace()->withFrame('dict<' . $key_type->toString() . ', _>')); + $k = null; + $iterating = true; - /** - * @var array-key $k - * @var mixed $v - * - * @psalm-suppress MixedAssignment - */ - foreach ($value as $k => $v) { - $result[$key_type->coerce($k)] = $v; + try { + /** + * @var array-key $k + * @var mixed $v + * + * @psalm-suppress MixedAssignment + */ + foreach ($value as $k => $v) { + $iterating = false; + $result[$key_type->coerce($k)] = $v; + $iterating = true; + } + } catch (Throwable $e) { + throw match (true) { + $iterating => CoercionException::withValue(null, $this->toString(), PathExpression::iteratorError($k), $e), + default => CoercionException::withValue($k, $this->toString(), PathExpression::iteratorKey($k), $e), + }; } + return $result; } @@ -58,7 +70,7 @@ public function coerce(mixed $value): array public function assert(mixed $value): array { if (! is_array($value)) { - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } return $value; diff --git a/src/Psl/Type/Internal/MixedVecType.php b/src/Psl/Type/Internal/MixedVecType.php index 5f1c9328..d94f62d0 100644 --- a/src/Psl/Type/Internal/MixedVecType.php +++ b/src/Psl/Type/Internal/MixedVecType.php @@ -31,7 +31,7 @@ public function matches(mixed $value): bool public function coerce(mixed $value): iterable { if (! is_iterable($value)) { - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } if (is_array($value)) { @@ -69,7 +69,7 @@ public function coerce(mixed $value): iterable public function assert(mixed $value): array { if (! is_array($value) || ! array_is_list($value)) { - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } return $value; diff --git a/src/Psl/Type/Internal/MutableMapType.php b/src/Psl/Type/Internal/MutableMapType.php index a5e6490f..7299e032 100644 --- a/src/Psl/Type/Internal/MutableMapType.php +++ b/src/Psl/Type/Internal/MutableMapType.php @@ -10,6 +10,7 @@ use Psl\Type; use Psl\Type\Exception\AssertException; use Psl\Type\Exception\CoercionException; +use Throwable; use function is_iterable; use function is_object; @@ -42,40 +43,47 @@ public function __construct( public function coerce(mixed $value): Collection\MutableMapInterface { if (is_iterable($value)) { - $key_trace = $this->getTrace()->withFrame( - Str\format('%s<%s, _>', Collection\MutableMapInterface::class, $this->key_type->toString()) - ); - - $value_trace = $this->getTrace()->withFrame( - Str\format('%s<_, %s>', Collection\MutableMapInterface::class, $this->value_type->toString()) - ); - /** @var Type\Type $key_type */ - $key_type = $this->key_type->withTrace($key_trace); + $key_type = $this->key_type; /** @var Type\Type $value_type */ - $value_type = $this->value_type->withTrace($value_trace); + $value_type = $this->value_type; /** @var list $entries */ $entries = []; - /** - * @var Tk $k - * @var Tv $v - */ - foreach ($value as $k => $v) { - $entries[] = [ - $key_type->coerce($k), - $value_type->coerce($v), - ]; + $k = $v = null; + $trying_key = true; + $iterating = true; + + try { + /** + * @var Tk $k + * @var Tv $v + */ + foreach ($value as $k => $v) { + $iterating = false; + $trying_key = true; + $k_result = $key_type->coerce($k); + $trying_key = false; + $v_result = $value_type->coerce($v); + + $entries[] = [$k_result, $v_result]; + $iterating = true; + } + } catch (Throwable $e) { + throw match (true) { + $iterating => CoercionException::withValue(null, $this->toString(), PathExpression::iteratorError($k), $e), + $trying_key => CoercionException::withValue($k, $this->toString(), PathExpression::iteratorKey($k), $e), + !$trying_key => CoercionException::withValue($v, $this->toString(), PathExpression::path($k), $e) + }; } - $dict = Dict\from_entries($entries); /** @var Collection\MutableMap */ return new Collection\MutableMap($dict); } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -88,31 +96,35 @@ public function coerce(mixed $value): Collection\MutableMapInterface public function assert(mixed $value): Collection\MutableMapInterface { if (is_object($value) && $value instanceof Collection\MutableMapInterface) { - $key_trace = $this->getTrace()->withFrame( - Str\format('%s<%s, _>', Collection\MutableMapInterface::class, $this->key_type->toString()) - ); - - $value_trace = $this->getTrace()->withFrame( - Str\format('%s<_, %s>', Collection\MutableMapInterface::class, $this->value_type->toString()) - ); - /** @var Type\Type $key_type */ - $key_type = $this->key_type->withTrace($key_trace); + $key_type = $this->key_type; /** @var Type\Type $value_type */ - $value_type = $this->value_type->withTrace($value_trace); + $value_type = $this->value_type; /** @var list $entries */ $entries = []; - /** - * @var Tk $k - * @var Tv $v - */ - foreach ($value as $k => $v) { - $entries[] = [ - $key_type->assert($k), - $value_type->assert($v), - ]; + $k = $v = null; + $trying_key = true; + + try { + /** + * @var Tk $k + * @var Tv $v + */ + foreach ($value as $k => $v) { + $trying_key = true; + $k_result = $key_type->assert($k); + $trying_key = false; + $v_result = $value_type->assert($v); + + $entries[] = [$k_result, $v_result]; + } + } catch (AssertException $e) { + throw match ($trying_key) { + true => AssertException::withValue($k, $this->toString(), PathExpression::iteratorKey($k), $e), + false => AssertException::withValue($v, $this->toString(), PathExpression::path($k), $e) + }; } $dict = Dict\from_entries($entries); @@ -121,7 +133,7 @@ public function assert(mixed $value): Collection\MutableMapInterface return new Collection\MutableMap($dict); } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/MutableVectorType.php b/src/Psl/Type/Internal/MutableVectorType.php index cd19b9df..8e8897b7 100644 --- a/src/Psl/Type/Internal/MutableVectorType.php +++ b/src/Psl/Type/Internal/MutableVectorType.php @@ -9,6 +9,7 @@ use Psl\Type; use Psl\Type\Exception\AssertException; use Psl\Type\Exception\CoercionException; +use Throwable; use function is_iterable; use function is_object; @@ -38,30 +39,38 @@ public function __construct( public function coerce(mixed $value): Collection\MutableVectorInterface { if (is_iterable($value)) { - $value_trace = $this->getTrace()->withFrame( - Str\format('%s<%s>', Collection\MutableVectorInterface::class, $this->value_type->toString()) - ); - /** @var Type\Type $value_type */ - $value_type = $this->value_type->withTrace($value_trace); + $value_type = $this->value_type; /** * @var list $values */ $values = []; - - /** - * @var T $v - */ - foreach ($value as $v) { - $values[] = $value_type->coerce($v); + $i = $v = null; + $iterating = true; + + try { + /** + * @var T $v + * @var array-key $i + */ + foreach ($value as $i => $v) { + $iterating = false; + $values[] = $value_type->coerce($v); + $iterating = true; + } + } catch (Throwable $e) { + throw match (true) { + $iterating => CoercionException::withValue(null, $this->toString(), PathExpression::iteratorError($i), $e), + default => CoercionException::withValue($v, $this->toString(), PathExpression::path($i), $e) + }; } /** @var Collection\MutableVector */ return new Collection\MutableVector($values); } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -74,30 +83,32 @@ public function coerce(mixed $value): Collection\MutableVectorInterface public function assert(mixed $value): Collection\MutableVectorInterface { if (is_object($value) && $value instanceof Collection\MutableVectorInterface) { - $value_trace = $this->getTrace()->withFrame( - Str\format('%s<%s>', Collection\MutableVectorInterface::class, $this->value_type->toString()) - ); - /** @var Type\Type $value_type */ - $value_type = $this->value_type->withTrace($value_trace); + $value_type = $this->value_type; /** * @var list $values */ $values = []; - - /** - * @var T $v - */ - foreach ($value as $v) { - $values[] = $value_type->coerce($v); + $i = $v = null; + + try { + /** + * @var T $v + * @var array-key $i + */ + foreach ($value as $i => $v) { + $values[] = $value_type->assert($v); + } + } catch (AssertException $e) { + throw AssertException::withValue($v, $this->toString(), PathExpression::path($i), $e); } /** @var Collection\MutableVector */ return new Collection\MutableVector($values); } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/NonEmptyDictType.php b/src/Psl/Type/Internal/NonEmptyDictType.php index fda06939..2c6c53b5 100644 --- a/src/Psl/Type/Internal/NonEmptyDictType.php +++ b/src/Psl/Type/Internal/NonEmptyDictType.php @@ -8,6 +8,7 @@ use Psl\Type; use Psl\Type\Exception\AssertException; use Psl\Type\Exception\CoercionException; +use Throwable; use function is_array; use function is_iterable; @@ -40,32 +41,46 @@ public function __construct( public function coerce(mixed $value): array { if (is_iterable($value)) { - $key_trace = $this->getTrace() - ->withFrame(Str\format('non-empty-dict<%s, _>', $this->key_type->toString())); - $value_trace = $this->getTrace() - ->withFrame(Str\format('non-empty-dict<_, %s>', $this->value_type->toString())); - - $key_type = $this->key_type->withTrace($key_trace); - $value_type = $this->value_type->withTrace($value_trace); + $key_type = $this->key_type; + $value_type = $this->value_type; $result = []; - /** - * @var Tk $k - * @var Tv $v - */ - foreach ($value as $k => $v) { - $result[$key_type->coerce($k)] = $value_type->coerce($v); + $k = $v = null; + $trying_key = true; + $iterating = true; + + try { + /** + * @var Tk $k + * @var Tv $v + */ + foreach ($value as $k => $v) { + $iterating = false; + $trying_key = true; + $k_result = $key_type->coerce($k); + $trying_key = false; + $v_result = $value_type->coerce($v); + + $result[$k_result] = $v_result; + $iterating = true; + } + } catch (Throwable $e) { + throw match (true) { + $iterating => CoercionException::withValue(null, $this->toString(), PathExpression::iteratorError($k), $e), + $trying_key => CoercionException::withValue($k, $this->toString(), PathExpression::iteratorKey($k), $e), + !$trying_key => CoercionException::withValue($v, $this->toString(), PathExpression::path($k), $e) + }; } if ($result === []) { - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } return $result; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -78,32 +93,42 @@ public function coerce(mixed $value): array public function assert(mixed $value): array { if (is_array($value)) { - $key_trace = $this->getTrace() - ->withFrame(Str\format('non-empty-dict<%s, _>', $this->key_type->toString())); - $value_trace = $this->getTrace() - ->withFrame(Str\format('non-empty-dict<_, %s>', $this->value_type->toString())); - - $key_type = $this->key_type->withTrace($key_trace); - $value_type = $this->value_type->withTrace($value_trace); + $key_type = $this->key_type; + $value_type = $this->value_type; $result = []; - /** - * @var Tk $k - * @var Tv $v - */ - foreach ($value as $k => $v) { - $result[$key_type->assert($k)] = $value_type->assert($v); + $k = $v = null; + $trying_key = true; + + try { + /** + * @var Tk $k + * @var Tv $v + */ + foreach ($value as $k => $v) { + $trying_key = true; + $k_result = $key_type->assert($k); + $trying_key = false; + $v_result = $value_type->assert($v); + + $result[$k_result] = $v_result; + } + } catch (AssertException $e) { + throw match ($trying_key) { + true => AssertException::withValue($k, $this->toString(), PathExpression::iteratorKey($k), $e), + false => AssertException::withValue($v, $this->toString(), PathExpression::path($k), $e) + }; } if ($result === []) { - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } return $result; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/NonEmptyStringType.php b/src/Psl/Type/Internal/NonEmptyStringType.php index dabe8f1d..ada92ce7 100644 --- a/src/Psl/Type/Internal/NonEmptyStringType.php +++ b/src/Psl/Type/Internal/NonEmptyStringType.php @@ -51,7 +51,7 @@ public function coerce(mixed $value): string } } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -68,7 +68,7 @@ public function assert(mixed $value): string return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/NonEmptyVecType.php b/src/Psl/Type/Internal/NonEmptyVecType.php index ebdcd7c4..8777d3b4 100644 --- a/src/Psl/Type/Internal/NonEmptyVecType.php +++ b/src/Psl/Type/Internal/NonEmptyVecType.php @@ -8,6 +8,7 @@ use Psl\Type; use Psl\Type\Exception\AssertException; use Psl\Type\Exception\CoercionException; +use Throwable; use function is_array; use function is_iterable; @@ -66,30 +67,42 @@ public function matches(mixed $value): bool public function coerce(mixed $value): iterable { if (is_iterable($value)) { - $value_trace = $this->getTrace() - ->withFrame(Str\format('non-empty-vec<%s>', $this->value_type->toString())); - /** @var Type\Type $value_type */ - $value_type = $this->value_type->withTrace($value_trace); + $value_type = $this->value_type; /** * @var list $entries */ $result = []; - /** @var Tv $v */ - foreach ($value as $v) { - $result[] = $value_type->coerce($v); + $i = $v = null; + $iterating = true; + + try { + /** + * @var Tv $v + * @var array-key $i + */ + foreach ($value as $i => $v) { + $iterating = false; + $result[] = $value_type->coerce($v); + $iterating = true; + } + } catch (Throwable $e) { + throw match (true) { + $iterating => CoercionException::withValue(null, $this->toString(), PathExpression::iteratorError($i), $e), + default => CoercionException::withValue($v, $this->toString(), PathExpression::path($i), $e) + }; } if ($result === []) { - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } return $result; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -101,37 +114,34 @@ public function coerce(mixed $value): iterable */ public function assert(mixed $value): array { - if (is_array($value)) { - $value_trace = $this->getTrace() - ->withFrame(Str\format('non-empty-vec<%s>', $this->value_type->toString())); + if (!is_array($value) || !array_is_list($value)) { + throw AssertException::withValue($value, $this->toString()); + } - /** @var Type\Type $value_type */ - $value_type = $this->value_type->withTrace($value_trace); + /** @var Type\Type $value_type */ + $value_type = $this->value_type; - $result = []; - $index = 0; + $result = []; + + $i = $v = null; + try { /** - * @var int $k * @var Tv $v + * @var array-key $i */ - foreach ($value as $k => $v) { - if ($index !== $k) { - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); - } - - $index++; + foreach ($value as $i => $v) { $result[] = $value_type->assert($v); } + } catch (AssertException $e) { + throw AssertException::withValue($v, $this->toString(), PathExpression::path($i), $e); + } - if ($result === []) { - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); - } - - return $result; + if ($result === []) { + throw AssertException::withValue($value, $this->toString()); } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + return $result; } public function toString(): string diff --git a/src/Psl/Type/Internal/NonNullType.php b/src/Psl/Type/Internal/NonNullType.php index 51602752..92294ae0 100644 --- a/src/Psl/Type/Internal/NonNullType.php +++ b/src/Psl/Type/Internal/NonNullType.php @@ -38,7 +38,7 @@ public function coerce(mixed $value): mixed return $value; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -56,7 +56,7 @@ public function assert(mixed $value): mixed return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/NullType.php b/src/Psl/Type/Internal/NullType.php index 5051846f..a1396efd 100644 --- a/src/Psl/Type/Internal/NullType.php +++ b/src/Psl/Type/Internal/NullType.php @@ -32,7 +32,7 @@ public function coerce(mixed $value): mixed return null; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -46,7 +46,7 @@ public function assert(mixed $value): mixed return null; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/NullableType.php b/src/Psl/Type/Internal/NullableType.php index 675641ae..5fa81e80 100644 --- a/src/Psl/Type/Internal/NullableType.php +++ b/src/Psl/Type/Internal/NullableType.php @@ -44,7 +44,7 @@ public function coerce(mixed $value): mixed return null; } - return $this->inner->withTrace($this->getTrace())->coerce($value); + return $this->inner->coerce($value); } /** @@ -60,7 +60,7 @@ public function assert(mixed $value): mixed return null; } - return $this->inner->withTrace($this->getTrace())->assert($value); + return $this->inner->assert($value); } public function toString(): string diff --git a/src/Psl/Type/Internal/NumericStringType.php b/src/Psl/Type/Internal/NumericStringType.php index 9e3c9d31..0deccf9d 100644 --- a/src/Psl/Type/Internal/NumericStringType.php +++ b/src/Psl/Type/Internal/NumericStringType.php @@ -50,7 +50,7 @@ public function coerce(mixed $value): string } } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -67,7 +67,7 @@ public function assert(mixed $value): string return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/ObjectType.php b/src/Psl/Type/Internal/ObjectType.php index 03c3b5a6..56f03cdc 100644 --- a/src/Psl/Type/Internal/ObjectType.php +++ b/src/Psl/Type/Internal/ObjectType.php @@ -36,7 +36,7 @@ public function coerce(mixed $value): object return $value; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -52,7 +52,7 @@ public function assert(mixed $value): object return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/OptionalType.php b/src/Psl/Type/Internal/OptionalType.php index 7997ae25..82274774 100644 --- a/src/Psl/Type/Internal/OptionalType.php +++ b/src/Psl/Type/Internal/OptionalType.php @@ -32,7 +32,7 @@ public function __construct( */ public function coerce(mixed $value): mixed { - return $this->inner->withTrace($this->getTrace())->coerce($value); + return $this->inner->coerce($value); } /** @@ -44,7 +44,7 @@ public function coerce(mixed $value): mixed */ public function assert(mixed $value): mixed { - return $this->inner->withTrace($this->getTrace())->assert($value); + return $this->inner->assert($value); } /** diff --git a/src/Psl/Type/Internal/PathExpression.php b/src/Psl/Type/Internal/PathExpression.php new file mode 100644 index 00000000..d26bd68c --- /dev/null +++ b/src/Psl/Type/Internal/PathExpression.php @@ -0,0 +1,129 @@ + $path ? 'true' : 'false', + is_scalar($path) => (string) $path, + default => get_debug_type($path), + }; + } + + /** + * @pure + * + * This function can be used to display the path in a very specific way. + * For example: + * - expression 'key(%s)' will display the path as "key(0)" when path is 0. + * - expression 'key(%s)' will display the path as "key(someIndex)" when path is "someIndex". + * + * In most situations, the $path will be an array-key (string|int) that represents the position of the value in the data structure. + * When using iterators, this could be any type. + */ + public static function expression(string $expression, mixed $path): string + { + return Str\format($expression, self::path($path)); + } + + /** + * @pure + * + * This function must be used to format the path when parsing the key of an iterator fails. + * + * In most situations, the $path will be an array-key (string|int) that represents the position of the value in the data structure. + * When using iterators, this could be any type. + */ + public static function iteratorKey(mixed $key): string + { + return self::expression('key(%s)', $key); + } + + /** + * @pure + * + * This function must be used to format the path when an internal error occurs when iterating an iterator - like for example a \Generator. + * + * This function takes the value of the $previousKey as an argument. + * If a previous key is known, the result will formatted as : previousKey.next(). + * If no previous key is known, the result will formatted as : first(). + */ + public static function iteratorError(mixed $previousKey): string + { + return self::expression($previousKey === null ? 'first()' : '%s.next()', $previousKey); + } + + /** + * @pure + * + * This function must be used to format the path when coercing a mixed input to a specific type fails. + * + * The first $input argument is used to display the type you are trying to coerce. + * The second $expectedType argument is used to display the type you are trying to coerce into. + * + * Example output: + * - **coerce_input(string): int**: This means that the input 'string' could not be coerced to the expected output 'int'. + */ + public static function coerceInput(mixed $input, string $expectedType): string + { + return Str\format('coerce_input(%s): %s', get_debug_type($input), $expectedType); + } + + /** + * @pure + * + * This function must be used to format the path when converting an input by using a custom converter fails. + * + * The first $input argument is used to display the type you are trying to convert. + * The second $expectedType argument is used to display the type you are trying to convert into. + * + * Example output: + * - **convert(string): int**: This means that the input 'string' could not be converted to the expected output 'int'. + */ + public static function convert(mixed $input, string $expectedType): string + { + return Str\format('convert(%s): %s', get_debug_type($input), $expectedType); + } + + /** + * @pure + * + * This function must be used to format the path when coercing a mixed output to a specific type fails. + * + * The first $input argument is used to display the type you are trying to coerce. + * The second $expectedType argument is used to display the type you are trying to coerce into. + * + * Example output: + * - **coerce_output(string): int**: This means that the input 'string' could not be coerced to the expected output 'int'. + */ + public static function coerceOutput(mixed $input, string $expectedType): string + { + return Str\format('coerce_output(%s): %s', get_debug_type($input), $expectedType); + } +} diff --git a/src/Psl/Type/Internal/PositiveIntType.php b/src/Psl/Type/Internal/PositiveIntType.php index b9a54ae9..10c46ae4 100644 --- a/src/Psl/Type/Internal/PositiveIntType.php +++ b/src/Psl/Type/Internal/PositiveIntType.php @@ -50,7 +50,7 @@ public function coerce(mixed $value): int try { $trimmed = Str\trim_left($str, '0'); } catch (Str\Exception\InvalidArgumentException $e) { - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } $int = Str\to_int($trimmed); @@ -60,7 +60,7 @@ public function coerce(mixed $value): int // Exceptional case "000" -(trim)-> "", but we treat it as 0 if ('' === $trimmed && '' !== $str) { - CoercionException::withValue($value, $this->toString(), $this->getTrace()); + CoercionException::withValue($value, $this->toString()); } } @@ -72,7 +72,7 @@ public function coerce(mixed $value): int } } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -88,7 +88,7 @@ public function assert(mixed $value): int return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/ResourceType.php b/src/Psl/Type/Internal/ResourceType.php index 62d512bd..87577dbf 100644 --- a/src/Psl/Type/Internal/ResourceType.php +++ b/src/Psl/Type/Internal/ResourceType.php @@ -41,7 +41,7 @@ public function coerce(mixed $value): mixed } } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -64,7 +64,7 @@ public function assert(mixed $value): mixed } } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/ShapeType.php b/src/Psl/Type/Internal/ShapeType.php index 65b2aad6..569dec88 100644 --- a/src/Psl/Type/Internal/ShapeType.php +++ b/src/Psl/Type/Internal/ShapeType.php @@ -9,6 +9,7 @@ use Psl\Type\Exception\AssertException; use Psl\Type\Exception\CoercionException; use stdClass; +use Throwable; use function array_diff_key; use function array_filter; @@ -101,35 +102,52 @@ public function coerce(mixed $value): array private function coerceIterable(mixed $value): array { if (! is_iterable($value)) { - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } $arrayKeyType = Type\array_key(); $array = []; - /** - * @var Tk $k - * @var Tv $v - */ - foreach ($value as $k => $v) { - if ($arrayKeyType->matches($k)) { - $array[$k] = $v; + $k = null; + try { + /** + * @var Tk $k + * @var Tv $v + */ + foreach ($value as $k => $v) { + if ($arrayKeyType->matches($k)) { + $array[$k] = $v; + } } + } catch (Throwable $e) { + throw CoercionException::withValue(null, $this->toString(), PathExpression::iteratorError($k), $e); } + $result = []; - foreach ($this->elements_types as $element => $type) { - [$trace, $type] = $this->getTypeAndTraceForElement($element, $type); - if (Iter\contains_key($array, $element)) { - $result[$element] = $type->coerce($array[$element]); + $element = null; + $element_value_found = false; - continue; - } + try { + foreach ($this->elements_types as $element => $type) { + $element_value_found = false; + if (Iter\contains_key($array, $element)) { + $element_value_found = true; + $result[$element] = $type->coerce($array[$element]); - if ($type->isOptional()) { - continue; - } + continue; + } + + if ($type->isOptional()) { + continue; + } - throw CoercionException::withValue($value, $this->toString(), $trace); + throw CoercionException::withValue(null, $this->toString(), PathExpression::path($element)); + } + } catch (CoercionException $e) { + throw match (true) { + $element_value_found => CoercionException::withValue($array[$element] ?? null, $this->toString(), PathExpression::path($element), $e), + default => $e + }; } if ($this->allow_unknown_fields) { @@ -154,23 +172,34 @@ private function coerceIterable(mixed $value): array public function assert(mixed $value): array { if (! is_array($value)) { - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } $result = []; - foreach ($this->elements_types as $element => $type) { - [$trace, $type] = $this->getTypeAndTraceForElement($element, $type); - if (Iter\contains_key($value, $element)) { - $result[$element] = $type->assert($value[$element]); + $element = null; + $element_value_found = false; - continue; - } + try { + foreach ($this->elements_types as $element => $type) { + $element_value_found = false; + if (Iter\contains_key($value, $element)) { + $element_value_found = true; + $result[$element] = $type->assert($value[$element]); - if ($type->isOptional()) { - continue; - } + continue; + } + + if ($type->isOptional()) { + continue; + } - throw AssertException::withValue($value, $this->toString(), $trace); + throw AssertException::withValue(null, $this->toString(), PathExpression::path($element)); + } + } catch (AssertException $e) { + throw match (true) { + $element_value_found => AssertException::withValue($value[$element] ?? null, $this->toString(), PathExpression::path($element), $e), + default => $e + }; } /** @@ -183,9 +212,9 @@ public function assert(mixed $value): array $result[$k] = $v; } else { throw AssertException::withValue( - $value, + $v, $this->toString(), - $this->getTrace()->withFrame('array{' . $this->getElementName($k) . ': _}') + PathExpression::path($k) ); } } @@ -217,22 +246,4 @@ private function getElementName(string|int $element): string ? (string) $element : '\'' . $element . '\''; } - - /** - * @template T - * - * @param Type\TypeInterface $type - * - * @return array{0: Type\Exception\TypeTrace, 1: Type\TypeInterface} - */ - private function getTypeAndTraceForElement(string|int $element, Type\TypeInterface $type): array - { - $element_name = $this->getElementName($element); - $trace = $this->getTrace()->withFrame('array{' . $element_name . ': _}'); - - return [ - $trace, - $type->withTrace($trace), - ]; - } } diff --git a/src/Psl/Type/Internal/StringType.php b/src/Psl/Type/Internal/StringType.php index d157380d..5eaeb7f8 100644 --- a/src/Psl/Type/Internal/StringType.php +++ b/src/Psl/Type/Internal/StringType.php @@ -40,7 +40,7 @@ public function coerce(mixed $value): string return (string) $value; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -54,7 +54,7 @@ public function assert(mixed $value): string return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/U16Type.php b/src/Psl/Type/Internal/U16Type.php index 3666d8fb..b630ebe8 100644 --- a/src/Psl/Type/Internal/U16Type.php +++ b/src/Psl/Type/Internal/U16Type.php @@ -40,15 +40,13 @@ public function matches(mixed $value): bool */ public function coerce(mixed $value): int { - $integer = Type\int() - ->withTrace($this->getTrace()->withFrame($this->toString())) - ->coerce($value); + $integer = Type\int()->coerce($value); if ($integer >= 0 && $integer <= MATH\UINT16_MAX) { return $integer; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -68,7 +66,7 @@ public function assert(mixed $value): int return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/U32Type.php b/src/Psl/Type/Internal/U32Type.php index d9c5fba7..1888afa9 100644 --- a/src/Psl/Type/Internal/U32Type.php +++ b/src/Psl/Type/Internal/U32Type.php @@ -40,15 +40,13 @@ public function matches(mixed $value): bool */ public function coerce(mixed $value): int { - $integer = Type\int() - ->withTrace($this->getTrace()->withFrame($this->toString())) - ->coerce($value); + $integer = Type\int()->coerce($value); if ($integer >= 0 && $integer <= MATH\UINT32_MAX) { return $integer; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -68,7 +66,7 @@ public function assert(mixed $value): int return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/U8Type.php b/src/Psl/Type/Internal/U8Type.php index e7ab6197..2411a578 100644 --- a/src/Psl/Type/Internal/U8Type.php +++ b/src/Psl/Type/Internal/U8Type.php @@ -40,15 +40,13 @@ public function matches(mixed $value): bool */ public function coerce(mixed $value): int { - $integer = Type\int() - ->withTrace($this->getTrace()->withFrame($this->toString())) - ->coerce($value); + $integer = Type\int()->coerce($value); if ($integer >= 0 && $integer <= MATH\UINT8_MAX) { return $integer; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -68,7 +66,7 @@ public function assert(mixed $value): int return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/UIntType.php b/src/Psl/Type/Internal/UIntType.php index 21d75cbc..58e41ef2 100644 --- a/src/Psl/Type/Internal/UIntType.php +++ b/src/Psl/Type/Internal/UIntType.php @@ -68,7 +68,7 @@ public function coerce(mixed $value): int } } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -84,7 +84,7 @@ public function assert(mixed $value): int return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/UnionType.php b/src/Psl/Type/Internal/UnionType.php index 4e8ed678..bce68782 100644 --- a/src/Psl/Type/Internal/UnionType.php +++ b/src/Psl/Type/Internal/UnionType.php @@ -62,7 +62,7 @@ public function coerce(mixed $value): mixed // ignore } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -86,7 +86,7 @@ public function assert(mixed $value): mixed // ignore } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/UnitEnumType.php b/src/Psl/Type/Internal/UnitEnumType.php index 564b030d..78bc8604 100644 --- a/src/Psl/Type/Internal/UnitEnumType.php +++ b/src/Psl/Type/Internal/UnitEnumType.php @@ -41,7 +41,7 @@ public function coerce(mixed $value): UnitEnum return $value; } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -57,7 +57,7 @@ public function assert(mixed $value): UnitEnum return $value; } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Internal/VecType.php b/src/Psl/Type/Internal/VecType.php index 00cde252..ab49e760 100644 --- a/src/Psl/Type/Internal/VecType.php +++ b/src/Psl/Type/Internal/VecType.php @@ -7,6 +7,7 @@ use Psl\Type; use Psl\Type\Exception\AssertException; use Psl\Type\Exception\CoercionException; +use Throwable; use function array_is_list; use function is_array; @@ -55,23 +56,32 @@ public function matches(mixed $value): bool public function coerce(mixed $value): iterable { if (! is_iterable($value)) { - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } - /** @var Type\Type $value_type */ - $value_type = $this->value_type->withTrace( - $this->getTrace() - ->withFrame($this->toString()) - ); - /** * @var list $entries */ $result = []; - - /** @var Tv $v */ - foreach ($value as $v) { - $result[] = $value_type->coerce($v); + $value_type = $this->value_type; + $i = $v = null; + $iterating = true; + + try { + /** + * @var Tv $v + * @var array-key $i + */ + foreach ($value as $i => $v) { + $iterating = false; + $result[] = $value_type->coerce($v); + $iterating = true; + } + } catch (Throwable $e) { + throw match (true) { + $iterating => CoercionException::withValue(null, $this->toString(), PathExpression::iteratorError($i), $e), + default => CoercionException::withValue($v, $this->toString(), PathExpression::path($i), $e) + }; } return $result; @@ -87,22 +97,23 @@ public function coerce(mixed $value): iterable public function assert(mixed $value): array { if (! is_array($value) || !array_is_list($value)) { - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } - /** @var Type\Type $value_type */ - $value_type = $this->value_type->withTrace( - $this->getTrace() - ->withFrame('vec<' . $this->value_type->toString() . '>') - ); - $result = []; - - /** - * @var Tv $v - */ - foreach ($value as $v) { - $result[] = $value_type->assert($v); + $value_type = $this->value_type; + $i = $v = null; + + try { + /** + * @var Tv $v + * @var array-key $i + */ + foreach ($value as $i => $v) { + $result[] = $value_type->assert($v); + } + } catch (AssertException $e) { + throw AssertException::withValue($v, $this->toString(), PathExpression::path($i), $e); } return $result; diff --git a/src/Psl/Type/Internal/VectorType.php b/src/Psl/Type/Internal/VectorType.php index abe72724..da5c8d0d 100644 --- a/src/Psl/Type/Internal/VectorType.php +++ b/src/Psl/Type/Internal/VectorType.php @@ -9,6 +9,7 @@ use Psl\Type; use Psl\Type\Exception\AssertException; use Psl\Type\Exception\CoercionException; +use Throwable; use function is_iterable; use function is_object; @@ -38,30 +39,38 @@ public function __construct( public function coerce(mixed $value): Collection\VectorInterface { if (is_iterable($value)) { - $value_trace = $this->getTrace()->withFrame( - Str\format('%s<%s>', Collection\VectorInterface::class, $this->value_type->toString()) - ); - /** @var Type\Type $value_type */ - $value_type = $this->value_type->withTrace($value_trace); + $value_type = $this->value_type; /** * @var list $values */ $values = []; - - /** - * @var T $v - */ - foreach ($value as $v) { - $values[] = $value_type->coerce($v); + $i = $v = null; + $iterating = true; + + try { + /** + * @var T $v + * @var array-key $i + */ + foreach ($value as $i => $v) { + $iterating = false; + $values[] = $value_type->coerce($v); + $iterating = true; + } + } catch (Throwable $e) { + throw match (true) { + $iterating => CoercionException::withValue(null, $this->toString(), PathExpression::iteratorError($i), $e), + default => CoercionException::withValue($v, $this->toString(), PathExpression::path($i), $e) + }; } /** @var Collection\Vector */ return new Collection\Vector($values); } - throw CoercionException::withValue($value, $this->toString(), $this->getTrace()); + throw CoercionException::withValue($value, $this->toString()); } /** @@ -76,30 +85,32 @@ public function coerce(mixed $value): Collection\VectorInterface public function assert(mixed $value): Collection\VectorInterface { if (is_object($value) && $value instanceof Collection\VectorInterface) { - $value_trace = $this->getTrace()->withFrame( - Str\format('%s<%s>', Collection\VectorInterface::class, $this->value_type->toString()) - ); - /** @var Type\Type $value_type */ - $value_type = $this->value_type->withTrace($value_trace); + $value_type = $this->value_type; /** * @var list $values */ $values = []; - - /** - * @var T $v - */ - foreach ($value as $v) { - $values[] = $value_type->coerce($v); + $i = $v = null; + + try { + /** + * @var T $v + * @var array-key $i + */ + foreach ($value as $i => $v) { + $values[] = $value_type->assert($v); + } + } catch (AssertException $e) { + throw AssertException::withValue($v, $this->toString(), PathExpression::path($i), $e); } /** @var Collection\Vector */ return new Collection\Vector($values); } - throw AssertException::withValue($value, $this->toString(), $this->getTrace()); + throw AssertException::withValue($value, $this->toString()); } public function toString(): string diff --git a/src/Psl/Type/Type.php b/src/Psl/Type/Type.php index 0bd6e2cf..1935157b 100644 --- a/src/Psl/Type/Type.php +++ b/src/Psl/Type/Type.php @@ -5,7 +5,6 @@ namespace Psl\Type; use Psl\Type\Exception\AssertException; -use Psl\Type\Exception\TypeTrace; /** * @template-covariant T @@ -14,8 +13,6 @@ */ abstract class Type implements TypeInterface { - private ?TypeTrace $trace = null; - /** * @psalm-assert-if-true T $value */ @@ -30,22 +27,6 @@ public function matches(mixed $value): bool } } - protected function getTrace(): TypeTrace - { - return $this->trace - ?? $this->trace = new TypeTrace(); - } - - /** - * @return TypeInterface - */ - public function withTrace(TypeTrace $trace): TypeInterface - { - $new = clone $this; - $new->trace = $trace; - return $new; - } - public function isOptional(): bool { return false; diff --git a/src/Psl/Type/TypeInterface.php b/src/Psl/Type/TypeInterface.php index 4c1831da..d69e6c15 100644 --- a/src/Psl/Type/TypeInterface.php +++ b/src/Psl/Type/TypeInterface.php @@ -6,7 +6,6 @@ use Psl\Type\Exception\AssertException; use Psl\Type\Exception\CoercionException; -use Psl\Type\Exception\TypeTrace; /** * @template-covariant T @@ -43,9 +42,4 @@ public function isOptional(): bool; * Returns a string representation of the type. */ public function toString(): string; - - /** - * @return TypeInterface - */ - public function withTrace(TypeTrace $trace): TypeInterface; } diff --git a/tests/unit/Class/ClassTest.php b/tests/unit/Class/ClassTest.php index 7293f602..2afb89e9 100644 --- a/tests/unit/Class/ClassTest.php +++ b/tests/unit/Class/ClassTest.php @@ -50,7 +50,7 @@ public function provideData(): iterable yield [Collection\MutableVector::class, true, true, false, ['first', 'last'], []]; yield [Collection\Map::class, true, true, false, ['first', 'last'], []]; yield [Collection\MutableMap::class, true, true, false, ['first', 'last'], []]; - yield [Type\Type::class, true, false, true, ['matches', 'getTrace', 'withTrace', 'isOptional'], []]; + yield [Type\Type::class, true, false, true, ['matches', 'isOptional'], []]; yield ['Psl\\Not\\Class', false, false, false, [], []]; } diff --git a/tests/unit/Json/TypedTest.php b/tests/unit/Json/TypedTest.php index b01b9fc7..96b32f81 100644 --- a/tests/unit/Json/TypedTest.php +++ b/tests/unit/Json/TypedTest.php @@ -47,7 +47,7 @@ public function testTypedVector(): void public function testTypedThrowsWhenUnableToCoerce(): void { $this->expectException(Json\Exception\DecodeException::class); - $this->expectExceptionMessage('Could not coerce "string" to type "int".'); + $this->expectExceptionMessage('Could not coerce "string" to type "' . MapInterface::class . '" at path "name".'); Json\typed('{ "name": "azjezz/psl", diff --git a/tests/unit/Type/ConvertedTypeTest.php b/tests/unit/Type/ConvertedTypeTest.php index cfda2ad8..f7fa5302 100644 --- a/tests/unit/Type/ConvertedTypeTest.php +++ b/tests/unit/Type/ConvertedTypeTest.php @@ -5,6 +5,7 @@ namespace Psl\Tests\Unit\Type; use DateTimeImmutable; +use Psl\Str; use Psl\Type; use RuntimeException; @@ -61,4 +62,50 @@ public function getToStringExamples(): iterable { yield [$this->getType(), DateTimeImmutable::class]; } + + public static function provideCoerceExceptionExpectations(): iterable + { + yield 'Coerce input error' => [ + Type\converted( + Type\int(), + Type\string(), + static fn (int $i): string => (string) $i + ), + new class () { + }, + 'Could not coerce "class@anonymous" to type "int" at path "coerce_input(class@anonymous): int".' + ]; + yield 'Convert exception error' => [ + Type\converted( + Type\int(), + Type\string(), + static fn (int $i): string => throw new RuntimeException('not possible') + ), + 1, + 'Could not coerce "int" to type "string" at path "convert(int): string": not possible.' + ]; + yield 'Coerce output error' => [ + Type\converted( + Type\int(), + Type\string(), + static fn (int $i): object => new class () { + } + ), + 1, + 'Could not coerce "class@anonymous" to type "string" at path "coerce_output(class@anonymous): string".' + ]; + } + + /** + * @dataProvider provideCoerceExceptionExpectations + */ + public function testInvalidCoercionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->coerce($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class)); + } catch (Type\Exception\CoercionException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } } diff --git a/tests/unit/Type/DictTypeTest.php b/tests/unit/Type/DictTypeTest.php index ced70021..a9e3bb00 100644 --- a/tests/unit/Type/DictTypeTest.php +++ b/tests/unit/Type/DictTypeTest.php @@ -10,25 +10,13 @@ use Psl\Str; use Psl\Type; use Psl\Vec; +use RuntimeException; /** * @extends TypeTest> */ final class DictTypeTest extends TypeTest { - public function testWithTraceReturnsAClone(): void - { - $type = $this->getType(); - - $trace = new Type\Exception\TypeTrace(); - $new_trace = $trace->withFrame('foo'); - static::assertNotSame($new_trace, $trace); - - $new_type = $type->withTrace($new_trace); - - static::assertNotSame($new_type, $type); - } - public function getType(): Type\TypeInterface { return Type\dict(Type\int(), Type\int()); @@ -112,4 +100,101 @@ public function getToStringExamples(): iterable 'dict' ]; } + + public static function provideAssertExceptionExpectations(): iterable + { + yield 'invalid assertion key' => [ + Type\dict(Type\int(), Type\int()), + ['nope' => 1], + 'Expected "dict", got "string" at path "key(nope)".' + ]; + yield 'invalid assertion value' => [ + Type\dict(Type\int(), Type\int()), + [0 => 'nope'], + 'Expected "dict", got "string" at path "0".' + ]; + yield 'nested' => [ + Type\dict(Type\int(), Type\dict(Type\int(), Type\int())), + [0 => ['nope' => 'nope'],], + 'Expected "dict>", got "string" at path "0.key(nope)".', + ]; + } + + public static function provideCoerceExceptionExpectations(): iterable + { + yield 'invalid coercion key' => [ + Type\dict(Type\int(), Type\int()), + ['nope' => 1], + 'Could not coerce "string" to type "dict" at path "key(nope)".' + ]; + yield 'invalid coercion value' => [ + Type\dict(Type\int(), Type\int()), + [0 => 'nope'], + 'Could not coerce "string" to type "dict" at path "0".' + ]; + yield 'invalid iterator first item' => [ + Type\dict(Type\int(), Type\int()), + (static function () { + yield 0 => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "dict" at path "first()".' + ]; + yield 'invalid iterator second item' => [ + Type\dict(Type\int(), Type\int()), + (static function () { + yield 0 => 0; + yield 1 => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "dict" at path "0.next()".' + ]; + yield 'iterator throwing exception' => [ + Type\dict(Type\int(), Type\int()), + (static function () { + throw new RuntimeException('whoops'); + yield; + })(), + 'Could not coerce "null" to type "dict" at path "first()": whoops.' + ]; + yield 'iterator yielding null key' => [ + Type\dict(Type\int(), Type\int()), + (static function () { + yield null => 'nope'; + })(), + 'Could not coerce "null" to type "dict" at path "key(null)".' + ]; + yield 'iterator yielding object key' => [ + Type\dict(Type\int(), Type\int()), + (static function () { + yield (new class () { + }) => 'nope'; + })(), + 'Could not coerce "class@anonymous" to type "dict" at path "key(class@anonymous)".' + ]; + } + + /** + * @dataProvider provideAssertExceptionExpectations + */ + public function testInvalidAssertionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->assert($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\AssertException::class)); + } catch (Type\Exception\AssertException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } + + /** + * @dataProvider provideCoerceExceptionExpectations + */ + public function testInvalidCoercionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->coerce($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class)); + } catch (Type\Exception\CoercionException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } } diff --git a/tests/unit/Type/Exception/TypeAssertExceptionTest.php b/tests/unit/Type/Exception/TypeAssertExceptionTest.php index 13eb4026..79eb7288 100644 --- a/tests/unit/Type/Exception/TypeAssertExceptionTest.php +++ b/tests/unit/Type/Exception/TypeAssertExceptionTest.php @@ -5,53 +5,66 @@ namespace Psl\Tests\Unit\Type\Exception; use PHPUnit\Framework\TestCase; -use Psl\Collection; -use Psl\Iter; use Psl\Str; use Psl\Type; final class TypeAssertExceptionTest extends TestCase { - public function testIncorrectIterableKey(): void + public function testIncorrectResourceType(): void { - $type = Type\iterable(Type\int(), Type\instance_of(Collection\CollectionInterface::class)); + $type = Type\resource('curl'); try { - $type->assert([ - 'hello' => new Collection\Vector([1, 2, 3]) - ]); + $type->assert(STDIN); static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\AssertException::class)); } catch (Type\Exception\AssertException $e) { - static::assertSame('int', $e->getExpectedType()); - static::assertSame('string', $e->getActualType()); - static::assertSame('Expected "int", got "string".', $e->getMessage()); - - $trace = $e->getTypeTrace(); - $frames = $trace->getFrames(); - - static::assertCount(1, $frames); - static::assertSame('iterable', Iter\first($frames)); + static::assertSame('resource (curl)', $e->getExpectedType()); + static::assertSame('resource (stream)', $e->getActualType()); + static::assertSame('Expected "resource (curl)", got "resource (stream)".', $e->getMessage()); + static::assertSame(0, $e->getCode()); + static::assertSame([], $e->getPaths()); } } - public function testIncorrectResourceType(): void + public function testIncorrectNestedType() { - $type = Type\resource('curl'); + $type = Type\shape([ + 'child' => Type\shape([ + 'name' => Type\string(), + ]) + ]); try { - $type->assert(STDIN); + $type->assert(['child' => ['name' => 123]]); static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\AssertException::class)); } catch (Type\Exception\AssertException $e) { - static::assertSame('resource (curl)', $e->getExpectedType()); - static::assertSame('resource (stream)', $e->getActualType()); - static::assertSame('Expected "resource (curl)", got "resource (stream)".', $e->getMessage()); + static::assertSame('array{\'child\': array{\'name\': string}}', $e->getExpectedType()); + static::assertSame('array', $e->getActualType()); + static::assertSame('int', $e->getFirstFailingActualType()); + static::assertSame('Expected "array{\'child\': array{\'name\': string}}", got "int" at path "child.name".', $e->getMessage()); + static::assertSame(0, $e->getCode()); + static::assertSame(['child', 'name'], $e->getPaths()); + + $previous = $e->getPrevious(); + static::assertInstanceOf(Type\Exception\AssertException::class, $previous); + static::assertSame('Expected "array{\'name\': string}", got "int" at path "name".', $previous->getMessage()); + static::assertSame('int', $previous->getActualType()); + static::assertSame('int', $previous->getFirstFailingActualType()); + static::assertSame(0, $previous->getCode()); + static::assertSame(['name'], $previous->getpaths()); - $trace = $e->getTypeTrace(); - $frames = $trace->getFrames(); + $previous = $previous->getPrevious(); + static::assertInstanceOf(Type\Exception\AssertException::class, $previous); + static::assertSame('Expected "string", got "int".', $previous->getMessage()); + static::assertSame('int', $previous->getActualType()); + static::assertSame('int', $previous->getFirstFailingActualType()); + static::assertSame(0, $previous->getCode()); + static::assertSame([], $previous->getpaths()); - static::assertCount(0, $frames); + $previous = $previous->getPrevious(); + static::assertNull($previous); } } } diff --git a/tests/unit/Type/Exception/TypeCoercionExceptionTest.php b/tests/unit/Type/Exception/TypeCoercionExceptionTest.php index 5f033106..5c85186c 100644 --- a/tests/unit/Type/Exception/TypeCoercionExceptionTest.php +++ b/tests/unit/Type/Exception/TypeCoercionExceptionTest.php @@ -6,39 +6,11 @@ use PHPUnit\Framework\TestCase; use Psl\Collection; -use Psl\Iter; use Psl\Str; use Psl\Type; -use RuntimeException; final class TypeCoercionExceptionTest extends TestCase { - public function testIncorrectIterableKey(): void - { - $type = Type\iterable(Type\bool(), Type\instance_of(Collection\CollectionInterface::class)); - - try { - $type->coerce([ - 4 => new Collection\Vector([1, 2, 3]) - ]); - - static::fail(Str\format( - 'Expected "%s" exception to be thrown.', - Type\Exception\CoercionException::class - )); - } catch (Type\Exception\CoercionException $e) { - static::assertSame('bool', $e->getTargetType()); - static::assertSame('int', $e->getActualType()); - static::assertSame('Could not coerce "int" to type "bool".', $e->getMessage()); - - $trace = $e->getTypeTrace(); - $frames = $trace->getFrames(); - - static::assertCount(1, $frames); - static::assertSame('iterable', Iter\first($frames)); - } - } - public function testIncorrectResourceType(): void { $type = Type\resource('curl'); @@ -53,45 +25,54 @@ public function testIncorrectResourceType(): void } catch (Type\Exception\CoercionException $e) { static::assertSame('resource (curl)', $e->getTargetType()); static::assertSame(Collection\Map::class, $e->getActualType()); + static::assertSame(0, $e->getCode()); static::assertSame(Str\format( 'Could not coerce "%s" to type "resource (curl)".', Collection\Map::class ), $e->getMessage()); - - $trace = $e->getTypeTrace(); - $frames = $trace->getFrames(); - - static::assertCount(0, $frames); + static::assertSame([], $e->getPaths()); } } - public function testConversionFailure(): void + public function testIncorrectNestedType() { - $type = Type\converted( - Type\int(), - Type\string(), - static fn (int $i): string => throw new RuntimeException('not possible') - ); + $type = Type\shape([ + 'child' => Type\shape([ + 'name' => Type\string(), + ]) + ]); try { - $type->coerce(1); + $type->coerce(['child' => ['name' => new class () { + }]]); - static::fail(Str\format( - 'Expected "%s" exception to be thrown.', - Type\Exception\CoercionException::class - )); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class)); } catch (Type\Exception\CoercionException $e) { - static::assertSame('string', $e->getTargetType()); - static::assertSame('int', $e->getActualType()); - static::assertSame(Str\format( - 'Could not coerce "int" to type "string": not possible', - Collection\Map::class - ), $e->getMessage()); - - $trace = $e->getTypeTrace(); - $frames = $trace->getFrames(); - - static::assertCount(0, $frames); + static::assertSame('array{\'child\': array{\'name\': string}}', $e->getTargetType()); + static::assertSame('array', $e->getActualType()); + static::assertSame('class@anonymous', $e->getFirstFailingActualType()); + static::assertSame('Could not coerce "class@anonymous" to type "array{\'child\': array{\'name\': string}}" at path "child.name".', $e->getMessage()); + static::assertSame(0, $e->getCode()); + static::assertSame(['child', 'name'], $e->getPaths()); + + $previous = $e->getPrevious(); + static::assertInstanceOf(Type\Exception\CoercionException::class, $previous); + static::assertSame('Could not coerce "class@anonymous" to type "array{\'name\': string}" at path "name".', $previous->getMessage()); + static::assertSame('class@anonymous', $previous->getActualType()); + static::assertSame('class@anonymous', $previous->getFirstFailingActualType()); + static::assertSame(0, $previous->getCode()); + static::assertSame(['name'], $previous->getpaths()); + + $previous = $previous->getPrevious(); + static::assertInstanceOf(Type\Exception\CoercionException::class, $previous); + static::assertSame('Could not coerce "class@anonymous" to type "string".', $previous->getMessage()); + static::assertSame('class@anonymous', $previous->getActualType()); + static::assertSame('class@anonymous', $previous->getFirstFailingActualType()); + static::assertSame(0, $previous->getCode()); + static::assertSame([], $previous->getpaths()); + + $previous = $previous->getPrevious(); + static::assertNull($previous); } } } diff --git a/tests/unit/Type/Internal/PathExpressionTest.php b/tests/unit/Type/Internal/PathExpressionTest.php new file mode 100644 index 00000000..b9e08af2 --- /dev/null +++ b/tests/unit/Type/Internal/PathExpressionTest.php @@ -0,0 +1,59 @@ +> @@ -75,4 +76,101 @@ protected function equals($a, $b): bool return $a === $b; } + + public static function provideAssertExceptionExpectations(): iterable + { + yield 'invalid assertion key' => [ + Type\iterable(Type\int(), Type\int()), + ['nope' => 1], + 'Expected "iterable", got "string" at path "key(nope)".' + ]; + yield 'invalid assertion value' => [ + Type\iterable(Type\int(), Type\int()), + [0 => 'nope'], + 'Expected "iterable", got "string" at path "0".' + ]; + yield 'nested' => [ + Type\iterable(Type\int(), Type\iterable(Type\int(), Type\int())), + [0 => ['nope' => 'nope'],], + 'Expected "iterable>", got "string" at path "0.key(nope)".', + ]; + } + + public static function provideCoerceExceptionExpectations(): iterable + { + yield 'invalid coercion key' => [ + Type\iterable(Type\int(), Type\int()), + ['nope' => 1], + 'Could not coerce "string" to type "iterable" at path "key(nope)".' + ]; + yield 'invalid coercion value' => [ + Type\iterable(Type\int(), Type\int()), + [0 => 'nope'], + 'Could not coerce "string" to type "iterable" at path "0".' + ]; + yield 'invalid iterator first item' => [ + Type\iterable(Type\int(), Type\int()), + (static function () { + yield 0 => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "iterable" at path "first()".' + ]; + yield 'invalid iterator second item' => [ + Type\iterable(Type\int(), Type\int()), + (static function () { + yield 0 => 0; + yield 1 => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "iterable" at path "0.next()".' + ]; + yield 'iterator throwing exception' => [ + Type\iterable(Type\int(), Type\int()), + (static function () { + throw new RuntimeException('whoops'); + yield; + })(), + 'Could not coerce "null" to type "iterable" at path "first()": whoops.' + ]; + yield 'iterator yielding null key' => [ + Type\iterable(Type\int(), Type\int()), + (static function () { + yield null => 'nope'; + })(), + 'Could not coerce "null" to type "iterable" at path "key(null)".' + ]; + yield 'iterator yielding object key' => [ + Type\iterable(Type\int(), Type\int()), + (static function () { + yield (new class () { + }) => 'nope'; + })(), + 'Could not coerce "class@anonymous" to type "iterable" at path "key(class@anonymous)".' + ]; + } + + /** + * @dataProvider provideAssertExceptionExpectations + */ + public function testInvalidAssertionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->assert($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\AssertException::class)); + } catch (Type\Exception\AssertException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } + + /** + * @dataProvider provideCoerceExceptionExpectations + */ + public function testInvalidCoercionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->coerce($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class)); + } catch (Type\Exception\CoercionException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } } diff --git a/tests/unit/Type/MapTypeTest.php b/tests/unit/Type/MapTypeTest.php index 471c0f8c..3cdc7326 100644 --- a/tests/unit/Type/MapTypeTest.php +++ b/tests/unit/Type/MapTypeTest.php @@ -5,11 +5,13 @@ namespace Psl\Tests\Unit\Type; use Psl\Collection; +use Psl\Collection\MapInterface; use Psl\Dict; use Psl\Iter; use Psl\Str; use Psl\Type; use Psl\Vec; +use RuntimeException; /** * @extends TypeTest> @@ -77,19 +79,116 @@ public function getToStringExamples(): iterable } /** - * @param Collection\MapInterface|mixed $a - * @param Collection\MapInterface|mixed $b + * @param MapInterface|mixed $a + * @param MapInterface|mixed $b */ protected function equals($a, $b): bool { - if (Type\instance_of(Collection\MapInterface::class)->matches($a)) { + if (Type\instance_of(MapInterface::class)->matches($a)) { $a = $a->toArray(); } - if (Type\instance_of(Collection\MapInterface::class)->matches($b)) { + if (Type\instance_of(MapInterface::class)->matches($b)) { $b = $b->toArray(); } return parent::equals($a, $b); } + + public static function provideAssertExceptionExpectations(): iterable + { + yield 'invalid assertion key' => [ + Type\map(Type\int(), Type\int()), + new Collection\Map(['nope' => 1]), + 'Expected "' . MapInterface::class . '", got "string" at path "key(nope)".' + ]; + yield 'invalid assertion value' => [ + Type\map(Type\int(), Type\int()), + new Collection\Map([0 => 'nope']), + 'Expected "' . MapInterface::class . '", got "string" at path "0".' + ]; + yield 'nested' => [ + Type\map(Type\int(), Type\map(Type\int(), Type\int())), + new Collection\Map([0 => new Collection\Map(['nope' => 'nope'])]), + 'Expected "' . MapInterface::class . '>", got "string" at path "0.key(nope)".', + ]; + } + + public static function provideCoerceExceptionExpectations(): iterable + { + yield 'invalid coercion key' => [ + Type\map(Type\int(), Type\int()), + ['nope' => 1], + 'Could not coerce "string" to type "' . MapInterface::class . '" at path "key(nope)".' + ]; + yield 'invalid coercion value' => [ + Type\map(Type\int(), Type\int()), + [0 => 'nope'], + 'Could not coerce "string" to type "' . MapInterface::class . '" at path "0".' + ]; + yield 'invalid iterator first item' => [ + Type\map(Type\int(), Type\int()), + (static function () { + yield 0 => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "' . MapInterface::class . '" at path "first()".' + ]; + yield 'invalid iterator second item' => [ + Type\map(Type\int(), Type\int()), + (static function () { + yield 0 => 0; + yield 1 => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "' . MapInterface::class . '" at path "0.next()".' + ]; + yield 'iterator throwing exception' => [ + Type\map(Type\int(), Type\int()), + (static function () { + throw new RuntimeException('whoops'); + yield; + })(), + 'Could not coerce "null" to type "' . MapInterface::class . '" at path "first()": whoops.' + ]; + yield 'iterator yielding null key' => [ + Type\map(Type\int(), Type\int()), + (static function () { + yield null => 'nope'; + })(), + 'Could not coerce "null" to type "' . MapInterface::class . '" at path "key(null)".' + ]; + yield 'iterator yielding object key' => [ + Type\map(Type\int(), Type\int()), + (static function () { + yield (new class () { + }) => 'nope'; + })(), + 'Could not coerce "class@anonymous" to type "' . MapInterface::class . '" at path "key(class@anonymous)".' + ]; + } + + /** + * @dataProvider provideAssertExceptionExpectations + */ + public function testInvalidAssertionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->assert($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\AssertException::class)); + } catch (Type\Exception\AssertException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } + + /** + * @dataProvider provideCoerceExceptionExpectations + */ + public function testInvalidCoercionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->coerce($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class)); + } catch (Type\Exception\CoercionException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } } diff --git a/tests/unit/Type/MixedDictTypeTest.php b/tests/unit/Type/MixedDictTypeTest.php index 42ba5109..b29f28ae 100644 --- a/tests/unit/Type/MixedDictTypeTest.php +++ b/tests/unit/Type/MixedDictTypeTest.php @@ -10,6 +10,7 @@ use Psl\Str; use Psl\Type; use Psl\Vec; +use RuntimeException; use SplObjectStorage; use stdClass; @@ -86,4 +87,60 @@ public function getToStringExamples(): iterable { yield [$this->getType(), 'dict']; } + + + public static function provideCoerceExceptionExpectations(): iterable + { + yield 'invalid iterator first item' => [ + Type\mixed_dict(), + (static function () { + yield 0 => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "dict" at path "first()".' + ]; + yield 'invalid iterator second item' => [ + Type\mixed_dict(), + (static function () { + yield 0 => 0; + yield 1 => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "dict" at path "0.next()".' + ]; + yield 'iterator throwing exception' => [ + Type\mixed_dict(), + (static function () { + throw new RuntimeException('whoops'); + yield; + })(), + 'Could not coerce "null" to type "dict" at path "first()": whoops.' + ]; + yield 'iterator yielding null key' => [ + Type\mixed_dict(), + (static function () { + yield null => 'nope'; + })(), + 'Could not coerce "null" to type "dict" at path "key(null)".' + ]; + yield 'iterator yielding object key' => [ + Type\mixed_dict(), + (static function () { + yield (new class () { + }) => 'nope'; + })(), + 'Could not coerce "class@anonymous" to type "dict" at path "key(class@anonymous)".' + ]; + } + + /** + * @dataProvider provideCoerceExceptionExpectations + */ + public function testInvalidCoercionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->coerce($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class)); + } catch (Type\Exception\CoercionException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } } diff --git a/tests/unit/Type/MutableMapTypeTest.php b/tests/unit/Type/MutableMapTypeTest.php index e81db4fe..71a228fa 100644 --- a/tests/unit/Type/MutableMapTypeTest.php +++ b/tests/unit/Type/MutableMapTypeTest.php @@ -5,14 +5,16 @@ namespace Psl\Tests\Unit\Type; use Psl\Collection; +use Psl\Collection\MutableMapInterface; use Psl\Dict; use Psl\Iter; use Psl\Str; use Psl\Type; use Psl\Vec; +use RuntimeException; /** - * @extends TypeTest> + * @extends TypeTest> */ final class MutableMapTypeTest extends TypeTest { @@ -94,19 +96,116 @@ public function getToStringExamples(): iterable } /** - * @param Collection\MutableMapInterface|mixed $a - * @param Collection\MutableMapInterface|mixed $b + * @param MutableMapInterface|mixed $a + * @param MutableMapInterface|mixed $b */ protected function equals($a, $b): bool { - if (Type\instance_of(Collection\MutableMapInterface::class)->matches($a)) { + if (Type\instance_of(MutableMapInterface::class)->matches($a)) { $a = $a->toArray(); } - if (Type\instance_of(Collection\MutableMapInterface::class)->matches($b)) { + if (Type\instance_of(MutableMapInterface::class)->matches($b)) { $b = $b->toArray(); } return parent::equals($a, $b); } + + public static function provideAssertExceptionExpectations(): iterable + { + yield 'invalid assertion key' => [ + Type\mutable_map(Type\int(), Type\int()), + new Collection\MutableMap(['nope' => 1]), + 'Expected "' . MutableMapInterface::class . '", got "string" at path "key(nope)".' + ]; + yield 'invalid assertion value' => [ + Type\mutable_map(Type\int(), Type\int()), + new Collection\MutableMap([0 => 'nope']), + 'Expected "' . MutableMapInterface::class . '", got "string" at path "0".' + ]; + yield 'nested' => [ + Type\mutable_map(Type\int(), Type\mutable_map(Type\int(), Type\int())), + new Collection\MutableMap([0 => new Collection\MutableMap(['nope' => 'nope'])]), + 'Expected "' . MutableMapInterface::class . '>", got "string" at path "0.key(nope)".', + ]; + } + + public static function provideCoerceExceptionExpectations(): iterable + { + yield 'invalid coercion key' => [ + Type\mutable_map(Type\int(), Type\int()), + ['nope' => 1], + 'Could not coerce "string" to type "' . MutableMapInterface::class . '" at path "key(nope)".' + ]; + yield 'invalid coercion value' => [ + Type\mutable_map(Type\int(), Type\int()), + [0 => 'nope'], + 'Could not coerce "string" to type "' . MutableMapInterface::class . '" at path "0".' + ]; + yield 'invalid iterator first item' => [ + Type\mutable_map(Type\int(), Type\int()), + (static function () { + yield 0 => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "' . MutableMapInterface::class . '" at path "first()".' + ]; + yield 'invalid iterator second item' => [ + Type\mutable_map(Type\int(), Type\int()), + (static function () { + yield 0 => 0; + yield 1 => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "' . MutableMapInterface::class . '" at path "0.next()".' + ]; + yield 'iterator throwing exception' => [ + Type\mutable_map(Type\int(), Type\int()), + (static function () { + throw new RuntimeException('whoops'); + yield; + })(), + 'Could not coerce "null" to type "' . MutableMapInterface::class . '" at path "first()": whoops.' + ]; + yield 'iterator yielding null key' => [ + Type\mutable_map(Type\int(), Type\int()), + (static function () { + yield null => 'nope'; + })(), + 'Could not coerce "null" to type "' . MutableMapInterface::class . '" at path "key(null)".' + ]; + yield 'iterator yielding object key' => [ + Type\mutable_map(Type\int(), Type\int()), + (static function () { + yield (new class () { + }) => 'nope'; + })(), + 'Could not coerce "class@anonymous" to type "' . MutableMapInterface::class . '" at path "key(class@anonymous)".' + ]; + } + + /** + * @dataProvider provideAssertExceptionExpectations + */ + public function testInvalidAssertionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->assert($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\AssertException::class)); + } catch (Type\Exception\AssertException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } + + /** + * @dataProvider provideCoerceExceptionExpectations + */ + public function testInvalidCoercionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->coerce($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class)); + } catch (Type\Exception\CoercionException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } } diff --git a/tests/unit/Type/MutableVectorTypeTest.php b/tests/unit/Type/MutableVectorTypeTest.php index 6fc0994c..bd519432 100644 --- a/tests/unit/Type/MutableVectorTypeTest.php +++ b/tests/unit/Type/MutableVectorTypeTest.php @@ -5,14 +5,16 @@ namespace Psl\Tests\Unit\Type; use Psl\Collection; +use Psl\Collection\MutableVectorInterface; use Psl\Dict; use Psl\Iter; use Psl\Str; use Psl\Type; use Psl\Vec; +use RuntimeException; /** - * @extends TypeTest> + * @extends TypeTest> */ final class MutableVectorTypeTest extends TypeTest { @@ -86,19 +88,106 @@ public function getToStringExamples(): iterable } /** - * @param Collection\MutableVectorInterface|mixed $a - * @param Collection\MutableVectorInterface|mixed $b + * @param MutableVectorInterface|mixed $a + * @param MutableVectorInterface|mixed $b */ protected function equals($a, $b): bool { - if (Type\instance_of(Collection\MutableVectorInterface::class)->matches($a)) { + if (Type\instance_of(MutableVectorInterface::class)->matches($a)) { $a = $a->toArray(); } - if (Type\instance_of(Collection\MutableVectorInterface::class)->matches($b)) { + if (Type\instance_of(MutableVectorInterface::class)->matches($b)) { $b = $b->toArray(); } return parent::equals($a, $b); } + + public static function provideAssertExceptionExpectations(): iterable + { + yield 'invalid assertion value' => [ + Type\mutable_vector(Type\int()), + new Collection\MutableVector(['nope']), + 'Expected "' . MutableVectorInterface::class . '", got "string" at path "0".' + ]; + yield 'nested' => [ + Type\mutable_vector(Type\mutable_vector(Type\int())), + new Collection\MutableVector([new Collection\MutableVector(['nope'])]), + 'Expected "' . MutableVectorInterface::class . '<' . MutableVectorInterface::class . '>", got "string" at path "0.0".', + ]; + } + + public static function provideCoerceExceptionExpectations(): iterable + { + yield 'invalid coercion value' => [ + Type\mutable_vector(Type\int()), + ['nope'], + 'Could not coerce "string" to type "' . MutableVectorInterface::class . '" at path "0".' + ]; + yield 'invalid iterator first item' => [ + Type\mutable_vector(Type\int()), + (static function () { + yield Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "' . MutableVectorInterface::class . '" at path "first()".' + ]; + yield 'invalid iterator second item' => [ + Type\mutable_vector(Type\int()), + (static function () { + yield 0; + yield Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "' . MutableVectorInterface::class . '" at path "0.next()".' + ]; + yield 'iterator throwing exception' => [ + Type\mutable_vector(Type\int()), + (static function () { + yield 0; + throw new RuntimeException('whoops'); + })(), + 'Could not coerce "null" to type "' . MutableVectorInterface::class . '" at path "0.next()": whoops.' + ]; + yield 'iterator yielding null key' => [ + Type\mutable_vector(Type\int()), + (static function () { + yield null => 'nope'; + })(), + 'Could not coerce "string" to type "' . MutableVectorInterface::class . '" at path "null".' + ]; + yield 'iterator yielding object key' => [ + Type\mutable_vector(Type\int()), + (static function () { + yield (new class () { + }) => 'nope'; + })(), + 'Could not coerce "string" to type "' . MutableVectorInterface::class . '" at path "class@anonymous".' + ]; + } + + /** + * @dataProvider provideAssertExceptionExpectations + */ + public function testInvalidAssertionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->assert($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\AssertException::class)); + } catch (Type\Exception\AssertException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } + + /** + * @dataProvider provideCoerceExceptionExpectations + */ + public function testInvalidCoercionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->coerce($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class)); + } catch (Type\Exception\CoercionException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } } diff --git a/tests/unit/Type/NonEmptyDictTypeTest.php b/tests/unit/Type/NonEmptyDictTypeTest.php index ac45a889..f7b535a1 100644 --- a/tests/unit/Type/NonEmptyDictTypeTest.php +++ b/tests/unit/Type/NonEmptyDictTypeTest.php @@ -10,6 +10,7 @@ use Psl\Str; use Psl\Type; use Psl\Vec; +use RuntimeException; /** * @extends TypeTest> @@ -100,4 +101,101 @@ public function getToStringExamples(): iterable 'non-empty-dict' ]; } + + public static function provideAssertExceptionExpectations(): iterable + { + yield 'invalid assertion key' => [ + Type\non_empty_dict(Type\int(), Type\int()), + ['nope' => 1], + 'Expected "non-empty-dict", got "string" at path "key(nope)".' + ]; + yield 'invalid assertion value' => [ + Type\non_empty_dict(Type\int(), Type\int()), + [0 => 'nope'], + 'Expected "non-empty-dict", got "string" at path "0".' + ]; + yield 'nested' => [ + Type\non_empty_dict(Type\int(), Type\non_empty_dict(Type\int(), Type\int())), + [0 => ['nope' => 'nope'],], + 'Expected "non-empty-dict>", got "string" at path "0.key(nope)".', + ]; + } + + public static function provideCoerceExceptionExpectations(): iterable + { + yield 'invalid coercion key' => [ + Type\non_empty_dict(Type\int(), Type\int()), + ['nope' => 1], + 'Could not coerce "string" to type "non-empty-dict" at path "key(nope)".' + ]; + yield 'invalid coercion value' => [ + Type\non_empty_dict(Type\int(), Type\int()), + [0 => 'nope'], + 'Could not coerce "string" to type "non-empty-dict" at path "0".' + ]; + yield 'invalid iterator first item' => [ + Type\non_empty_dict(Type\int(), Type\int()), + (static function () { + yield 0 => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "non-empty-dict" at path "first()".' + ]; + yield 'invalid iterator second item' => [ + Type\non_empty_dict(Type\int(), Type\int()), + (static function () { + yield 0 => 0; + yield 1 => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "non-empty-dict" at path "0.next()".' + ]; + yield 'iterator throwing exception' => [ + Type\non_empty_dict(Type\int(), Type\int()), + (static function () { + throw new RuntimeException('whoops'); + yield; + })(), + 'Could not coerce "null" to type "non-empty-dict" at path "first()": whoops.' + ]; + yield 'iterator yielding null key' => [ + Type\non_empty_dict(Type\int(), Type\int()), + (static function () { + yield null => 'nope'; + })(), + 'Could not coerce "null" to type "non-empty-dict" at path "key(null)".' + ]; + yield 'iterator yielding object key' => [ + Type\non_empty_dict(Type\int(), Type\int()), + (static function () { + yield (new class () { + }) => 'nope'; + })(), + 'Could not coerce "class@anonymous" to type "non-empty-dict" at path "key(class@anonymous)".' + ]; + } + + /** + * @dataProvider provideAssertExceptionExpectations + */ + public function testInvalidAssertionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->assert($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\AssertException::class)); + } catch (Type\Exception\AssertException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } + + /** + * @dataProvider provideCoerceExceptionExpectations + */ + public function testInvalidCoercionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->coerce($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class)); + } catch (Type\Exception\CoercionException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } } diff --git a/tests/unit/Type/NonEmptyVecTypeTest.php b/tests/unit/Type/NonEmptyVecTypeTest.php index 06a39d69..5022d444 100644 --- a/tests/unit/Type/NonEmptyVecTypeTest.php +++ b/tests/unit/Type/NonEmptyVecTypeTest.php @@ -10,6 +10,7 @@ use Psl\Str; use Psl\Type; use Psl\Vec; +use RuntimeException; /** * @extends TypeTest> @@ -90,4 +91,91 @@ public function getToStringExamples(): iterable 'non-empty-vec' ]; } + + public static function provideAssertExceptionExpectations(): iterable + { + yield 'invalid assertion value' => [ + Type\vec(Type\int()), + ['nope'], + 'Expected "vec", got "string" at path "0".' + ]; + yield 'nested' => [ + Type\vec(Type\vec(Type\int())), + [['nope']], + 'Expected "vec>", got "string" at path "0.0".', + ]; + } + + public static function provideCoerceExceptionExpectations(): iterable + { + yield 'invalid coercion value' => [ + Type\vec(Type\int()), + ['nope'], + 'Could not coerce "string" to type "vec" at path "0".' + ]; + yield 'invalid iterator first item' => [ + Type\vec(Type\int()), + (static function () { + yield Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "vec" at path "first()".' + ]; + yield 'invalid iterator second item' => [ + Type\vec(Type\int()), + (static function () { + yield 0; + yield Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "vec" at path "0.next()".' + ]; + yield 'iterator throwing exception' => [ + Type\vec(Type\int()), + (static function () { + yield 0; + throw new RuntimeException('whoops'); + })(), + 'Could not coerce "null" to type "vec" at path "0.next()": whoops.' + ]; + yield 'iterator yielding null key' => [ + Type\vec(Type\int()), + (static function () { + yield null => 'nope'; + })(), + 'Could not coerce "string" to type "vec" at path "null".' + ]; + yield 'iterator yielding object key' => [ + Type\vec(Type\int()), + (static function () { + yield (new class () { + }) => 'nope'; + })(), + 'Could not coerce "string" to type "vec" at path "class@anonymous".' + ]; + } + + /** + * @dataProvider provideAssertExceptionExpectations + */ + public function testInvalidAssertionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->assert($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\AssertException::class)); + } catch (Type\Exception\AssertException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } + + /** + * @dataProvider provideCoerceExceptionExpectations + */ + public function testInvalidCoercionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->coerce($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class)); + } catch (Type\Exception\CoercionException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } } diff --git a/tests/unit/Type/ShapeTypeTest.php b/tests/unit/Type/ShapeTypeTest.php index 43809d17..ac6a2339 100644 --- a/tests/unit/Type/ShapeTypeTest.php +++ b/tests/unit/Type/ShapeTypeTest.php @@ -7,7 +7,9 @@ use ArrayIterator; use Psl\Collection; use Psl\Iter; +use Psl\Str; use Psl\Type; +use RuntimeException; /** * @extends TypeTest @@ -30,19 +32,6 @@ public function getType(): Type\TypeInterface ]); } - public function testInvalidAssertionExtraKey(): void - { - $this->expectException(Type\Exception\AssertException::class); - - $this->getType()->assert([ - 'name' => 'saif', - 'articles' => [ - ['title' => 'Foo', 'content' => 'Bar', 'likes' => 0, 'dislikes' => 5], - ['title' => 'Baz', 'content' => 'Qux', 'likes' => 13, 'dislikes' => 3], - ] - ]); - } - public function testWillConsiderUnknownIterableFieldsWhenCoercing(): void { static::assertEquals( @@ -209,4 +198,140 @@ protected function equals($a, $b): bool return parent::equals($a, $b); } + + public static function provideAssertExceptionExpectations(): iterable + { + yield 'extra key' => [ + Type\shape([ + 'name' => Type\string(), + ]), + [ + 'name' => 'saif', + 'extra' => 123, + ], + 'Expected "array{\'name\': string}", got "int" at path "extra".' + ]; + yield 'missing key' => [ + Type\shape([ + 'name' => Type\string(), + ]), + [], + 'Expected "array{\'name\': string}", got "null" at path "name".' + ]; + yield 'invalid key' => [ + Type\shape([ + 'name' => Type\string(), + ]), + ['name' => 123], + 'Expected "array{\'name\': string}", got "int" at path "name".' + ]; + yield 'nested' => [ + Type\shape([ + 'item' => Type\shape([ + 'name' => Type\string(), + ]), + ]), + [ + 'item' => [ + 'name' => 123, + ] + ], + 'Expected "array{\'item\': array{\'name\': string}}", got "int" at path "item.name".', + ]; + } + + public static function provideCoerceExceptionExpectations(): iterable + { + yield 'missing key' => [ + Type\shape([ + 'name' => Type\string(), + ]), + [], + 'Could not coerce "null" to type "array{\'name\': string}" at path "name".' + ]; + yield 'invalid key' => [ + Type\shape([ + 'name' => Type\string(), + ]), + [ + 'name' => new class () { + }, + ], + 'Could not coerce "class@anonymous" to type "array{\'name\': string}" at path "name".', + ]; + yield 'invalid iterator first item' => [ + Type\shape([ + 'id' => Type\int(), + ]), + (static function () { + yield 'id' => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "array{\'id\': int}" at path "first()".' + ]; + yield 'invalid iterator second item' => [ + Type\shape([ + 'id' => Type\int(), + ]), + (static function () { + yield 'id' => 1; + yield 'next' => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "array{\'id\': int}" at path "id.next()".' + ]; + yield 'iterator throwing exception' => [ + Type\shape([ + 'id' => Type\int(), + ]), + (static function () { + throw new RuntimeException('whoops'); + yield; + })(), + 'Could not coerce "null" to type "array{\'id\': int}" at path "first()": whoops.' + ]; + yield 'iterator yielding null key' => [ + Type\shape([ + 'id' => Type\int(), + ]), + (static function () { + yield null => 'nope'; + })(), + 'Could not coerce "null" to type "array{\'id\': int}" at path "id".' + ]; + yield 'iterator yielding object key' => [ + Type\shape([ + 'id' => Type\int(), + ]), + (static function () { + yield (new class () { + }) => 'nope'; + })(), + 'Could not coerce "null" to type "array{\'id\': int}" at path "id".' + ]; + } + + /** + * @dataProvider provideAssertExceptionExpectations + */ + public function testInvalidAssertionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->assert($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\AssertException::class)); + } catch (Type\Exception\AssertException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } + + /** + * @dataProvider provideCoerceExceptionExpectations + */ + public function testInvalidCoercionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->coerce($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class)); + } catch (Type\Exception\CoercionException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } } diff --git a/tests/unit/Type/VecTypeTest.php b/tests/unit/Type/VecTypeTest.php index c2a7fc2d..d316ab15 100644 --- a/tests/unit/Type/VecTypeTest.php +++ b/tests/unit/Type/VecTypeTest.php @@ -10,6 +10,7 @@ use Psl\Str; use Psl\Type; use Psl\Vec; +use RuntimeException; /** * @extends TypeTest> @@ -94,4 +95,91 @@ public function getType(): Type\TypeInterface { return Type\vec(Type\int()); } + + public static function provideAssertExceptionExpectations(): iterable + { + yield 'invalid assertion value' => [ + Type\vec(Type\int()), + ['nope'], + 'Expected "vec", got "string" at path "0".' + ]; + yield 'nested' => [ + Type\vec(Type\vec(Type\int())), + [['nope']], + 'Expected "vec>", got "string" at path "0.0".', + ]; + } + + public static function provideCoerceExceptionExpectations(): iterable + { + yield 'invalid coercion value' => [ + Type\vec(Type\int()), + ['nope'], + 'Could not coerce "string" to type "vec" at path "0".' + ]; + yield 'invalid iterator first item' => [ + Type\vec(Type\int()), + (static function () { + yield Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "vec" at path "first()".' + ]; + yield 'invalid iterator second item' => [ + Type\vec(Type\int()), + (static function () { + yield 0; + yield Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "vec" at path "0.next()".' + ]; + yield 'iterator throwing exception' => [ + Type\vec(Type\int()), + (static function () { + yield 0; + throw new RuntimeException('whoops'); + })(), + 'Could not coerce "null" to type "vec" at path "0.next()": whoops.' + ]; + yield 'iterator yielding null key' => [ + Type\vec(Type\int()), + (static function () { + yield null => 'nope'; + })(), + 'Could not coerce "string" to type "vec" at path "null".' + ]; + yield 'iterator yielding object key' => [ + Type\vec(Type\int()), + (static function () { + yield (new class () { + }) => 'nope'; + })(), + 'Could not coerce "string" to type "vec" at path "class@anonymous".' + ]; + } + + /** + * @dataProvider provideAssertExceptionExpectations + */ + public function testInvalidAssertionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->assert($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\AssertException::class)); + } catch (Type\Exception\AssertException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } + + /** + * @dataProvider provideCoerceExceptionExpectations + */ + public function testInvalidCoercionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->coerce($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class)); + } catch (Type\Exception\CoercionException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } } diff --git a/tests/unit/Type/VectorTypeTest.php b/tests/unit/Type/VectorTypeTest.php index 64140ae3..9cf15945 100644 --- a/tests/unit/Type/VectorTypeTest.php +++ b/tests/unit/Type/VectorTypeTest.php @@ -5,14 +5,16 @@ namespace Psl\Tests\Unit\Type; use Psl\Collection; +use Psl\Collection\VectorInterface; use Psl\Dict; use Psl\Iter; use Psl\Str; use Psl\Type; use Psl\Vec; +use RuntimeException; /** - * @extends TypeTest> + * @extends TypeTest> */ final class VectorTypeTest extends TypeTest { @@ -86,19 +88,106 @@ public function getToStringExamples(): iterable } /** - * @param Collection\VectorInterface|mixed $a - * @param Collection\VectorInterface|mixed $b + * @param VectorInterface|mixed $a + * @param VectorInterface|mixed $b */ protected function equals($a, $b): bool { - if (Type\instance_of(Collection\VectorInterface::class)->matches($a)) { + if (Type\instance_of(VectorInterface::class)->matches($a)) { $a = $a->toArray(); } - if (Type\instance_of(Collection\VectorInterface::class)->matches($b)) { + if (Type\instance_of(VectorInterface::class)->matches($b)) { $b = $b->toArray(); } return parent::equals($a, $b); } + + public static function provideAssertExceptionExpectations(): iterable + { + yield 'invalid assertion value' => [ + Type\vector(Type\int()), + new Collection\MutableVector(['nope']), + 'Expected "' . VectorInterface::class . '", got "string" at path "0".' + ]; + yield 'nested' => [ + Type\vector(Type\vector(Type\int())), + new Collection\MutableVector([new Collection\MutableVector(['nope'])]), + 'Expected "' . VectorInterface::class . '<' . VectorInterface::class . '>", got "string" at path "0.0".', + ]; + } + + public static function provideCoerceExceptionExpectations(): iterable + { + yield 'invalid coercion value' => [ + Type\vector(Type\int()), + ['nope'], + 'Could not coerce "string" to type "' . VectorInterface::class . '" at path "0".' + ]; + yield 'invalid iterator first item' => [ + Type\vector(Type\int()), + (static function () { + yield Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "' . VectorInterface::class . '" at path "first()".' + ]; + yield 'invalid iterator second item' => [ + Type\vector(Type\int()), + (static function () { + yield 0; + yield Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "' . VectorInterface::class . '" at path "0.next()".' + ]; + yield 'iterator throwing exception' => [ + Type\vector(Type\int()), + (static function () { + yield 0; + throw new RuntimeException('whoops'); + })(), + 'Could not coerce "null" to type "' . VectorInterface::class . '" at path "0.next()": whoops.' + ]; + yield 'iterator yielding null key' => [ + Type\vector(Type\int()), + (static function () { + yield null => 'nope'; + })(), + 'Could not coerce "string" to type "' . VectorInterface::class . '" at path "null".' + ]; + yield 'iterator yielding object key' => [ + Type\vector(Type\int()), + (static function () { + yield (new class () { + }) => 'nope'; + })(), + 'Could not coerce "string" to type "' . VectorInterface::class . '" at path "class@anonymous".' + ]; + } + + /** + * @dataProvider provideAssertExceptionExpectations + */ + public function testInvalidAssertionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->assert($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\AssertException::class)); + } catch (Type\Exception\AssertException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } + + /** + * @dataProvider provideCoerceExceptionExpectations + */ + public function testInvalidCoercionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->coerce($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class)); + } catch (Type\Exception\CoercionException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } }