diff --git a/src/Collection.php b/src/Collection.php index 91e1dc0..64fd2ec 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -15,6 +15,7 @@ use JsonSerializable; use Noctud\Collection\Exception\ConversionException; use Noctud\Collection\Exception\NoSuchElementException; +use Noctud\Collection\Exception\NonReplayableSourceException; use Noctud\Collection\Exception\UnsupportedOperationException; use Noctud\Collection\List\ImmutableList; use Noctud\Collection\List\ListInterface; @@ -546,6 +547,7 @@ public function windowed(int $size, int $step = 1, bool $partialWindows = false) * @template U * @param iterable $other * @return ListInterface + * @throws NonReplayableSourceException If the other iterator cannot be rewound */ #[NoDiscard] public function zip(iterable $other): ListInterface; diff --git a/src/Exception/ConversionException.php b/src/Exception/ConversionException.php index 1971677..853570c 100644 --- a/src/Exception/ConversionException.php +++ b/src/Exception/ConversionException.php @@ -11,6 +11,6 @@ use LogicException; -final class ConversionException extends LogicException +final class ConversionException extends LogicException implements NoctudCollectionException { } diff --git a/src/Exception/IndexOutOfBoundsException.php b/src/Exception/IndexOutOfBoundsException.php index 8758131..4acf3c1 100644 --- a/src/Exception/IndexOutOfBoundsException.php +++ b/src/Exception/IndexOutOfBoundsException.php @@ -11,6 +11,6 @@ use LogicException; -final class IndexOutOfBoundsException extends LogicException +final class IndexOutOfBoundsException extends LogicException implements NoctudCollectionException { } diff --git a/src/Exception/InvalidKeyTypeException.php b/src/Exception/InvalidKeyTypeException.php index c0915bb..bd476da 100644 --- a/src/Exception/InvalidKeyTypeException.php +++ b/src/Exception/InvalidKeyTypeException.php @@ -11,6 +11,6 @@ use LogicException; -final class InvalidKeyTypeException extends LogicException +final class InvalidKeyTypeException extends LogicException implements NoctudCollectionException { } diff --git a/src/Exception/InvalidSequenceSourceException.php b/src/Exception/InvalidSequenceSourceException.php index 88bbf51..411f75e 100644 --- a/src/Exception/InvalidSequenceSourceException.php +++ b/src/Exception/InvalidSequenceSourceException.php @@ -14,7 +14,7 @@ /** * Thrown when a sequence source closure returns a non-iterable value. */ -final class InvalidSequenceSourceException extends LogicException +final class InvalidSequenceSourceException extends LogicException implements SourceException { public static function closureReturnedNonIterable(mixed $produced): self { diff --git a/src/Exception/NoSuchElementException.php b/src/Exception/NoSuchElementException.php index 7ee8b1e..47537c7 100644 --- a/src/Exception/NoSuchElementException.php +++ b/src/Exception/NoSuchElementException.php @@ -13,7 +13,7 @@ use Noctud\Collection\Collection; use Noctud\Collection\Sequence\Sequence; -final class NoSuchElementException extends LogicException +final class NoSuchElementException extends LogicException implements NoctudCollectionException { use NamesItsSubject; diff --git a/src/Exception/NoctudCollectionException.php b/src/Exception/NoctudCollectionException.php new file mode 100644 index 0000000..f1ef2c6 --- /dev/null +++ b/src/Exception/NoctudCollectionException.php @@ -0,0 +1,23 @@ + $elements - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function containsAll(iterable $elements): bool; @@ -416,8 +402,7 @@ public function containsAll(iterable $elements): bool; * Stops pulling at the first element that does not. * * @param Closure(E, int):bool $predicate - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function all(Closure $predicate): bool; @@ -426,8 +411,7 @@ public function all(Closure $predicate): bool; * Stops pulling at the first match. * * @param Closure(E, int):bool $predicate - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function any(Closure $predicate): bool; @@ -436,8 +420,7 @@ public function any(Closure $predicate): bool; * Stops pulling at the first match. * * @param Closure(E, int):bool $predicate - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function none(Closure $predicate): bool; @@ -447,8 +430,7 @@ public function none(Closure $predicate): bool; * elements to pull, which is why the cost has to be asked for explicitly. * * @return int<0, max> - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function count(): int; @@ -457,8 +439,7 @@ public function count(): int; * * @param Closure(E, int):bool $predicate * @return int<0, max> - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function countWhere(Closure $predicate): int; @@ -472,8 +453,7 @@ public function countWhere(Closure $predicate): int; * @param R $initial * @param Closure(R, E):R $operation * @return R - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function fold(mixed $initial, Closure $operation): mixed; @@ -483,8 +463,7 @@ public function fold(mixed $initial, Closure $operation): mixed; * @param Closure(E, E):E $operation * @return E * @throws UnsupportedOperationException If the sequence is empty - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function reduce(Closure $operation); @@ -494,8 +473,7 @@ public function reduce(Closure $operation); * * @param Closure(E, E):E $operation * @return E|null - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function reduceOrNull(Closure $operation): mixed; @@ -505,8 +483,7 @@ public function reduceOrNull(Closure $operation): mixed; * @template TSum * @param (Closure(E, int):TSum)|null $selector * @return ($selector is null ? (E is int ? int : int|float) : (TSum is int ? int : int|float)) - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function sum(?Closure $selector = null): int|float; @@ -516,8 +493,7 @@ public function sum(?Closure $selector = null): int|float; * * @param Closure(E, int):(int|float)|null $selector * @throws UnsupportedOperationException If the sequence is empty - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function avg(?Closure $selector = null): float; @@ -526,8 +502,7 @@ public function avg(?Closure $selector = null): float; * sequence is empty. Drains the sequence. * * @param Closure(E, int):(int|float)|null $selector - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function avgOrNull(?Closure $selector = null): float|null; @@ -538,8 +513,7 @@ public function avgOrNull(?Closure $selector = null): float|null; * @param Closure(E, int):mixed|null $selector * @return E * @throws NoSuchElementException If the sequence is empty - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function min(?Closure $selector = null): mixed; @@ -549,8 +523,7 @@ public function min(?Closure $selector = null): mixed; * * @param Closure(E, int):mixed|null $selector * @return E|null - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function minOrNull(?Closure $selector = null): mixed; @@ -561,8 +534,7 @@ public function minOrNull(?Closure $selector = null): mixed; * @param Closure(E, int):mixed|null $selector * @return E * @throws NoSuchElementException If the sequence is empty - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function max(?Closure $selector = null): mixed; @@ -572,8 +544,7 @@ public function max(?Closure $selector = null): mixed; * * @param Closure(E, int):mixed|null $selector * @return E|null - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function maxOrNull(?Closure $selector = null): mixed; @@ -584,8 +555,7 @@ public function maxOrNull(?Closure $selector = null): mixed; * @param Closure(E, int):R $selector * @return R * @throws NoSuchElementException If the sequence is empty - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function minOf(Closure $selector): mixed; @@ -596,8 +566,7 @@ public function minOf(Closure $selector): mixed; * @template R of mixed * @param Closure(E, int):R $selector * @return R|null - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function minOfOrNull(Closure $selector): mixed; @@ -608,8 +577,7 @@ public function minOfOrNull(Closure $selector): mixed; * @param Closure(E, int):R $selector * @return R * @throws NoSuchElementException If the sequence is empty - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function maxOf(Closure $selector): mixed; @@ -620,8 +588,7 @@ public function maxOf(Closure $selector): mixed; * @template R of mixed * @param Closure(E, int):R $selector * @return R|null - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function maxOfOrNull(Closure $selector): mixed; @@ -639,8 +606,7 @@ public function maxOfOrNull(Closure $selector): mixed; * * @param Closure(E, int):string|null $transform Optional transform to apply to each element * @throws ConversionException When an element cannot be converted to string and no transform is provided - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function joinToString(string $separator = ', ', string $prefix = '', string $postfix = '', int $limit = -1, string $truncated = '...', ?Closure $transform = null): string; @@ -650,8 +616,7 @@ public function joinToString(string $separator = ', ', string $prefix = '', stri * Convert to an immutable list, consuming one pass of the sequence. * * @return ImmutableList - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ #[NoDiscard] public function toList(): ImmutableList; @@ -660,8 +625,7 @@ public function toList(): ImmutableList; * Convert to an immutable set (duplicates removed), consuming one pass of the sequence. * * @return ImmutableSet - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ #[NoDiscard] public function toSet(): ImmutableSet; @@ -670,8 +634,7 @@ public function toSet(): ImmutableSet; * Convert to a primitive PHP array, consuming one pass of the sequence. * * @return list - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ #[NoDiscard] public function toArray(): array; @@ -684,8 +647,7 @@ public function toArray(): array; * @param Closure(E, int):K $keySelector * @param ?Closure(E, int):V $valueTransform * @return ImmutableMap - * @throws NonReplayableSourceException If a non-replayable source has already been consumed - * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ #[NoDiscard] public function toMap(Closure $keySelector, ?Closure $valueTransform = null): ImmutableMap; diff --git a/tests/ExceptionHierarchyTest.php b/tests/ExceptionHierarchyTest.php new file mode 100644 index 0000000..f53ebfd --- /dev/null +++ b/tests/ExceptionHierarchyTest.php @@ -0,0 +1,107 @@ +assertSame([1], $sequence->toArray()); + + $this->expectException(SourceException::class); + + $_ = $sequence->toArray(); // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + } + + #[Test] + public function a_source_returning_a_non_iterable_is_caught_as_a_sequence_failure(): void + { + /** @phpstan-ignore argument.type, argument.templateType */ + $sequence = sequenceOf(static fn (): int => 42); + + $this->expectException(SourceException::class); + + $_ = $sequence->toArray(); // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + } + + #[Test] + public function a_sequence_failure_is_also_caught_at_the_library_root(): void + { + /** @phpstan-ignore argument.type, argument.templateType */ + $sequence = sequenceOf(static fn (): int => 42); + + $this->expectException(NoctudCollectionException::class); + + $_ = $sequence->toArray(); // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + } + + #[Test] + public function an_eager_failure_is_caught_at_the_library_root(): void + { + $this->expectException(NoctudCollectionException::class); + + $_ = listOf([])->first(); // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + } + + #[Test] + #[DataProvider('libraryExceptions')] + public function every_library_exception_implements_the_root_marker(string $class): void + { + $this->assertTrue( + is_subclass_of($class, NoctudCollectionException::class), + sprintf('%s must implement %s', $class, NoctudCollectionException::class), + ); + } + + /** + * @return Generator}> + */ + public static function libraryExceptions(): Generator + { + $src = dirname(__DIR__) . '/src'; + /** @var iterable $files */ + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)); + + foreach ($files as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + // PSR-4 in reverse: src/Exception/Foo.php -> Noctud\Collection\Exception\Foo + $relative = substr($file->getPathname(), strlen($src) + 1, -4); + $class = 'Noctud\\Collection\\' . str_replace(DIRECTORY_SEPARATOR, '\\', $relative); + + // class_exists() autoloads; interfaces, traits and functions.php fall out here + if (class_exists($class) && is_subclass_of($class, Throwable::class)) { + yield $class => [$class]; + } + } + } +}