From 07ec4055122d4af23757b449858a46bb9f21f96d Mon Sep 17 00:00:00 2001 From: Henrik Hautakoski Date: Sat, 27 Jun 2026 08:40:11 +0200 Subject: [PATCH] Adopt Go-style minimal interfaces for streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace PSR-7 StreamInterface with a set of small, composable interfaces (ReaderInterface, WriterInterface, SeekerInterface and their combinations). BinaryReader now depends only on ReaderInterface, BinaryWriter on WriterInterface, making both easy to satisfy with any byte source or sink. Buffer and Resource are simplified — no capability flags, no detach/close lifecycle, no PSR-7 metadata methods. LimitedResource is replaced by LimitedReader, a lightweight reader that enforces a byte count limit. BinaryReader::read() is renamed to readExact() for clarity. Tests updated throughout to match the new APIs. --- README.md | 124 +++++++++-- composer.json | 3 +- composer.lock | 60 +----- src/BinaryReader.php | 67 ++---- src/BinaryWriter.php | 45 +--- src/Buffer.php | 177 ++------------- src/File.php | 11 +- src/FileMode.php | 15 -- src/LimitedReader.php | 51 +++++ src/LimitedResource.php | 248 --------------------- src/ReadSeekerInterface.php | 10 + src/ReadWriteSeekerInterface.php | 10 + src/ReadWriterInterface.php | 11 + src/ReaderInterface.php | 16 ++ src/Resource.php | 119 +++------- src/SeekerInterface.php | 28 +++ src/WriteSeekerInterface.php | 10 + src/WriterInterface.php | 13 ++ tests/Unit/BinaryReaderTest.php | 53 ++--- tests/Unit/BinaryWriterTest.php | 46 ++-- tests/Unit/BufferTest.php | 24 ++- tests/Unit/FileModeTest.php | 10 - tests/Unit/FileTest.php | 32 +-- tests/Unit/LimitedResourceTest.php | 334 ----------------------------- tests/Unit/ResourceTest.php | 102 +++++---- 25 files changed, 451 insertions(+), 1168 deletions(-) create mode 100644 src/LimitedReader.php delete mode 100644 src/LimitedResource.php create mode 100644 src/ReadSeekerInterface.php create mode 100644 src/ReadWriteSeekerInterface.php create mode 100644 src/ReadWriterInterface.php create mode 100644 src/ReaderInterface.php create mode 100644 src/SeekerInterface.php create mode 100644 src/WriteSeekerInterface.php create mode 100644 src/WriterInterface.php delete mode 100644 tests/Unit/LimitedResourceTest.php diff --git a/README.md b/README.md index a7a3e1e..da1a41e 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ A small, focused PHP I/O toolkit for working with streams and binary data. `php-io` gives you: -- A consistent `StreamInterface` abstraction +- A minimal, Go-inspired interface hierarchy for byte streams - In-memory and file-backed stream implementations -- A `BinaryReader` for common integer formats (8/16/32-bit, LE/BE) -- Clear exception types for I/O and end-of-stream conditions +- `BinaryReader` and `BinaryWriter` for common integer formats (8/16/32-bit, LE/BE) +- Clear exception types for I/O failures ## Requirements @@ -21,35 +21,53 @@ composer require shufflingpixels/php-io ## Quick Start -### Read binary values from a string +### Read binary values from an in-memory buffer ```php readUInt16LE(); // 0x1234 => 4660 $b = $reader->readInt8(); // -128 $c = $reader->readInt8(); // -1 ``` -### Work with an in-memory buffer +### Write binary values to a buffer + +```php +writeUInt16LE(0x1234); +$writer->writeInt8(-1); + +$buffer->seek(0); +$bytes = $buffer->read($buffer->length()); // "\x34\x12\xff" +``` + +### Seek and write in-place with a Buffer ```php seek(2); // position = 2 -$buffer->write('XY'); // data becomes: abXYef -$buffer->seek(-2, SeekMode::END); // position near end +$buffer->seek(2); +$buffer->write('XY'); // data becomes: abXYef -$tail = $buffer->read(2); // "ef" +$buffer->seek(-2, SEEK_END); +$tail = $buffer->read(2); // "ef" ``` ### Open and use a file stream @@ -70,24 +88,86 @@ $bytes = $file->read(3); // "ABC" $file->close(); ``` -## Main Types +### Limit reads to a byte window -- `Shufflingpixels\IO\StreamInterface`: common stream contract (`read`, `write`, `seek`, `tell`, `eof`, `length`) -- `Shufflingpixels\IO\Buffer`: in-memory stream implementation -- `Shufflingpixels\IO\File`: file-backed stream implementation -- `Shufflingpixels\IO\BinaryReader`: typed binary reads over any `StreamInterface` -- `Shufflingpixels\IO\SeekMode`: type-safe seek modes (`SET`, `CUR`, `END`) -- `Shufflingpixels\IO\FileMode`: file open modes (`READ`, `WRITE`, `RW`) +```php +seek(6); // skip header +$section = new LimitedReader($buffer, 2); +$reader = new BinaryReader($section); + +$value = $reader->readUInt16LE(); // 0x1234 — cannot read past the 2-byte window +``` + +## Interfaces + +`php-io` uses a minimal, composable interface hierarchy inspired by Go's `io` package. +Each interface adds exactly one capability. + +| Interface | Methods | +|---|---| +| `ReaderInterface` | `read(int $length): string\|false` | +| `WriterInterface` | `write(string $data): int` | +| `SeekerInterface` | `seek()`, `tell()`, `eof()`, `length()` | +| `ReadSeekerInterface` | `ReaderInterface` + `SeekerInterface` | +| `WriteSeekerInterface` | `WriterInterface` + `SeekerInterface` | +| `ReadWriterInterface` | `ReaderInterface` + `WriterInterface` | +| `ReadWriteSeekerInterface` | `ReaderInterface` + `WriterInterface` + `SeekerInterface` | + +`read()` returns `false` when the stream is at EOF. + +## Implementations + +| Class | Implements | Description | +|---|---|---| +| `Buffer` | `ReadWriteSeekerInterface` | In-memory stream backed by a PHP string | +| `Resource` | `ReadWriteSeekerInterface` | Base class wrapping a PHP file resource | +| `File` | `ReadWriteSeekerInterface` | File-backed stream opened via `FileMode` | +| `LimitedReader` | `ReaderInterface` | Limits reads to a fixed byte budget | +| `BinaryReader` | — | Typed binary reads over any `ReaderInterface` | +| `BinaryWriter` | — | Typed binary writes over any `WriterInterface` | + +## BinaryReader methods + +Integer names follow `read{Signedness}{Bits}{Endianness}`: + +| Method | Size | Range | +|---|---|---| +| `readUInt8()` | 1 byte | 0–255 | +| `readInt8()` | 1 byte | −128–127 | +| `readUInt16LE()` / `readUInt16BE()` | 2 bytes | 0–65535 | +| `readInt16LE()` / `readInt16BE()` | 2 bytes | −32768–32767 | +| `readUInt32LE()` / `readUInt32BE()` | 4 bytes | 0–4294967295 | +| `readInt32LE()` / `readInt32BE()` | 4 bytes | −2147483648–2147483647 | +| `readPaddedString(int $length, string $pad_chars)` | `$length` bytes | strips trailing `$pad_chars` | + +`readExact(int $length)` reads exactly `$length` bytes and throws `RuntimeException` if fewer are available. + +## BinaryWriter methods + +Integer names follow `write{Signedness}{Bits}{Endianness}`. All methods return bytes written. + +| Method | Size | +|---|---| +| `writeUInt8()` / `writeInt8()` | 1 byte | +| `writeUInt16LE()` / `writeUInt16BE()` / `writeInt16LE()` / `writeInt16BE()` | 2 bytes | +| `writeUInt32LE()` / `writeUInt32BE()` / `writeInt32LE()` / `writeInt32BE()` | 4 bytes | +| `writePaddedString(string $data, int $length, string $pad_char)` | `$length` bytes | ## Exceptions -- `Shufflingpixels\IO\Exception\IOException`: generic stream/file I/O failures -- `Shufflingpixels\IO\Exception\EndOfStreamException`: not enough bytes available when reading +- `Shufflingpixels\IO\Exception\IOException` — generic stream/file I/O failures +- `Shufflingpixels\IO\Exception\EndOfStreamException` — subclass of `IOException` ## Running Tests -This package uses Pest. - ```bash composer test ``` diff --git a/composer.json b/composer.json index daf0973..342d896 100644 --- a/composer.json +++ b/composer.json @@ -15,8 +15,7 @@ } ], "require": { - "php": ">=8.1", - "psr/http-message": "^2.0" + "php": ">=8.1" }, "require-dev": { "pestphp/pest": "^3.0" diff --git a/composer.lock b/composer.lock index ef0f2fa..b1a268a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,62 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c3d163cb00c11d9951f136537f938f25", - "packages": [ - { - "name": "psr/http-message", - "version": "2.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", - "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" - }, - "time": "2023-04-04T09:54:51+00:00" - } - ], + "content-hash": "c7b8b0e90d8a72bb5f82eed381fe5790", + "packages": [], "packages-dev": [ { "name": "brianium/paratest", @@ -3988,7 +3934,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.0" + "php": ">=8.1" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/src/BinaryReader.php b/src/BinaryReader.php index eb7a558..38b6766 100644 --- a/src/BinaryReader.php +++ b/src/BinaryReader.php @@ -6,78 +6,37 @@ declare(strict_types=1); namespace Shufflingpixels\IO; use InvalidArgumentException; -use Psr\Http\Message\StreamInterface; use RuntimeException; /** - * Reads primitive binary types from a PSR-7 stream. + * Reads primitive binary types from a reader. * * Integer methods follow the naming convention read{Signedness}{Bits}{Endianness}, * e.g. readInt16LE for a signed 16-bit little-endian integer. */ class BinaryReader { - public function __construct(protected StreamInterface $stream) + public function __construct(protected ReaderInterface $r) { } - /** - * Returns the total byte length of the stream. - * - * @throws \RuntimeException if the stream size is not known - */ - public function length() : int - { - $size = $this->stream->getSize(); - if ($size === null) { - throw new RuntimeException('Stream size is not known'); - } - - return $size; - } - - /** - * Returns the current byte offset of the stream cursor. - */ - public function tell() : int - { - return $this->stream->tell(); - } - - /** - * Returns true when the cursor is at the end of the stream. - */ - public function eof() : bool - { - return $this->stream->eof(); - } - - /** - * Moves the stream cursor to the given position. - * - * @param int $whence SEEK_SET, SEEK_CUR, or SEEK_END - */ - public function seek(int $position, int $whence = SEEK_SET): void - { - $this->stream->seek($position, $whence); - } - /** * Reads exactly $length bytes, throwing if fewer are available. * * @throws \InvalidArgumentException if $length is negative * @throws \RuntimeException if the stream returns fewer bytes than requested */ - public function read(int $length): string + public function readExact(int $length): string { if ($length < 0) { throw new InvalidArgumentException('Length must be >= 0'); } - $data = $this->stream->read($length); - if (\strlen($data) !== $length) { + $data = $this->r->read($length); + $got = $data === false ? 0 : \strlen($data); + if ($got !== $length) { throw new RuntimeException( - "Not enough bytes to read {$length} byte(s), " . \strlen($data) . ' read' + "Not enough bytes to read {$length} byte(s), {$got} read" ); } @@ -89,7 +48,7 @@ class BinaryReader */ public function readUInt8(): int { - return \ord($this->read(1)); + return \ord($this->readExact(1)); } /** @@ -107,7 +66,7 @@ class BinaryReader */ public function readUInt16LE(): int { - return \unpack('v', $this->read(2))[1]; + return \unpack('v', $this->readExact(2))[1]; } /** @@ -115,7 +74,7 @@ class BinaryReader */ public function readUInt16BE(): int { - return \unpack('n', $this->read(2))[1]; + return \unpack('n', $this->readExact(2))[1]; } /** @@ -143,7 +102,7 @@ class BinaryReader */ public function readUInt32LE(): int { - return \unpack('V', $this->read(4))[1]; + return \unpack('V', $this->readExact(4))[1]; } /** @@ -151,7 +110,7 @@ class BinaryReader */ public function readUInt32BE(): int { - return \unpack('N', $this->read(4))[1]; + return \unpack('N', $this->readExact(4))[1]; } /** @@ -181,7 +140,7 @@ class BinaryReader */ public function readPaddedString(int $length, string $pad_chars = "\x00") : string { - $data = $this->read($length); + $data = $this->readExact($length); return rtrim($data, $pad_chars); } } diff --git a/src/BinaryWriter.php b/src/BinaryWriter.php index a90c668..83eda17 100644 --- a/src/BinaryWriter.php +++ b/src/BinaryWriter.php @@ -6,54 +6,27 @@ declare(strict_types=1); namespace Shufflingpixels\IO; use InvalidArgumentException; -use Psr\Http\Message\StreamInterface; /** - * Writes primitive binary types to a PSR-7 stream. + * Writes primitive binary types to a writer. * * Integer methods follow the naming convention write{Signedness}{Bits}{Endianness}, * e.g. writeInt16LE for a signed 16-bit little-endian integer. * - * All write methods return the number of bytes written, as forwarded from the stream. + * All write methods return the number of bytes written, as forwarded from the writer. */ class BinaryWriter { - public function __construct(protected StreamInterface $stream) + public function __construct(protected WriterInterface $w) { } - /** - * Returns the current byte offset of the stream cursor. - */ - public function tell(): int - { - return $this->stream->tell(); - } - - /** - * Moves the stream cursor to the given position. - * - * @param int $whence SEEK_SET, SEEK_CUR, or SEEK_END - */ - public function seek(int $position, int $whence = SEEK_SET): void - { - $this->stream->seek($position, $whence); - } - - /** - * Writes raw bytes to the stream. - */ - public function write(string $data): int - { - return $this->stream->write($data); - } - /** * Writes an unsigned 8-bit integer (0–255). */ public function writeUInt8(int $value): int { - return $this->write(\chr($value & 0xff)); + return $this->w->write(\chr($value & 0xff)); } /** @@ -69,7 +42,7 @@ class BinaryWriter */ public function writeUInt16LE(int $value): int { - return $this->write(\pack('v', $value)); + return $this->w->write(\pack('v', $value)); } /** @@ -77,7 +50,7 @@ class BinaryWriter */ public function writeUInt16BE(int $value): int { - return $this->write(\pack('n', $value)); + return $this->w->write(\pack('n', $value)); } /** @@ -101,7 +74,7 @@ class BinaryWriter */ public function writeUInt32LE(int $value): int { - return $this->write(\pack('V', $value)); + return $this->w->write(\pack('V', $value)); } /** @@ -109,7 +82,7 @@ class BinaryWriter */ public function writeUInt32BE(int $value): int { - return $this->write(\pack('N', $value)); + return $this->w->write(\pack('N', $value)); } /** @@ -142,6 +115,6 @@ class BinaryWriter throw new InvalidArgumentException('pad_char must be exactly one byte'); } - return $this->write(\substr(\str_pad($data, $length, $pad_char), 0, $length)); + return $this->w->write(\substr(\str_pad($data, $length, $pad_char), 0, $length)); } } diff --git a/src/Buffer.php b/src/Buffer.php index abdfd15..bda7582 100644 --- a/src/Buffer.php +++ b/src/Buffer.php @@ -7,56 +7,21 @@ namespace Shufflingpixels\IO; use InvalidArgumentException; use OutOfBoundsException; -use Psr\Http\Message\StreamInterface; -use RuntimeException; /** - * An in-memory PSR-7 stream backed by a plain PHP string. + * An in-memory byte stream backed by a plain PHP string. * * Supports both reading and writing. Writing at the current cursor position * overwrites existing bytes and extends the buffer if the write goes past the end. */ -class Buffer implements StreamInterface +class Buffer implements ReadWriteSeekerInterface { private int $position = 0; - private bool $detached = false; public function __construct(protected string $data) { } - /** - * Detaches and discards the internal string. - */ - public function close(): void - { - $this->detach(); - } - - /** - * Detaches the internal string, clearing its contents, and returns null. - */ - public function detach(): mixed - { - if ($this->detached) { - return null; - } - - $this->detached = true; - $this->data = ''; - $this->position = 0; - - return null; - } - - /** - * Returns the byte length of the buffer, or null after detach. - */ - public function getSize(): ?int - { - return $this->detached ? null : $this->length(); - } - /** * Returns the total byte length of the buffer. */ @@ -65,58 +30,29 @@ class Buffer implements StreamInterface return \strlen($this->data); } - /** - * Returns the number of bytes between the current cursor position and the end. - */ - public function remaining() : int - { - if ($this->detached) { - return 0; - } - - return $this->length() - $this->position; - } - /** * Returns true when the cursor is at or past the end of the buffer. */ public function eof(): bool { - return $this->remaining() === 0; + return $this->position >= $this->length(); } - /** - * Returns the current byte offset of the cursor. - * - * @throws \RuntimeException if the stream is detached - */ + /** Returns the current byte offset of the cursor. */ public function tell() : int { - $this->ensureAttached(); - return $this->position; } - /** - * Always returns true — buffers are always seekable. - */ - public function isSeekable(): bool - { - return true; - } - /** * Moves the cursor to the given position. * * @param int $whence SEEK_SET, SEEK_CUR, or SEEK_END * @throws \InvalidArgumentException for an unrecognised $whence value * @throws \OutOfBoundsException if the resolved position is outside [0, length] - * @throws \RuntimeException if the stream is detached */ public function seek(int $offset, int $whence = SEEK_SET): void { - $this->ensureAttached(); - $position = match($whence) { SEEK_SET => $offset, SEEK_CUR => $this->position + $offset, @@ -131,68 +67,37 @@ class Buffer implements StreamInterface $this->position = $position; } - /** - * Moves the cursor to the start of the buffer. - */ - public function rewind(): void - { - $this->seek(0); - } - - /** - * Always returns true — buffers are always readable. - */ - public function isReadable(): bool - { - return true; - } - /** * Reads up to $length bytes from the current cursor position. * - * Returns fewer bytes than requested when the end of the buffer is reached. + * Returns a partial result when a read extends past the end of the buffer. + * Returns false when already at end of stream. * * @throws \InvalidArgumentException if $length is negative - * @throws \RuntimeException if the stream is detached */ - public function read(int $length): string + public function read(int $length): string|false { - $this->ensureAttached(); - if ($length < 0) { throw new InvalidArgumentException("Length must be >= 0"); } - if ($length === 0 || $this->eof()) { - return ''; + if ($this->eof()) { + return false; } - $result = substr($this->data, $this->position, min($length, $this->remaining())); - $this->position += strlen($result); - + $result = substr($this->data, $this->position, $length); + $this->position += \strlen($result); return $result; } - /** - * Always returns true — buffers are always writable. - */ - public function isWritable(): bool - { - return true; - } - /** * Writes $string at the current cursor position, overwriting existing bytes. * * If the write extends past the end of the buffer, the buffer grows accordingly. * Returns the number of bytes written. - * - * @throws \RuntimeException if the stream is detached */ public function write(string $string): int { - $this->ensureAttached(); - $length = \strlen($string); if ($length === 0) { @@ -205,67 +110,9 @@ class Buffer implements StreamInterface ? \substr($this->data, $suffixStart) : ''; - $this->data = $prefix . $string . $suffix; + $this->data = "{$prefix}{$string}{$suffix}"; $this->position += $length; return $length; } - - /** - * Returns all bytes from the current cursor position to the end and advances the cursor. - * - * @throws \RuntimeException if the stream is detached - */ - public function getContents(): string - { - $this->ensureAttached(); - - if ($this->eof()) { - return ''; - } - - $result = substr($this->data, $this->position); - $this->position = $this->length(); - - return $result; - } - - /** - * Returns stream metadata, or a single key if $key is provided. - * - * Returns null for unknown keys. - */ - public function getMetadata(?string $key = null): mixed - { - $metadata = [ - 'seekable' => true, - 'readable' => true, - 'writable' => true, - 'uri' => null, - ]; - - return $key !== null ? $metadata[$key] ?? null : $metadata; - } - - /** - * Returns the full buffer contents regardless of cursor position. Returns '' after detach. - */ - public function __toString(): string - { - if ($this->detached) { - return ''; - } - - return $this->data; - } - - /** - * @throws \RuntimeException - */ - private function ensureAttached(): void - { - if ($this->detached) { - throw new RuntimeException('Stream is detached'); - } - } } diff --git a/src/File.php b/src/File.php index 01243cb..07ccbeb 100644 --- a/src/File.php +++ b/src/File.php @@ -7,9 +7,7 @@ namespace Shufflingpixels\IO; use Shufflingpixels\IO\Exception\IOException; -/** - * A PSR-7 stream backed by a file on disk, opened via {@see FileMode}. - */ +/** A file-backed stream opened via {@see FileMode}. */ class File extends Resource { /** @@ -31,11 +29,6 @@ class File extends Resource throw new IOException("Unable to open file"); } - return new self( - $fd, - $mode->seekable(), - $mode->readable(), - $mode->writable() - ); + return new self($fd); } } diff --git a/src/FileMode.php b/src/FileMode.php index e219c03..9dad035 100644 --- a/src/FileMode.php +++ b/src/FileMode.php @@ -17,19 +17,4 @@ enum FileMode : string case READ = 'r'; case WRITE = 'w'; case RW = 'r+'; - - public function seekable(): bool - { - return true; - } - - public function readable(): bool - { - return $this === self::READ || $this === self::RW; - } - - public function writable(): bool - { - return $this === self::WRITE || $this === self::RW; - } } diff --git a/src/LimitedReader.php b/src/LimitedReader.php new file mode 100644 index 0000000..a66a751 --- /dev/null +++ b/src/LimitedReader.php @@ -0,0 +1,51 @@ += 0'); + } + + if ($this->remaining <= 0) { + return false; + } + + $data = $this->r->read(min($length, $this->remaining)); + $this->remaining -= \strlen($data); + return $data; + } +} diff --git a/src/LimitedResource.php b/src/LimitedResource.php deleted file mode 100644 index 1a99c78..0000000 --- a/src/LimitedResource.php +++ /dev/null @@ -1,248 +0,0 @@ -isSeekable()) { - throw new IOException('Underlying stream must be seekable'); - } - if ($start < 0) { - throw new IOException('Start offset must be >= 0'); - } - if ($length < 0) { - throw new IOException('Length must be >= 0'); - } - } - - /** - * Detaches from the underlying stream. Does not close it. - */ - public function close(): void - { - $this->detach(); - } - - /** - * Detaches from the underlying stream and returns null. Does not close it. - */ - public function detach(): mixed - { - if ($this->detached) { - return null; - } - - $this->detached = true; - $this->position = 0; - - return null; - } - - /** - * Returns the window length in bytes, or null after detach. - */ - public function getSize(): ?int - { - return $this->detached ? null : $this->length; - } - - /** - * Returns true when the cursor is at or past the end of the window, or after detach. - */ - public function eof(): bool - { - return $this->detached || $this->position >= $this->length; - } - - /** - * Returns the current byte offset within the window (not the underlying stream). - * - * @throws \RuntimeException if the stream is detached - */ - public function tell(): int - { - $this->ensureAttached(); - - return $this->position; - } - - /** - * Always returns true — the window is always seekable. - */ - public function isSeekable(): bool - { - return true; - } - - /** - * Moves the cursor to the given position within the window. - * - * @param int $whence SEEK_SET, SEEK_CUR, or SEEK_END (relative to the window, not the underlying stream) - * @throws \InvalidArgumentException for an unrecognised $whence value - * @throws \OutOfBoundsException if the resolved position is outside [0, length] - * @throws \RuntimeException if the stream is detached - */ - public function seek(int $offset, int $whence = SEEK_SET): void - { - $this->ensureAttached(); - - $position = match ($whence) { - SEEK_SET => $offset, - SEEK_CUR => $this->position + $offset, - SEEK_END => $this->length + $offset, - default => throw new InvalidArgumentException('Invalid seek mode'), - }; - - if ($position < 0 || $position > $this->length) { - throw new OutOfBoundsException("Seek position out of bounds: {$position}"); - } - - $this->position = $position; - } - - /** - * Moves the cursor to the start of the window. - */ - public function rewind(): void - { - $this->seek(0); - } - - /** - * Always returns true — the window is always readable. - */ - public function isReadable(): bool - { - return true; - } - - /** - * Reads up to $length bytes from the current cursor position within the window. - * - * Clamps the read to the window boundary so it never reads into adjacent data. - * Returns fewer bytes than requested when the end of the window is reached. - * - * @throws \InvalidArgumentException if $length is negative - * @throws \RuntimeException if the stream is detached - */ - public function read(int $length): string - { - $this->ensureAttached(); - - if ($length < 0) { - throw new InvalidArgumentException('Length must be >= 0'); - } - - if ($length === 0 || $this->eof()) { - return ''; - } - - $toRead = min($length, $this->length - $this->position); - $this->stream->seek($this->start + $this->position); - $data = $this->stream->read($toRead); - $this->position += \strlen($data); - - return $data; - } - - /** - * Always returns false — writing to a window is not supported. - */ - public function isWritable(): bool - { - return false; - } - - /** - * @throws IOException always — the window is read-only - */ - public function write(string $string): int - { - throw new IOException('LimitedResource is read-only'); - } - - /** - * Returns all bytes from the current cursor position to the end of the window. - * - * @throws \RuntimeException if the stream is detached - */ - public function getContents(): string - { - $this->ensureAttached(); - - if ($this->eof()) { - return ''; - } - - return $this->read($this->length - $this->position); - } - - /** - * Always returns null — no metadata is available for a window stream. - */ - public function getMetadata(?string $key = null): mixed - { - return null; - } - - /** - * Returns the full window contents regardless of current cursor position. - * - * Returns '' after detach or if an error occurs during reading. - */ - public function __toString(): string - { - if ($this->detached) { - return ''; - } - - try { - $this->seek(0); - return $this->getContents(); - } catch (\Throwable) { - return ''; - } - } - - /** - * @throws \RuntimeException - */ - private function ensureAttached(): void - { - if ($this->detached) { - throw new RuntimeException('Stream is detached'); - } - } -} diff --git a/src/ReadSeekerInterface.php b/src/ReadSeekerInterface.php new file mode 100644 index 0000000..40f476c --- /dev/null +++ b/src/ReadSeekerInterface.php @@ -0,0 +1,10 @@ +isSeekable()) { - return null; - } - - if ($this->size === null) { + if ($this->size < 0) { $pos = $this->tell(); fseek($this->resource, 0, SEEK_END); @@ -81,14 +69,6 @@ abstract class Resource implements StreamInterface return feof($this->resource); } - /** - * Returns whether this stream supports seeking. - */ - public function isSeekable(): bool - { - return $this->seekable; - } - /** * Returns the current byte offset of the file cursor. */ @@ -101,98 +81,55 @@ abstract class Resource implements StreamInterface * Moves the file cursor to the given position. * * @param int $whence SEEK_SET, SEEK_CUR, or SEEK_END - * @throws IOException if the stream is not seekable or the seek fails + * @throws IOException if the seek fails */ public function seek(int $position, int $whence = SEEK_SET): void { - if (!$this->isSeekable()) { - throw new IOException("Unable to seek on a non-seekable stream"); - } - if (fseek($this->resource, $position, $whence) < 0) { throw new IOException("Unable to seek to the given position"); } } - /** - * Returns whether this stream supports reading. - */ - public function isReadable(): bool - { - return $this->readable; - } - /** * Reads up to $length bytes from the current cursor position. + * + * Returns false when at end of file or on a read error. */ - public function read(int $length): string + public function read(int $length): string|false { - return fread($this->resource, $length); - } + if ($this->eof()) { + return false; + } - /** - * Returns whether this stream supports writing. - */ - public function isWritable(): bool - { - return $this->writable; + $result = fread($this->resource, $length); + + return ($result === '' || $result === false) ? false : $result; } /** * Writes $string at the current cursor position and returns the bytes written. * - * Invalidates the cached size so {@see getSize()} reflects the new length. + * Invalidates the cached length so the next call to {@see length()} reflects the new size. * - * @throws IOException if the stream is not writable or the write fails + * @throws IOException if the write fails */ public function write(string $string): int { - if (!$this->writable) { - throw new IOException("Stream is not writeable"); + set_error_handler(static fn () => true); + + try { + $result = fwrite($this->resource, $string); + } finally { + restore_error_handler(); } - $result = fwrite($this->resource, $string); if ($result === false) { throw new IOException("Failed to write to stream"); } - $this->size = null; + // invalidate cached length + $this->size = -1; + return $result; } - - /** - * Moves the cursor to the start of the stream. - */ - public function rewind(): void - { - $this->seek(0); - } - - /** - * Returns all bytes from the current cursor position to the end. - */ - public function getContents(): string - { - return (string) stream_get_contents($this->resource); - } - - /** - * Returns stream metadata, or a single key if $key is provided. - * - * Returns null for unknown keys. - */ - public function getMetadata(?string $key = null): mixed - { - $data = stream_get_meta_data($this->resource); - - return $key !== null ? $data[$key] ?? null : $data; - } - - /** - * Returns all remaining stream contents as a string. - */ - public function __toString(): string - { - return $this->getContents(); - } } diff --git a/src/SeekerInterface.php b/src/SeekerInterface.php new file mode 100644 index 0000000..1be0efd --- /dev/null +++ b/src/SeekerInterface.php @@ -0,0 +1,28 @@ +length())->toBe(4) - ->and($reader->tell())->toBe(0) - ->and($reader->eof())->toBeFalse(); - - $reader->seek(2); - expect($reader->tell())->toBe(2) - ->and($reader->read(1))->toBe('c'); - - $reader->seek(-1, SEEK_END); - expect($reader->read(1))->toBe('d') - ->and($reader->eof())->toBeTrue(); -}); +use Shufflingpixels\IO\ReaderInterface; it('throws for negative read length', function () { $reader = new BinaryReader(new Buffer('abc')); - expect(fn () => $reader->read(-1))->toThrow(InvalidArgumentException::class); + expect(fn () => $reader->readExact(-1))->toThrow(InvalidArgumentException::class); }); it('throws when stream returns fewer bytes than requested', function () { - $stream = new class implements \Psr\Http\Message\StreamInterface { - public function __toString(): string { return ''; } - public function close(): void {} - public function detach(): mixed { return null; } - public function getSize(): ?int { return 0; } - public function eof(): bool { return false; } - public function isSeekable(): bool { return false; } - public function seek(int $position, int $whence = SEEK_SET): void {} - public function rewind(): void {} - public function tell(): int { return 0; } - public function isWritable(): bool { return false; } - public function write(string $string): int { return 0; } - public function isReadable(): bool { return true; } - public function read(int $length): string { return 'x'; } - public function getContents(): string { return ''; } - public function getMetadata(?string $key = null): mixed { return null; } + $stream = new class implements ReaderInterface { + public function read(int $length): string|false { return 'x'; } }; - $reader = new BinaryReader(new Buffer($stream)); + $reader = new BinaryReader($stream); - expect(fn () => $reader->read(2))->toThrow(RuntimeException::class, 'Not enough bytes'); + expect(fn () => $reader->readExact(2))->toThrow(RuntimeException::class, 'Not enough bytes'); +}); + +it('throws when stream returns false', function () { + $stream = new class implements ReaderInterface { + public function read(int $length): string|false { return false; } + }; + + $reader = new BinaryReader($stream); + + expect(fn () => $reader->readExact(1))->toThrow(RuntimeException::class, 'Not enough bytes'); }); it('reads 8 bit integers', function () { @@ -98,7 +79,7 @@ it('advances the cursor by the full field length after readPaddedString', functi $reader->readPaddedString(4); - expect($reader->read(3))->toBe('end'); + expect($reader->readExact(3))->toBe('end'); }); it('throws when not enough bytes remain for readPaddedString', function () { diff --git a/tests/Unit/BinaryWriterTest.php b/tests/Unit/BinaryWriterTest.php index 1985a86..5c2b6c0 100644 --- a/tests/Unit/BinaryWriterTest.php +++ b/tests/Unit/BinaryWriterTest.php @@ -1,5 +1,6 @@ tell())->toBe(0); - - $writer->write('abc'); - expect($writer->tell())->toBe(3); - - $writer->seek(1); - expect($writer->tell())->toBe(1); -}); - -it('writes raw bytes', function () { - [$writer, $buffer] = writer(); - - $writer->write('hello'); - - expect((string) $buffer)->toBe('hello'); -}); +function bufferContents(Buffer $buffer): string +{ + $pos = $buffer->tell(); + $buffer->seek(0); + $result = $buffer->read($buffer->length()); + $buffer->seek($pos); + return $result; +} it('writes 8 bit integers', function () { [$writer, $buffer] = writer(); @@ -37,7 +27,7 @@ it('writes 8 bit integers', function () { $writer->writeInt8(-1); $writer->writeInt8(-128); - expect((string) $buffer)->toBe("\x7f\xff\xff\x80"); + expect(bufferContents($buffer))->toBe("\x7f\xff\xff\x80"); }); it('writes 16 bit integers in little and big endian', function () { @@ -48,7 +38,7 @@ it('writes 16 bit integers in little and big endian', function () { $writer->writeInt16LE(-1); $writer->writeInt16BE(-32768); - expect((string) $buffer)->toBe( + expect(bufferContents($buffer))->toBe( pack('v', 0x1234) . pack('n', 0x5678) . pack('v', 0xffff) . pack('n', 0x8000) ); }); @@ -61,7 +51,7 @@ it('writes 32 bit integers in little and big endian', function () { $writer->writeInt32LE(-1); $writer->writeInt32BE(-2147483648); - expect((string) $buffer)->toBe( + expect(bufferContents($buffer))->toBe( pack('V', 0x12345678) . pack('N', 0x10203040) . pack('V', 0xffffffff) . pack('N', 0x80000000) ); }); @@ -71,7 +61,7 @@ it('writes a padded string shorter than the field length', function () { $writer->writePaddedString('hi', 5); - expect((string) $buffer)->toBe("hi\x00\x00\x00"); + expect(bufferContents($buffer))->toBe("hi\x00\x00\x00"); }); it('writes a padded string exactly matching the field length', function () { @@ -79,7 +69,7 @@ it('writes a padded string exactly matching the field length', function () { $writer->writePaddedString('hello', 5); - expect((string) $buffer)->toBe('hello'); + expect(bufferContents($buffer))->toBe('hello'); }); it('truncates a string longer than the field length', function () { @@ -87,7 +77,7 @@ it('truncates a string longer than the field length', function () { $writer->writePaddedString('toolong', 4); - expect((string) $buffer)->toBe('tool'); + expect(bufferContents($buffer))->toBe('tool'); }); it('uses a custom pad character', function () { @@ -95,7 +85,7 @@ it('uses a custom pad character', function () { $writer->writePaddedString('hi', 5, ' '); - expect((string) $buffer)->toBe('hi '); + expect(bufferContents($buffer))->toBe('hi '); }); it('throws for a multi-byte pad character', function () { @@ -112,8 +102,8 @@ it('written values round-trip through BinaryReader', function () { $writer->writeInt16LE(-300); $writer->writeUInt32BE(0xdeadbeef); - $buffer->rewind(); - $reader = new \Shufflingpixels\IO\BinaryReader($buffer); + $buffer->seek(0); + $reader = new BinaryReader($buffer); expect($reader->readUInt8())->toBe(42) ->and($reader->readInt16LE())->toBe(-300) diff --git a/tests/Unit/BufferTest.php b/tests/Unit/BufferTest.php index 5796a31..63c25a7 100644 --- a/tests/Unit/BufferTest.php +++ b/tests/Unit/BufferTest.php @@ -5,14 +5,12 @@ use Shufflingpixels\IO\Buffer; it('reads, seeks and tracks position', function () { $buffer = new Buffer('abcdef'); - expect($buffer->getSize())->toBe(6) + expect($buffer->length())->toBe(6) ->and($buffer->tell())->toBe(0) - ->and($buffer->remaining())->toBe(6) ->and($buffer->eof())->toBeFalse(); expect($buffer->read(2))->toBe('ab') - ->and($buffer->tell())->toBe(2) - ->and($buffer->remaining())->toBe(4); + ->and($buffer->tell())->toBe(2); $buffer->seek(-1, SEEK_END); @@ -50,6 +48,13 @@ it('returns available bytes when reading beyond end of stream', function () { ->and($buffer->eof())->toBeTrue(); }); +it('returns false when reading at end of stream', function () { + $buffer = new Buffer('a'); + $buffer->read(1); + + expect($buffer->read(1))->toBeFalse(); +}); + it('writes at current position and updates contents', function () { $buffer = new Buffer('abcdef'); $buffer->seek(2); @@ -67,14 +72,15 @@ it('writes nothing for empty payload', function () { expect($buffer->write(''))->toBe(0) ->and($buffer->tell())->toBe(0) - ->and($buffer->getSize())->toBe(3); + ->and($buffer->length())->toBe(3); }); -it('returns remaining contents and advances cursor', function () { +it('reads remaining bytes from cursor to end', function () { $buffer = new Buffer('abcdef'); $buffer->seek(2); - expect($buffer->getContents())->toBe('cdef') - ->and($buffer->tell())->toBe(6) - ->and($buffer->getContents())->toBe(''); + $remaining = $buffer->read($buffer->length() - $buffer->tell()); + + expect($remaining)->toBe('cdef') + ->and($buffer->eof())->toBeTrue(); }); diff --git a/tests/Unit/FileModeTest.php b/tests/Unit/FileModeTest.php index daddfa5..d7f2d47 100644 --- a/tests/Unit/FileModeTest.php +++ b/tests/Unit/FileModeTest.php @@ -7,13 +7,3 @@ it('defines expected fopen mode values', function () { ->and(FileMode::WRITE->value)->toBe('w') ->and(FileMode::RW->value)->toBe('r+'); }); - -it('reports read and write capabilities for each mode', function () { - expect(FileMode::READ->readable())->toBeTrue() - ->and(FileMode::READ->writable())->toBeFalse() - ->and(FileMode::WRITE->readable())->toBeFalse() - ->and(FileMode::WRITE->writable())->toBeTrue() - ->and(FileMode::RW->readable())->toBeTrue() - ->and(FileMode::RW->writable())->toBeTrue() - ->and(FileMode::READ->seekable())->toBeTrue(); -}); diff --git a/tests/Unit/FileTest.php b/tests/Unit/FileTest.php index abc38be..432acbf 100644 --- a/tests/Unit/FileTest.php +++ b/tests/Unit/FileTest.php @@ -4,31 +4,27 @@ use Shufflingpixels\IO\Exception\IOException; use Shufflingpixels\IO\File; use Shufflingpixels\IO\FileMode; -it('opens readable files and reads contents', function () { +it('opens a file and reads its contents', function () { $path = tempnam(sys_get_temp_dir(), 'php-io-'); file_put_contents($path, 'hello'); $file = File::open($path, FileMode::READ); - expect($file->isReadable())->toBeTrue() - ->and($file->isWritable())->toBeFalse() - ->and($file->getSize())->toBe(5) + expect($file->length())->toBe(5) ->and($file->read(5))->toBe('hello'); $file->close(); unlink($path); }); -it('opens read-write files and persists writes', function () { +it('opens a file for reading and writing', function () { $path = tempnam(sys_get_temp_dir(), 'php-io-'); file_put_contents($path, 'abc'); $file = File::open($path, FileMode::RW); $file->seek(0); - expect($file->isReadable())->toBeTrue() - ->and($file->isWritable())->toBeTrue() - ->and($file->write('X'))->toBe(1); + expect($file->write('X'))->toBe(1); $file->seek(0); expect($file->read(3))->toBe('Xbc'); @@ -37,15 +33,13 @@ it('opens read-write files and persists writes', function () { unlink($path); }); -it('opens write mode files and truncates existing contents', function () { +it('opens a file for writing and truncates existing contents', function () { $path = tempnam(sys_get_temp_dir(), 'php-io-'); file_put_contents($path, 'abcdef'); $file = File::open($path, FileMode::WRITE); - expect($file->isReadable())->toBeFalse() - ->and($file->isWritable())->toBeTrue() - ->and($file->getSize())->toBe(0) + expect($file->length())->toBe(0) ->and($file->write('xy'))->toBe(2); $file->close(); @@ -53,7 +47,19 @@ it('opens write mode files and truncates existing contents', function () { unlink($path); }); -it('throws an io exception when opening a missing path', function () { +it('throws when writing to a read-only file', function () { + $path = tempnam(sys_get_temp_dir(), 'php-io-'); + file_put_contents($path, 'hello'); + + $file = File::open($path, FileMode::READ); + + expect(fn () => $file->write('x'))->toThrow(IOException::class); + + $file->close(); + unlink($path); +}); + +it('throws an IOException when opening a missing path', function () { $path = sys_get_temp_dir() . '/php-io-missing-dir/' . uniqid('', true) . '.txt'; expect(fn () => File::open($path, FileMode::READ))->toThrow(IOException::class); diff --git a/tests/Unit/LimitedResourceTest.php b/tests/Unit/LimitedResourceTest.php deleted file mode 100644 index 4b200a6..0000000 --- a/tests/Unit/LimitedResourceTest.php +++ /dev/null @@ -1,334 +0,0 @@ - new LimitedResource($stream, 0, 5))->toThrow(IOException::class); -}); - -it('throws IOException for negative start offset', function () { - expect(fn() => new LimitedResource(new Buffer('hello'), -1, 5))->toThrow(IOException::class); -}); - -it('throws IOException for negative length', function () { - expect(fn() => new LimitedResource(new Buffer('hello'), 0, -1))->toThrow(IOException::class); -}); - -// --- Basic reads & position tracking --- - -it('reads the correct bytes from a scoped window', function () { - $limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5); - - expect($limited->read(5))->toBe('World') - ->and($limited->tell())->toBe(5) - ->and($limited->eof())->toBeTrue(); -}); - -it('advances position correctly across partial reads', function () { - $limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5); - - expect($limited->read(3))->toBe('Wor') - ->and($limited->tell())->toBe(3); - - expect($limited->read(2))->toBe('ld') - ->and($limited->tell())->toBe(5); -}); - -it('clamps read to window boundary without throwing', function () { - $limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5); - - expect($limited->read(9999))->toBe('World') - ->and($limited->eof())->toBeTrue(); -}); - -it('returns empty string when already at eof', function () { - $limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5); - $limited->seek(5); - - expect($limited->read(1))->toBe(''); -}); - -it('returns empty string for zero-length read', function () { - $limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5); - - expect($limited->read(0))->toBe(''); - expect($limited->tell())->toBe(0); -}); - -it('throws InvalidArgumentException for negative read length', function () { - $limited = new LimitedResource(new Buffer('hello'), 0, 5); - - expect(fn() => $limited->read(-1))->toThrow(InvalidArgumentException::class); -}); - -// --- Seek modes --- - -it('supports SEEK_SET within the window', function () { - $limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5); - $limited->seek(2); - - expect($limited->tell())->toBe(2) - ->and($limited->read(3))->toBe('rld'); -}); - -it('supports SEEK_CUR within the window', function () { - $limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5); - $limited->read(3); - $limited->seek(-1, SEEK_CUR); - - expect($limited->tell())->toBe(2) - ->and($limited->read(1))->toBe('r'); -}); - -it('supports SEEK_END within the window', function () { - $limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5); - $limited->seek(-2, SEEK_END); - - expect($limited->tell())->toBe(3) - ->and($limited->read(2))->toBe('ld'); -}); - -it('allows seeking to exactly the window length (eof position)', function () { - $limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5); - $limited->seek(5); - - expect($limited->tell())->toBe(5) - ->and($limited->eof())->toBeTrue(); -}); - -it('throws OutOfBoundsException for seek past window end', function () { - $limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5); - - expect(fn() => $limited->seek(6))->toThrow(OutOfBoundsException::class); -}); - -it('throws OutOfBoundsException for negative seek position', function () { - $limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5); - - expect(fn() => $limited->seek(-1, SEEK_SET))->toThrow(OutOfBoundsException::class); -}); - -it('throws InvalidArgumentException for invalid seek whence', function () { - $limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5); - - expect(fn() => $limited->seek(0, 99))->toThrow(InvalidArgumentException::class); -}); - -// --- getSize / eof / rewind / getContents / __toString --- - -it('getSize returns window length regardless of underlying stream size', function () { - $limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5); - - expect($limited->getSize())->toBe(5); -}); - -it('isSeekable returns true', function () { - expect((new LimitedResource(new Buffer('hello'), 0, 5))->isSeekable())->toBeTrue(); -}); - -it('isReadable returns true', function () { - expect((new LimitedResource(new Buffer('hello'), 0, 5))->isReadable())->toBeTrue(); -}); - -it('rewind resets position to zero', function () { - $limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5); - $limited->read(3); - $limited->rewind(); - - expect($limited->tell())->toBe(0) - ->and($limited->read(5))->toBe('World'); -}); - -it('getContents returns remaining window content from current position', function () { - $limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5); - $limited->read(2); - - expect($limited->getContents())->toBe('rld') - ->and($limited->getContents())->toBe(''); -}); - -it('__toString returns full window content regardless of current position', function () { - $limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5); - $limited->read(3); - - expect((string) $limited)->toBe('World'); -}); - -// --- Zero-length window --- - -it('handles zero-length window: immediate eof, reads return empty', function () { - $limited = new LimitedResource(new Buffer('hello'), 3, 0); - - expect($limited->getSize())->toBe(0) - ->and($limited->eof())->toBeTrue() - ->and($limited->read(5))->toBe('') - ->and($limited->getContents())->toBe(''); -}); - -it('zero-length window allows SEEK_END with offset 0 without throwing', function () { - $limited = new LimitedResource(new Buffer('hello'), 3, 0); - $limited->seek(0, SEEK_END); - - expect($limited->tell())->toBe(0); -}); - -// --- Lifecycle --- - -it('close does not close the underlying stream', function () { - $buffer = new Buffer('Hello, World!'); - $limited = new LimitedResource($buffer, 7, 5); - $limited->close(); - - expect($buffer->read(5))->toBe('Hello'); -}); - -it('detach returns null', function () { - $limited = new LimitedResource(new Buffer('hello'), 0, 5); - - expect($limited->detach())->toBeNull(); -}); - -it('getSize returns null after detach', function () { - $limited = new LimitedResource(new Buffer('hello'), 0, 5); - $limited->close(); - - expect($limited->getSize())->toBeNull(); -}); - -it('eof returns true after detach', function () { - $limited = new LimitedResource(new Buffer('hello'), 0, 5); - $limited->close(); - - expect($limited->eof())->toBeTrue(); -}); - -it('throws RuntimeException on read after detach', function () { - $limited = new LimitedResource(new Buffer('hello'), 0, 5); - $limited->close(); - - expect(fn() => $limited->read(1))->toThrow(RuntimeException::class); -}); - -it('throws RuntimeException on tell after detach', function () { - $limited = new LimitedResource(new Buffer('hello'), 0, 5); - $limited->close(); - - expect(fn() => $limited->tell())->toThrow(RuntimeException::class); -}); - -it('throws RuntimeException on seek after detach', function () { - $limited = new LimitedResource(new Buffer('hello'), 0, 5); - $limited->close(); - - expect(fn() => $limited->seek(0))->toThrow(RuntimeException::class); -}); - -it('throws RuntimeException on getContents after detach', function () { - $limited = new LimitedResource(new Buffer('hello'), 0, 5); - $limited->close(); - - expect(fn() => $limited->getContents())->toThrow(RuntimeException::class); -}); - -it('__toString returns empty string after detach', function () { - $limited = new LimitedResource(new Buffer('hello'), 0, 5); - $limited->close(); - - expect((string) $limited)->toBe(''); -}); - -// --- Write rejection --- - -it('isWritable returns false', function () { - expect((new LimitedResource(new Buffer('hello'), 0, 5))->isWritable())->toBeFalse(); -}); - -it('write throws IOException', function () { - $limited = new LimitedResource(new Buffer('hello'), 0, 5); - - expect(fn() => $limited->write('x'))->toThrow(IOException::class); -}); - -// --- Underlying position independence --- - -it('reads correctly regardless of where the underlying stream cursor is', function () { - $buffer = new Buffer('Hello, World!'); - $limited = new LimitedResource($buffer, 7, 5); - - $buffer->seek(0); - - expect($limited->read(5))->toBe('World'); -}); - -it('two LimitedResource windows on the same stream read independently', function () { - $buffer = new Buffer('Hello, World!'); - $hello = new LimitedResource($buffer, 0, 5); - $world = new LimitedResource($buffer, 7, 5); - - expect($hello->read(3))->toBe('Hel') - ->and($world->read(3))->toBe('Wor') - ->and($hello->read(2))->toBe('lo') - ->and($world->read(2))->toBe('ld'); -}); - -// --- BinaryReader integration --- - -it('integrates with BinaryReader for binary reads from a scoped section', function () { - $value = 0xDEADBEEF; - $data = str_repeat("\x00", 4) . pack('V', $value) . str_repeat("\x00", 4); - $buffer = new Buffer($data); - - $limited = new LimitedResource($buffer, 4, 4); - $reader = new BinaryReader($limited); - - expect($reader->length())->toBe(4) - ->and($reader->readUInt32LE())->toBe($value); -}); - -it('BinaryReader seek works within the window', function () { - $value = 0x0000CAFE; - $data = str_repeat("\x00", 8) . pack('V', $value); - $buffer = new Buffer($data); - - $limited = new LimitedResource($buffer, 8, 4); - $reader = new BinaryReader($limited); - - $first = $reader->readUInt32LE(); - $reader->seek(0); - $second = $reader->readUInt32LE(); - - expect($first)->toBe($value) - ->and($second)->toBe($value); -}); - -// --- getMetadata --- - -it('getMetadata always returns null', function () { - $limited = new LimitedResource(new Buffer('hello'), 0, 5); - - expect($limited->getMetadata())->toBeNull() - ->and($limited->getMetadata('seekable'))->toBeNull(); -}); diff --git a/tests/Unit/ResourceTest.php b/tests/Unit/ResourceTest.php index 32d56bb..f62000d 100644 --- a/tests/Unit/ResourceTest.php +++ b/tests/Unit/ResourceTest.php @@ -3,66 +3,90 @@ use Shufflingpixels\IO\Exception\IOException; use Shufflingpixels\IO\Resource; -it('reads, writes, seeks and reports stream state', function () { - $handle = fopen('php://temp', 'r+'); - fwrite($handle, 'hello'); - rewind($handle); +function makeResource(string $mode = 'r+', string $initial = ''): Resource +{ + $handle = fopen('php://temp', $mode); + if ($initial !== '') { + fwrite($handle, $initial); + rewind($handle); + } - $stream = new class($handle, true, true, true) extends Resource { - public function __construct($resource, bool $seekable, bool $readable, bool $writeable) - { - parent::__construct($resource, $seekable, $readable, $writeable); - } + return new class($handle) extends Resource { + public function __construct(mixed $resource) { parent::__construct($resource); } }; +} - expect($stream->getSize())->toBe(5) +it('reads, writes, seeks and reports length', function () { + $stream = makeResource('r+', 'hello'); + + expect($stream->length())->toBe(5) ->and($stream->tell())->toBe(0) - ->and($stream->isSeekable())->toBeTrue() - ->and($stream->isReadable())->toBeTrue() - ->and($stream->isWritable())->toBeTrue() ->and($stream->read(2))->toBe('he'); $stream->seek(0); expect($stream->write('H'))->toBe(1); $stream->seek(0); - expect($stream->read(5))->toBe('Hello') - ->and($stream->getSize())->toBe(5) - ->and($stream->eof())->toBeFalse(); + expect($stream->read(5))->toBe('Hello'); +}); + +it('length is recalculated after a write', function () { + $stream = makeResource('r+', 'abc'); + + expect($stream->length())->toBe(3); + + $stream->seek(0, SEEK_END); + $stream->write('de'); + + expect($stream->length())->toBe(5); +}); + +it('eof is true only after reading past the end', function () { + $stream = makeResource('r+', 'ab'); + + expect($stream->eof())->toBeFalse(); + + $stream->read(3); - $stream->read(1); expect($stream->eof())->toBeTrue(); - - $stream->close(); }); -it('throws when seeking or getting length on non-seekable stream', function () { - $handle = fopen('php://temp', 'r+'); +it('returns false when reading at end of stream', function () { + $stream = makeResource('r+', 'ab'); + $stream->read(3); - $stream = new class($handle, false, true, false) extends Resource { - public function __construct($resource, bool $seekable, bool $readable, bool $writeable) - { - parent::__construct($resource, $seekable, $readable, $writeable); - } + expect($stream->read(1))->toBeFalse(); +}); + +it('seek throws IOException on failure', function () { + $stream = makeResource('r+', 'abc'); + + expect(fn () => $stream->seek(-999))->toThrow(IOException::class); +}); + +it('write throws IOException when the underlying fwrite fails', function () { + $handle = fopen('php://temp', 'r'); + $stream = new class($handle) extends Resource { + public function __construct(mixed $resource) { parent::__construct($resource); } }; - expect($stream->getSize())->toBeNull() - ->and(fn () => $stream->seek(0))->toThrow(IOException::class); - - $stream->close(); + expect(fn () => $stream->write('x'))->toThrow(IOException::class); }); -it('throws when writing to a non-writeable stream', function () { - $handle = fopen('php://temp', 'r+'); +it('close releases the resource', function () { + $stream = makeResource('r+', 'abc'); + $stream->close(); - $stream = new class($handle, true, true, false) extends Resource { - public function __construct($resource, bool $seekable, bool $readable, bool $writeable) - { - parent::__construct($resource, $seekable, $readable, $writeable); - } + expect(fn () => $stream->read(1))->toThrow(\TypeError::class); +}); + +it('detach returns the underlying resource', function () { + $handle = fopen('php://temp', 'r+'); + $stream = new class($handle) extends Resource { + public function __construct(mixed $resource) { parent::__construct($resource); } }; - expect(fn () => $stream->write('x'))->toThrow(IOException::class, 'not writeable'); + $detached = $stream->detach(); - $stream->close(); + expect($detached)->toBe($handle); });