Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 3 additions & 56 deletions src/CollectionLogic.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
*/
trait CollectionLogic
{
/** @use IterableTerminalsLogic<E> */
use IterableTerminalsLogic;

// --- Element Access ---

/** {@inheritDoc} */
Expand All @@ -70,50 +73,6 @@ public function lastOrNull(): mixed
return $this->store->last();
}

/** {@inheritDoc} */
public function single()
{
$found = false;
$result = null;

foreach ($this as $v) {
if ($found) {
throw new NoSuchElementException('Collection contains more than one element');
}

$result = $v;
$found = true;
}

if (!$found) {
throw new NoSuchElementException('Collection is empty');
}

return $result; // @phpstan-ignore return.type
}

/** {@inheritDoc} */
public function singleOrNull(): mixed
{
try {
return $this->single();
} catch (NoSuchElementException) {
return null;
}
}

/** {@inheritDoc} */
public function find(Closure $predicate): mixed
{
foreach ($this as $i => $v) {
if ($predicate($v, $i)) {
return $v;
}
}

return null;
}

/** {@inheritDoc} */
public function findLast(Closure $predicate): mixed
{
Expand All @@ -128,18 +87,6 @@ public function findLast(Closure $predicate): mixed
return $result;
}

/** {@inheritDoc} */
public function expect(Closure $predicate)
{
foreach ($this as $i => $v) {
if ($predicate($v, $i)) {
return $v;
}
}

throw new NoSuchElementException('No element matching the predicate was found');
}

