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); });