From dca26829058abda08a65805b5aa26035254c7658 Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Tue, 22 Sep 2026 21:39:04 +0200 Subject: [PATCH 1/4] Add NoctudCollectionException and SequenceLogicException Every exception this library raises now implements NoctudCollectionException, so a single catch covers them all. An interface rather than a base class: each failure keeps the parent that describes it best, and a third-party collection built on this library can join the family without giving up the exception hierarchy it already has. SequenceLogicException extends it and marks the two failures any Sequence method can raise - a source that refuses to hand back a fresh pass, or one that hands back something not iterable. Which one surfaces depends on the source, not on the method called, so the two @throws lines repeated on all 41 Sequence methods collapse into a single one declaring that type. Refs #23 --- src/Exception/ConversionException.php | 2 +- src/Exception/IndexOutOfBoundsException.php | 2 +- src/Exception/InvalidKeyTypeException.php | 2 +- .../InvalidSequenceSourceException.php | 2 +- src/Exception/NoSuchElementException.php | 2 +- src/Exception/NoctudCollectionException.php | 23 ++++ .../NonReplayableSourceException.php | 2 +- src/Exception/SequenceLogicException.php | 21 +++ .../UnsupportedOperationException.php | 2 +- src/Sequence/Sequence.php | 130 +++++++----------- tests/ExceptionHierarchyTest.php | 66 +++++++++ 11 files changed, 163 insertions(+), 91 deletions(-) create mode 100644 src/Exception/NoctudCollectionException.php create mode 100644 src/Exception/SequenceLogicException.php create mode 100644 tests/ExceptionHierarchyTest.php 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..5bdfc9b 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 SequenceLogicException { 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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 SequenceLogicException 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..a52948a --- /dev/null +++ b/tests/ExceptionHierarchyTest.php @@ -0,0 +1,66 @@ +assertSame([1], $sequence->toArray()); + + $this->expectException(SequenceLogicException::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(SequenceLogicException::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 + } +} From dc4b94e4e4a8eef28797b0b1e64f30fc8c70b44e Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Sat, 26 Sep 2026 10:33:15 +0200 Subject: [PATCH 2/4] Rename SequenceLogicException to SourceException The exception can also be thrown from Collection::zip(), so the Sequence prefix was misleading. --- .../InvalidSequenceSourceException.php | 2 +- .../NonReplayableSourceException.php | 2 +- ...LogicException.php => SourceException.php} | 2 +- src/Sequence/Sequence.php | 86 +++++++++---------- tests/ExceptionHierarchyTest.php | 6 +- 5 files changed, 49 insertions(+), 49 deletions(-) rename src/Exception/{SequenceLogicException.php => SourceException.php} (88%) diff --git a/src/Exception/InvalidSequenceSourceException.php b/src/Exception/InvalidSequenceSourceException.php index 5bdfc9b..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 implements SequenceLogicException +final class InvalidSequenceSourceException extends LogicException implements SourceException { public static function closureReturnedNonIterable(mixed $produced): self { diff --git a/src/Exception/NonReplayableSourceException.php b/src/Exception/NonReplayableSourceException.php index a9a189a..ed412c1 100644 --- a/src/Exception/NonReplayableSourceException.php +++ b/src/Exception/NonReplayableSourceException.php @@ -14,7 +14,7 @@ /** * Thrown when an iterable that can only be walked once is walked again. */ -final class NonReplayableSourceException extends UnsupportedOperationException implements SequenceLogicException +final class NonReplayableSourceException extends UnsupportedOperationException implements SourceException { public static function sequenceSourceAlreadyIterated(): self { diff --git a/src/Exception/SequenceLogicException.php b/src/Exception/SourceException.php similarity index 88% rename from src/Exception/SequenceLogicException.php rename to src/Exception/SourceException.php index 9697fa6..275d361 100644 --- a/src/Exception/SequenceLogicException.php +++ b/src/Exception/SourceException.php @@ -16,6 +16,6 @@ * Every terminal operation declares this single type rather than the concrete exceptions * behind it - which one surfaces depends on the source, not on the method called. */ -interface SequenceLogicException extends NoctudCollectionException +interface SourceException extends NoctudCollectionException { } diff --git a/src/Sequence/Sequence.php b/src/Sequence/Sequence.php index 92289c1..e754f79 100644 --- a/src/Sequence/Sequence.php +++ b/src/Sequence/Sequence.php @@ -14,7 +14,7 @@ use Noctud\Collection\Exception\ConversionException; use Noctud\Collection\Exception\IndexOutOfBoundsException; use Noctud\Collection\Exception\NoSuchElementException; -use Noctud\Collection\Exception\SequenceLogicException; +use Noctud\Collection\Exception\SourceException; use Noctud\Collection\Exception\UnsupportedOperationException; use Noctud\Collection\List\ImmutableList; use Noctud\Collection\Map\ImmutableMap; @@ -44,7 +44,7 @@ * or re-invoking a producer, which is what you want when replaying would repeat a side effect. * * NonReplayableSourceException and InvalidSequenceSourceException both implement - * SequenceLogicException, the single type every method below declares - which concrete + * SourceException, the single type every method below declares - which concrete * one surfaces depends on the source, not on the method called. * * Keys are positional: every pass yields fresh 0..n keys, whatever the source yields. @@ -239,7 +239,7 @@ public function onEach(Closure $action): Sequence; * still hands the collection back. * * @param Closure(E, int):void $action - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function forEach(Closure $action): void; @@ -250,7 +250,7 @@ public function forEach(Closure $action): void; * * @return E * @throws NoSuchElementException If the sequence is empty - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function first(); @@ -258,7 +258,7 @@ public function first(); * Returns the first element, or null if the sequence is empty. Pulls exactly one element. * * @return E|null - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function firstOrNull(): mixed; @@ -269,7 +269,7 @@ public function firstOrNull(): mixed; * * @return E * @throws NoSuchElementException If the sequence is empty - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function last(); @@ -277,7 +277,7 @@ public function last(); * Returns the last element, or null if the sequence is empty. Drains the sequence. * * @return E|null - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function lastOrNull(): mixed; @@ -287,7 +287,7 @@ public function lastOrNull(): mixed; * * @return E * @throws NoSuchElementException If the sequence is empty or holds more than one element - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function single(); @@ -295,7 +295,7 @@ public function single(); * Returns the single element, or null if the sequence is empty or holds more than one. * * @return E|null - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function singleOrNull(): mixed; @@ -307,7 +307,7 @@ public function singleOrNull(): mixed; * @param non-negative-int $index * @return E * @throws IndexOutOfBoundsException If the sequence holds fewer elements than that or if the index is a negative int - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function elementAt(int $index); @@ -317,7 +317,7 @@ public function elementAt(int $index); * @param non-negative-int $index * @return E|null * @throws IndexOutOfBoundsException If the index is a negative int - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function elementAtOrNull(int $index): mixed; @@ -327,7 +327,7 @@ public function elementAtOrNull(int $index): mixed; * * @param Closure(E, int):bool $predicate * @return E|null - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function find(Closure $predicate): mixed; @@ -338,7 +338,7 @@ public function find(Closure $predicate): mixed; * @param Closure(E, int):bool $predicate * @return E * @throws NoSuchElementException If no element matches the predicate - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function expect(Closure $predicate); @@ -348,7 +348,7 @@ public function expect(Closure $predicate); * * @param Closure(E, int):bool $predicate * @return E|null - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function findLast(Closure $predicate): mixed; @@ -359,7 +359,7 @@ public function findLast(Closure $predicate): mixed; * @param Closure(E, int):bool $predicate * @return E * @throws NoSuchElementException If no element matches the predicate - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function expectLast(Closure $predicate); @@ -368,14 +368,14 @@ public function expectLast(Closure $predicate); /** * Whether the sequence does not contain any elements. Pulls exactly one element. * - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function isEmpty(): bool; /** * Whether the sequence contains at least one element. Pulls exactly one element. * - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function isNotEmpty(): bool; @@ -384,7 +384,7 @@ public function isNotEmpty(): bool; * Stops pulling at the first match. * * @param E $element - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function contains(mixed $element): bool; @@ -393,7 +393,7 @@ public function contains(mixed $element): bool; * Walks the sequence once, stopping as soon as none is left to look for. * * @param iterable $elements - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function containsAll(iterable $elements): bool; @@ -402,7 +402,7 @@ public function containsAll(iterable $elements): bool; * Stops pulling at the first element that does not. * * @param Closure(E, int):bool $predicate - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function all(Closure $predicate): bool; @@ -411,7 +411,7 @@ public function all(Closure $predicate): bool; * Stops pulling at the first match. * * @param Closure(E, int):bool $predicate - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function any(Closure $predicate): bool; @@ -420,7 +420,7 @@ public function any(Closure $predicate): bool; * Stops pulling at the first match. * * @param Closure(E, int):bool $predicate - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function none(Closure $predicate): bool; @@ -430,7 +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 SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function count(): int; @@ -439,7 +439,7 @@ public function count(): int; * * @param Closure(E, int):bool $predicate * @return int<0, max> - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function countWhere(Closure $predicate): int; @@ -453,7 +453,7 @@ public function countWhere(Closure $predicate): int; * @param R $initial * @param Closure(R, E):R $operation * @return R - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function fold(mixed $initial, Closure $operation): mixed; @@ -463,7 +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 SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function reduce(Closure $operation); @@ -473,7 +473,7 @@ public function reduce(Closure $operation); * * @param Closure(E, E):E $operation * @return E|null - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function reduceOrNull(Closure $operation): mixed; @@ -483,7 +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 SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function sum(?Closure $selector = null): int|float; @@ -493,7 +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 SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function avg(?Closure $selector = null): float; @@ -502,7 +502,7 @@ public function avg(?Closure $selector = null): float; * sequence is empty. Drains the sequence. * * @param Closure(E, int):(int|float)|null $selector - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function avgOrNull(?Closure $selector = null): float|null; @@ -513,7 +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 SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function min(?Closure $selector = null): mixed; @@ -523,7 +523,7 @@ public function min(?Closure $selector = null): mixed; * * @param Closure(E, int):mixed|null $selector * @return E|null - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function minOrNull(?Closure $selector = null): mixed; @@ -534,7 +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 SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function max(?Closure $selector = null): mixed; @@ -544,7 +544,7 @@ public function max(?Closure $selector = null): mixed; * * @param Closure(E, int):mixed|null $selector * @return E|null - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function maxOrNull(?Closure $selector = null): mixed; @@ -555,7 +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 SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function minOf(Closure $selector): mixed; @@ -566,7 +566,7 @@ public function minOf(Closure $selector): mixed; * @template R of mixed * @param Closure(E, int):R $selector * @return R|null - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function minOfOrNull(Closure $selector): mixed; @@ -577,7 +577,7 @@ public function minOfOrNull(Closure $selector): mixed; * @param Closure(E, int):R $selector * @return R * @throws NoSuchElementException If the sequence is empty - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function maxOf(Closure $selector): mixed; @@ -588,7 +588,7 @@ public function maxOf(Closure $selector): mixed; * @template R of mixed * @param Closure(E, int):R $selector * @return R|null - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ public function maxOfOrNull(Closure $selector): mixed; @@ -606,7 +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 SequenceLogicException If the source cannot produce a pass (already consumed, or not an 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; @@ -616,7 +616,7 @@ public function joinToString(string $separator = ', ', string $prefix = '', stri * Convert to an immutable list, consuming one pass of the sequence. * * @return ImmutableList - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ #[NoDiscard] public function toList(): ImmutableList; @@ -625,7 +625,7 @@ public function toList(): ImmutableList; * Convert to an immutable set (duplicates removed), consuming one pass of the sequence. * * @return ImmutableSet - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ #[NoDiscard] public function toSet(): ImmutableSet; @@ -634,7 +634,7 @@ public function toSet(): ImmutableSet; * Convert to a primitive PHP array, consuming one pass of the sequence. * * @return list - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an iterable) + * @throws SourceException If the source cannot produce a pass (already consumed, or not an iterable) */ #[NoDiscard] public function toArray(): array; @@ -647,7 +647,7 @@ public function toArray(): array; * @param Closure(E, int):K $keySelector * @param ?Closure(E, int):V $valueTransform * @return ImmutableMap - * @throws SequenceLogicException If the source cannot produce a pass (already consumed, or not an 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 index a52948a..1ee7cc7 100644 --- a/tests/ExceptionHierarchyTest.php +++ b/tests/ExceptionHierarchyTest.php @@ -11,7 +11,7 @@ use Generator; use Noctud\Collection\Exception\NoctudCollectionException; -use Noctud\Collection\Exception\SequenceLogicException; +use Noctud\Collection\Exception\SourceException; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use function Noctud\Collection\listOf; @@ -29,7 +29,7 @@ public function a_source_that_cannot_replay_is_caught_as_a_sequence_failure(): v $this->assertSame([1], $sequence->toArray()); - $this->expectException(SequenceLogicException::class); + $this->expectException(SourceException::class); $_ = $sequence->toArray(); // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable } @@ -40,7 +40,7 @@ public function a_source_returning_a_non_iterable_is_caught_as_a_sequence_failur /** @phpstan-ignore argument.type, argument.templateType */ $sequence = sequenceOf(static fn (): int => 42); - $this->expectException(SequenceLogicException::class); + $this->expectException(SourceException::class); $_ = $sequence->toArray(); // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable } From 04be2739070d060c14efd318dd81770791a7103b Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Sat, 26 Sep 2026 10:33:15 +0200 Subject: [PATCH 3/4] Test that every library exception implements NoctudCollectionException --- tests/ExceptionHierarchyTest.php | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/ExceptionHierarchyTest.php b/tests/ExceptionHierarchyTest.php index 1ee7cc7..f53ebfd 100644 --- a/tests/ExceptionHierarchyTest.php +++ b/tests/ExceptionHierarchyTest.php @@ -9,11 +9,17 @@ namespace Noctud\Collection\Tests; +use FilesystemIterator; use Generator; use Noctud\Collection\Exception\NoctudCollectionException; use Noctud\Collection\Exception\SourceException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; +use Throwable; use function Noctud\Collection\listOf; use function Noctud\Collection\sequenceOf; @@ -63,4 +69,39 @@ public function an_eager_failure_is_caught_at_the_library_root(): void $_ = 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]; + } + } + } } From 5fd0b5ad9e0c886ab7ce810528094793be0be2c4 Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Sun, 27 Sep 2026 09:15:05 +0200 Subject: [PATCH 4/4] Document NonReplayableSourceException on Collection::zip() Collection::zip() rewinds the other iterable eagerly, so an iterator whose rewind() fails surfaces there. SourceException no longer presents itself as Sequence-only, since walking any source can raise it. --- src/Collection.php | 2 ++ src/Exception/SourceException.php | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) 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/SourceException.php b/src/Exception/SourceException.php index 275d361..32a5876 100644 --- a/src/Exception/SourceException.php +++ b/src/Exception/SourceException.php @@ -10,10 +10,11 @@ namespace Noctud\Collection\Exception; /** - * Marks the failures any Sequence method can raise, whatever that method does: a source - * that refuses to hand back a fresh pass, or one that hands back something not iterable. + * Marks the failures raised when walking a source rather than by what a method does: a + * source that refuses to hand back a fresh pass, or one that hands back something not + * iterable. * - * Every terminal operation declares this single type rather than the concrete exceptions + * Methods that walk a source declare this single type rather than the concrete exceptions * behind it - which one surfaces depends on the source, not on the method called. */ interface SourceException extends NoctudCollectionException