/** {@inheritDoc} */
public function expectLast(Closure $predicate)
{
Expand Down
36 changes: 36 additions & 0 deletions src/Exception/NamesItsSubject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

/**
* This file is part of the Noctud Collection.
* Copyright (c) Noctud.dev
*/

declare(strict_types=1);

namespace Noctud\Collection\Exception;

use Noctud\Collection\Collection;
use Noctud\Collection\Sequence\Sequence;

/**
* Names what a message is about, so that a body shared by the eager and the lazy side does not
* have to carry two versions of its own error text: a sequence must not be told a "Collection" is
* empty, and that difference is the only thing that used to keep those bodies apart.
*
* @internal
*/
trait NamesItsSubject
{
/**
* @param Collection<mixed>|Sequence<mixed> $subject
*/
private static function subjectName(Collection|Sequence $subject): string
{
// The parameter type makes this exhaustive, so the default arm *is* the Collection case
// rather than a catch-all. Widening that union means adding an arm here.
return match (true) {
$subject instanceof Sequence => 'Sequence',
default => 'Collection',
};
}
}
19 changes: 19 additions & 0 deletions src/Exception/NoSuchElementException.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,26 @@
namespace Noctud\Collection\Exception;

use LogicException;
use Noctud\Collection\Collection;
use Noctud\Collection\Sequence\Sequence;

final class NoSuchElementException extends LogicException
{
use NamesItsSubject;

/**
* @param Collection<mixed>|Sequence<mixed> $subject
*/
public static function emptySubject(Collection|Sequence $subject): self
Comment thread
nikophil marked this conversation as resolved.
{
return new self(sprintf('%s is empty', self::subjectName($subject)));
}

/**
* @param Collection<mixed>|Sequence<mixed> $subject
*/
public static function subjectHasMoreThanOneElement(Collection|Sequence $subject): self
{
return new self(sprintf('%s contains more than one element', self::subjectName($subject)));
}
}
100 changes: 100 additions & 0 deletions src/IterableTerminalsLogic.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

/**
* This file is part of the Noctud Collection.
* Copyright (c) Noctud.dev
*/

declare(strict_types=1);

namespace Noctud\Collection;

use Closure;
use Noctud\Collection\Exception\NoSuchElementException;

/**
* Terminal operations implemented by walking $this and nothing else, shared by the eager
* Collection side and the lazy Sequence side.
*
* A body belongs here only if it is identical for both, which excludes the family the eager side
* answers from its store in O(1) (first/last/contains/count). A message naming the subject is no
* longer a reason to split: the exception derives that noun from $this.
*
* Consumers declare the contract, so the PHPDoc here is {@inheritDoc}: it resolves against
* Collection<E> or Sequence<E> depending on who uses the trait.
*
* @template E
Comment thread
nikophil marked this conversation as resolved.
*
* @internal
*/
trait IterableTerminalsLogic
Comment thread
nikophil marked this conversation as resolved.
{
/** {@inheritDoc} */
public function single()
{
$found = false;
$result = null;

foreach ($this as $v) {
if ($found) {
throw NoSuchElementException::subjectHasMoreThanOneElement($this);
}

$result = $v;
$found = true;
}

if (!$found) {
throw NoSuchElementException::emptySubject($this);
}

return $result; // @phpstan-ignore return.type
}

/**
* {@inheritDoc}
*
* Not a try-catch around single(): iterating $this runs user closures on the lazy side, and a
* NoSuchElementException raised inside one is a real error, not an answer to this question.
*/
public function singleOrNull(): mixed
{
$found = false;
$result = null;

foreach ($this as $v) {
if ($found) {
return null;
}

$result = $v;
$found = true;
}

return $result;
}

/** {@inheritDoc} */
public function find(Closure $predicate): mixed
{
foreach ($this as $i => $v) {
if ($predicate($v, $i)) {
return $v;
}
}

return null;
}

/** {@inheritDoc} */
public function expect(Closure $predicate)
{
foreach ($this as $i => $v) {
if ($predicate($v, $i)) {
return $v;
}
}

throw new NoSuchElementException('No element matching the predicate was found');
}
}
111 changes: 111 additions & 0 deletions src/Sequence/Sequence.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@

use Closure;
use IteratorAggregate;
use Noctud\Collection\Exception\IndexOutOfBoundsException;
use Noctud\Collection\Exception\InvalidSequenceSourceException;
use Noctud\Collection\Exception\NonReplayableSourceException;
use Noctud\Collection\Exception\NoSuchElementException;
use Noctud\Collection\List\ImmutableList;
use Noctud\Collection\Set\ImmutableSet;
use NoDiscard;
Expand Down Expand Up @@ -218,6 +220,115 @@ public function zipWithNext(): Sequence;
#[NoDiscard]
public function onEach(Closure $action): Sequence;

// --- Element Access ---

/**
* Returns the first element, consuming one pass. Pulls exactly one element.
*
* @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
*/
public function first();

/**
* Returns the first element, or null if the sequence is empty. Pulls exactly one element.
*
* @return E|null
* @throws NonReplayableSourceException If a non-replayable source has already been consumed
* @throws InvalidSequenceSourceException If a Closure source returns a non-iterable
*/
public function firstOrNull(): mixed;

/**
* Returns the last element, consuming one pass.
* Unlike its Collection counterpart this drains the whole sequence - the last element is
* only knowable at the end.
*
* @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
*/
public function last();

/**
* Returns the last element, or null if the sequence is empty. Drains the sequence.
*
* @return E|null
* @throws NonReplayableSourceException If a non-replayable source has already been consumed
* @throws InvalidSequenceSourceException If a Closure source returns a non-iterable
*/
public function lastOrNull(): mixed;

/**
* Returns the single element, consuming one pass. Pulls at most two elements: a second one
* existing is already an error.
*
* @return E
* @throws NoSuchElementException If the sequence is empty or holds more than one element
* @throws NonReplayableSourceException If a non-replayable source has already been consumed
* @throws InvalidSequenceSourceException If a Closure source returns a non-iterable
*/
public function single();

/**
* Returns the single element, or null if the sequence is empty or holds more than one.
*
* @return E|null
* @throws NonReplayableSourceException If a non-replayable source has already been consumed
* @throws InvalidSequenceSourceException If a Closure source returns a non-iterable
*/
public function singleOrNull(): mixed;

/**
* Returns the element at the given position, consuming one pass.
* Pulls up to that position and no further; there is no length to check the index against
* beforehand, so an index past the end is only known once the sequence runs out.
*
* @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 NonReplayableSourceException If a non-replayable source has already been consumed
* @throws InvalidSequenceSourceException If a Closure source returns a non-iterable
*/
public function elementAt(int $index);
Comment thread
nikophil marked this conversation as resolved.

/**
* Returns the element at the given position, or null if the sequence is shorter than that.
*
* @param non-negative-int $index
* @return E|null
* @throws IndexOutOfBoundsException If the index is a negative int
* @throws NonReplayableSourceException If a non-replayable source has already been consumed
* @throws InvalidSequenceSourceException If a Closure source returns a non-iterable
*/
public function elementAtOrNull(int $index): mixed;

/**
* Returns the first element matching the predicate, or null if none does.
* Stops pulling at the first match.
*
* @param Closure(E, int):bool $predicate
* @return E|null
* @throws NonReplayableSourceException If a non-replayable source has already been consumed
* @throws InvalidSequenceSourceException If a Closure source returns a non-iterable
*/
public function find(Closure $predicate): mixed;

/**
* Returns the first element matching the predicate, throwing if none does.
* Stops pulling at the first match.
*
* @param Closure(E, int):bool $predicate
* @return E
* @throws NoSuchElementException If no element matches the predicate
* @throws NonReplayableSourceException If a non-replayable source has already been consumed
* @throws InvalidSequenceSourceException If a Closure source returns a non-iterable
*/
public function expect(Closure $predicate);

// --- Conversion ---

/**
Expand Down
Loading
Loading