From cffafb8ee3e5ad20e77dba9204b20cc9bb74d4b0 Mon Sep 17 00:00:00 2001 From: PrinsFrank <25006490+PrinsFrank@users.noreply.github.com> Date: Sat, 6 Jun 2026 21:39:40 +0200 Subject: [PATCH] Introduce bounded streams to reduce memory footprint and stream count --- src/Exception/OutOfBoundsException.php | 5 + src/Stream/FileStream.php | 2 +- src/Stream/InMemoryStream.php | 2 +- src/Stream/Meta/BoundedStream.php | 91 +++++++++++++++++ src/Stream/Meta/DerivedStream.php | 7 ++ src/Stream/PrimaryStream.php | 5 + tests/Unit/Stream/Meta/BoundedStreamTest.php | 100 +++++++++++++++++++ 7 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 src/Exception/OutOfBoundsException.php create mode 100644 src/Stream/Meta/BoundedStream.php create mode 100644 src/Stream/Meta/DerivedStream.php create mode 100644 src/Stream/PrimaryStream.php create mode 100644 tests/Unit/Stream/Meta/BoundedStreamTest.php diff --git a/src/Exception/OutOfBoundsException.php b/src/Exception/OutOfBoundsException.php new file mode 100644 index 00000000..7f745b23 --- /dev/null +++ b/src/Exception/OutOfBoundsException.php @@ -0,0 +1,5 @@ +offsetEnd < $this->offsetStart) { + throw new InvalidArgumentException('OffsetEnd should be bigger than offsetStart'); + } + + if ($this->offsetStart > $this->primaryStream->getSizeInBytes()) { + throw new OutOfBoundsException('Start of bounded stream should be within parent stream length'); + } + + if ($this->offsetEnd > $this->primaryStream->getSizeInBytes()) { + throw new OutOfBoundsException('End of bounded stream should be within parent stream length'); + } + } + + #[Override] + public function getSizeInBytes(): int { + return $this->offsetEnd - $this->offsetStart; + } + + /** @throws OutOfBoundsException */ + #[Override] + public function read(int $from, int $nrOfBytes): string { + if ($from + $nrOfBytes > $this->getSizeInBytes()) { + throw new OutOfBoundsException(sprintf('Stream is only %d bytes long, trying to read %d bytes from offset %d', $this->getSizeInBytes(), $nrOfBytes, $from)); + } + + return $this->primaryStream + ->read($this->offsetStart + $from, $nrOfBytes); + } + + #[Override] + public function slice(int $startByteOffset, int $endByteOffset): string { + if ($startByteOffset > $this->getSizeInBytes() || $endByteOffset > $this->getSizeInBytes()) { + throw new OutOfBoundsException(); + } + + return $this->primaryStream + ->read($this->offsetStart + $startByteOffset, $endByteOffset - $startByteOffset); + } + + #[Override] + public function chars(int $from, int $nrOfBytes): iterable { + if ($from + $nrOfBytes > $this->getSizeInBytes()) { + throw new OutOfBoundsException(); + } + + return $this->primaryStream + ->chars($this->offsetStart + $from, $nrOfBytes); + } + + #[Override] + public function firstPos(WhitespaceCharacter|DelimiterCharacter|ToUnicodeCMapOperator|Marker $needle, int $offsetFromStart, int $before): ?int { + if ($offsetFromStart > $this->getSizeInBytes() || $before > $this->getSizeInBytes()) { + throw new OutOfBoundsException(); + } + + $firstPos = $this->primaryStream + ->firstPos($needle, $this->offsetStart + $offsetFromStart, $this->offsetStart + $before); + if ($firstPos === null) { + return null; + } + + return $firstPos - $this->offsetStart; + } + + #[Override] + public function lastPos(WhitespaceCharacter|DelimiterCharacter|ToUnicodeCMapOperator|Marker $needle, int $offsetFromEnd): ?int { + throw new NotImplementedException(); + } +} diff --git a/src/Stream/Meta/DerivedStream.php b/src/Stream/Meta/DerivedStream.php new file mode 100644 index 00000000..e6698a73 --- /dev/null +++ b/src/Stream/Meta/DerivedStream.php @@ -0,0 +1,7 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('OffsetEnd should be bigger than offsetStart'); + new BoundedStream($this->createMock(PrimaryStream::class), 10, 9); + } + + public function testConstructThrowsExceptionWhenStartBiggerThanEndOfPrimaryStream(): void { + $primaryStream = $this->createMock(PrimaryStream::class); + $primaryStream->expects(self::once()) + ->method('getSizeInBytes') + ->willReturn(42); + + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('Start of bounded stream should be within parent stream length'); + new BoundedStream($primaryStream, 43, 44); + } + + public function testConstructThrowsExceptionWhenEndBiggerThanEndOfPrimaryStream(): void { + $primaryStream = $this->createMock(PrimaryStream::class); + $primaryStream->expects(self::atLeastOnce()) + ->method('getSizeInBytes') + ->willReturn(43); + + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('End of bounded stream should be within parent stream length'); + new BoundedStream($primaryStream, 43, 44); + } + + public function testGetSizeInBytes(): void { + $primaryStream = $this->createMock(PrimaryStream::class); + $primaryStream->expects(self::atLeastOnce()) + ->method('getSizeInBytes') + ->willReturn(44); + + static::assertSame(0, (new BoundedStream($primaryStream, 42, 42))->getSizeInBytes()); + static::assertSame(1, (new BoundedStream($primaryStream, 42, 43))->getSizeInBytes()); + static::assertSame(2, (new BoundedStream($primaryStream, 42, 44))->getSizeInBytes()); + } + + public function testReadThrowsOutOfBoundsException(): void { + $primaryStream = $this->createMock(PrimaryStream::class); + $primaryStream->expects(self::atLeastOnce()) + ->method('getSizeInBytes') + ->willReturn(30); + + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('Stream is only 10 bytes long, trying to read 15 bytes from offset 5'); + (new BoundedStream($primaryStream, 10, 20)) + ->read(5, 15); + } + + public function testRead(): void { + static::assertSame( + '012', + (new BoundedStream( + new InMemoryStream('0123456789'), + 0, + 10, + ))->read(0, 3), + ); + static::assertSame( + '123', + (new BoundedStream( + new InMemoryStream('0123456789'), + 1, + 10, + ))->read(0, 3), + ); + static::assertSame( + '234', + (new BoundedStream( + new InMemoryStream('0123456789'), + 1, + 10, + ))->read(1, 3), + ); + static::assertSame( + '678', + (new BoundedStream( + new InMemoryStream('0123456789'), + 3, + 10, + ))->read(3, 3), + ); + } +}