1
0
Fork 0
mirror of https://github.com/shufflingpixels/php-io.git synced 2026-08-15 11:48:13 +02:00

Adopt Go-style minimal interfaces for streams

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.
This commit is contained in:
Henrik Hautakoski 2026-06-27 08:40:11 +02:00
parent a27c0a87ea
commit 07ec405512
25 changed files with 451 additions and 1168 deletions

124
README.md
View file

@ -4,10 +4,10 @@ A small, focused PHP I/O toolkit for working with streams and binary data.
`php-io` gives you: `php-io` gives you:
- A consistent `StreamInterface` abstraction - A minimal, Go-inspired interface hierarchy for byte streams
- In-memory and file-backed stream implementations - In-memory and file-backed stream implementations
- A `BinaryReader` for common integer formats (8/16/32-bit, LE/BE) - `BinaryReader` and `BinaryWriter` for common integer formats (8/16/32-bit, LE/BE)
- Clear exception types for I/O and end-of-stream conditions - Clear exception types for I/O failures
## Requirements ## Requirements
@ -21,35 +21,53 @@ composer require shufflingpixels/php-io
## Quick Start ## Quick Start
### Read binary values from a string ### Read binary values from an in-memory buffer
```php ```php
<?php <?php
use Shufflingpixels\IO\BinaryReader; use Shufflingpixels\IO\BinaryReader;
use Shufflingpixels\IO\Buffer;
$reader = BinaryReader::string("\x34\x12\x80\xff"); $reader = new BinaryReader(new Buffer("\x34\x12\x80\xff"));
$a = $reader->readUInt16LE(); // 0x1234 => 4660 $a = $reader->readUInt16LE(); // 0x1234 => 4660
$b = $reader->readInt8(); // -128 $b = $reader->readInt8(); // -128
$c = $reader->readInt8(); // -1 $c = $reader->readInt8(); // -1
``` ```
### Work with an in-memory buffer ### Write binary values to a buffer
```php
<?php
use Shufflingpixels\IO\BinaryWriter;
use Shufflingpixels\IO\Buffer;
$buffer = new Buffer('');
$writer = new BinaryWriter($buffer);
$writer->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 ```php
<?php <?php
use Shufflingpixels\IO\Buffer; use Shufflingpixels\IO\Buffer;
use Shufflingpixels\IO\SeekMode;
$buffer = new Buffer('abcdef'); $buffer = new Buffer('abcdef');
$buffer->seek(2); // position = 2 $buffer->seek(2);
$buffer->write('XY'); // data becomes: abXYef $buffer->write('XY'); // data becomes: abXYef
$buffer->seek(-2, SeekMode::END); // position near end
$tail = $buffer->read(2); // "ef" $buffer->seek(-2, SEEK_END);
$tail = $buffer->read(2); // "ef"
``` ```
### Open and use a file stream ### Open and use a file stream
@ -70,24 +88,86 @@ $bytes = $file->read(3); // "ABC"
$file->close(); $file->close();
``` ```
## Main Types ### Limit reads to a byte window
- `Shufflingpixels\IO\StreamInterface`: common stream contract (`read`, `write`, `seek`, `tell`, `eof`, `length`) ```php
- `Shufflingpixels\IO\Buffer`: in-memory stream implementation <?php
- `Shufflingpixels\IO\File`: file-backed stream implementation
- `Shufflingpixels\IO\BinaryReader`: typed binary reads over any `StreamInterface` use Shufflingpixels\IO\BinaryReader;
- `Shufflingpixels\IO\SeekMode`: type-safe seek modes (`SET`, `CUR`, `END`) use Shufflingpixels\IO\Buffer;
- `Shufflingpixels\IO\FileMode`: file open modes (`READ`, `WRITE`, `RW`) use Shufflingpixels\IO\LimitedReader;
$buffer = new Buffer("header\x34\x12rest");
$buffer->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 | 0255 |
| `readInt8()` | 1 byte | 128127 |
| `readUInt16LE()` / `readUInt16BE()` | 2 bytes | 065535 |
| `readInt16LE()` / `readInt16BE()` | 2 bytes | 3276832767 |
| `readUInt32LE()` / `readUInt32BE()` | 4 bytes | 04294967295 |
| `readInt32LE()` / `readInt32BE()` | 4 bytes | 21474836482147483647 |
| `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 ## Exceptions
- `Shufflingpixels\IO\Exception\IOException`: generic stream/file I/O failures - `Shufflingpixels\IO\Exception\IOException` generic stream/file I/O failures
- `Shufflingpixels\IO\Exception\EndOfStreamException`: not enough bytes available when reading - `Shufflingpixels\IO\Exception\EndOfStreamException` — subclass of `IOException`
## Running Tests ## Running Tests
This package uses Pest.
```bash ```bash
composer test composer test
``` ```

View file

@ -15,8 +15,7 @@
} }
], ],
"require": { "require": {
"php": ">=8.1", "php": ">=8.1"
"psr/http-message": "^2.0"
}, },
"require-dev": { "require-dev": {
"pestphp/pest": "^3.0" "pestphp/pest": "^3.0"

60
composer.lock generated
View file

@ -4,62 +4,8 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "c3d163cb00c11d9951f136537f938f25", "content-hash": "c7b8b0e90d8a72bb5f82eed381fe5790",
"packages": [ "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"
}
],
"packages-dev": [ "packages-dev": [
{ {
"name": "brianium/paratest", "name": "brianium/paratest",
@ -3988,7 +3934,7 @@
"prefer-stable": false, "prefer-stable": false,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": ">=8.0" "php": ">=8.1"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.9.0"

View file

@ -6,78 +6,37 @@ declare(strict_types=1);
namespace Shufflingpixels\IO; namespace Shufflingpixels\IO;
use InvalidArgumentException; use InvalidArgumentException;
use Psr\Http\Message\StreamInterface;
use RuntimeException; 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}, * Integer methods follow the naming convention read{Signedness}{Bits}{Endianness},
* e.g. readInt16LE for a signed 16-bit little-endian integer. * e.g. readInt16LE for a signed 16-bit little-endian integer.
*/ */
class BinaryReader 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. * Reads exactly $length bytes, throwing if fewer are available.
* *
* @throws \InvalidArgumentException if $length is negative * @throws \InvalidArgumentException if $length is negative
* @throws \RuntimeException if the stream returns fewer bytes than requested * @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) { if ($length < 0) {
throw new InvalidArgumentException('Length must be >= 0'); throw new InvalidArgumentException('Length must be >= 0');
} }
$data = $this->stream->read($length); $data = $this->r->read($length);
if (\strlen($data) !== $length) { $got = $data === false ? 0 : \strlen($data);
if ($got !== $length) {
throw new RuntimeException( 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 public function readUInt8(): int
{ {
return \ord($this->read(1)); return \ord($this->readExact(1));
} }
/** /**
@ -107,7 +66,7 @@ class BinaryReader
*/ */
public function readUInt16LE(): int 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 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 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 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 public function readPaddedString(int $length, string $pad_chars = "\x00") : string
{ {
$data = $this->read($length); $data = $this->readExact($length);
return rtrim($data, $pad_chars); return rtrim($data, $pad_chars);
} }
} }

View file

@ -6,54 +6,27 @@ declare(strict_types=1);
namespace Shufflingpixels\IO; namespace Shufflingpixels\IO;
use InvalidArgumentException; 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}, * Integer methods follow the naming convention write{Signedness}{Bits}{Endianness},
* e.g. writeInt16LE for a signed 16-bit little-endian integer. * 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 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 (0255). * Writes an unsigned 8-bit integer (0255).
*/ */
public function writeUInt8(int $value): int 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 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 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 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 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'); 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));
} }
} }

View file

@ -7,56 +7,21 @@ namespace Shufflingpixels\IO;
use InvalidArgumentException; use InvalidArgumentException;
use OutOfBoundsException; 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 * 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. * 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 int $position = 0;
private bool $detached = false;
public function __construct(protected string $data) 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. * Returns the total byte length of the buffer.
*/ */
@ -65,58 +30,29 @@ class Buffer implements StreamInterface
return \strlen($this->data); 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. * Returns true when the cursor is at or past the end of the buffer.
*/ */
public function eof(): bool public function eof(): bool
{ {
return $this->remaining() === 0; return $this->position >= $this->length();
} }
/** /** Returns the current byte offset of the cursor. */
* Returns the current byte offset of the cursor.
*
* @throws \RuntimeException if the stream is detached
*/
public function tell() : int public function tell() : int
{ {
$this->ensureAttached();
return $this->position; return $this->position;
} }
/**
* Always returns true buffers are always seekable.
*/
public function isSeekable(): bool
{
return true;
}
/** /**
* Moves the cursor to the given position. * Moves the cursor to the given position.
* *
* @param int $whence SEEK_SET, SEEK_CUR, or SEEK_END * @param int $whence SEEK_SET, SEEK_CUR, or SEEK_END
* @throws \InvalidArgumentException for an unrecognised $whence value * @throws \InvalidArgumentException for an unrecognised $whence value
* @throws \OutOfBoundsException if the resolved position is outside [0, length] * @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 public function seek(int $offset, int $whence = SEEK_SET): void
{ {
$this->ensureAttached();
$position = match($whence) { $position = match($whence) {
SEEK_SET => $offset, SEEK_SET => $offset,
SEEK_CUR => $this->position + $offset, SEEK_CUR => $this->position + $offset,
@ -131,68 +67,37 @@ class Buffer implements StreamInterface
$this->position = $position; $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. * 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 \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) { if ($length < 0) {
throw new InvalidArgumentException("Length must be >= 0"); throw new InvalidArgumentException("Length must be >= 0");
} }
if ($length === 0 || $this->eof()) { if ($this->eof()) {
return ''; return false;
} }
$result = substr($this->data, $this->position, min($length, $this->remaining())); $result = substr($this->data, $this->position, $length);
$this->position += strlen($result); $this->position += \strlen($result);
return $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. * Writes $string at the current cursor position, overwriting existing bytes.
* *
* If the write extends past the end of the buffer, the buffer grows accordingly. * If the write extends past the end of the buffer, the buffer grows accordingly.
* Returns the number of bytes written. * Returns the number of bytes written.
*
* @throws \RuntimeException if the stream is detached
*/ */
public function write(string $string): int public function write(string $string): int
{ {
$this->ensureAttached();
$length = \strlen($string); $length = \strlen($string);
if ($length === 0) { if ($length === 0) {
@ -205,67 +110,9 @@ class Buffer implements StreamInterface
? \substr($this->data, $suffixStart) ? \substr($this->data, $suffixStart)
: ''; : '';
$this->data = $prefix . $string . $suffix; $this->data = "{$prefix}{$string}{$suffix}";
$this->position += $length; $this->position += $length;
return $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');
}
}
} }

View file

@ -7,9 +7,7 @@ namespace Shufflingpixels\IO;
use Shufflingpixels\IO\Exception\IOException; use Shufflingpixels\IO\Exception\IOException;
/** /** A file-backed stream opened via {@see FileMode}. */
* A PSR-7 stream backed by a file on disk, opened via {@see FileMode}.
*/
class File extends Resource class File extends Resource
{ {
/** /**
@ -31,11 +29,6 @@ class File extends Resource
throw new IOException("Unable to open file"); throw new IOException("Unable to open file");
} }
return new self( return new self($fd);
$fd,
$mode->seekable(),
$mode->readable(),
$mode->writable()
);
} }
} }

View file

@ -17,19 +17,4 @@ enum FileMode : string
case READ = 'r'; case READ = 'r';
case WRITE = 'w'; case WRITE = 'w';
case RW = 'r+'; 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;
}
} }

51
src/LimitedReader.php Normal file
View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Shufflingpixels\IO;
use InvalidArgumentException;
/**
* A read-only byte-count-limited wrapper around another reader.
*
* Forwards reads to the underlying reader but clamps them to a fixed number
* of bytes, making it possible to hand a section of a sequential stream to a
* consumer without the consumer reading past the end of that section.
*/
class LimitedReader implements ReaderInterface
{
/**
* @param ReaderInterface $r The underlying reader to read from.
* @param int $remaining Maximum number of bytes that may be read.
*/
public function __construct(
private ReaderInterface $r,
private int $remaining,
) {
}
/**
* Reads up to $length bytes, clamped to the remaining byte budget.
*
* Returns false when the byte budget is exhausted.
*
* @throws \InvalidArgumentException if $length is negative
*/
public function read(int $length): string|false
{
if ($length < 0) {
throw new InvalidArgumentException('Length must be >= 0');
}
if ($this->remaining <= 0) {
return false;
}
$data = $this->r->read(min($length, $this->remaining));
$this->remaining -= \strlen($data);
return $data;
}
}

View file

@ -1,248 +0,0 @@
<?php
declare(strict_types=1);
namespace Shufflingpixels\IO;
use InvalidArgumentException;
use OutOfBoundsException;
use Psr\Http\Message\StreamInterface;
use RuntimeException;
use Shufflingpixels\IO\Exception\IOException;
/**
* A read-only PSR-7 stream window over a slice of another seekable stream.
*
* Presents the bytes [$start, $start + $length) of the underlying stream as an
* independent stream with its own cursor starting at offset 0. The underlying
* stream is seeked on every read, so LimitedResource instances over the same
* base stream can be used independently without interfering with each other.
*/
class LimitedResource implements StreamInterface
{
private int $position = 0;
private bool $detached = false;
/**
* @param StreamInterface $stream The underlying stream to read from. Must be seekable.
* @param int $start Byte offset in $stream where this window begins.
* @param int $length Number of bytes this window exposes.
* @throws IOException if $stream is not seekable, or $start or $length are negative
*/
public function __construct(
private StreamInterface $stream,
private int $start,
private int $length
) {
if (!$stream->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');
}
}
}

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Shufflingpixels\IO;
/** A readable, seekable byte stream. */
interface ReadSeekerInterface extends ReaderInterface, SeekerInterface
{
}

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Shufflingpixels\IO;
/** A readable, writable, and seekable byte stream. */
interface ReadWriteSeekerInterface extends ReadSeekerInterface, WriteSeekerInterface
{
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Shufflingpixels\IO;
/** A readable and writable byte stream. */
interface ReadWriterInterface extends ReaderInterface, WriterInterface
{
}

16
src/ReaderInterface.php Normal file
View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Shufflingpixels\IO;
/** Describes a source of bytes that can be read sequentially. */
interface ReaderInterface
{
/**
* Reads up to $length bytes, returning fewer at end of stream.
*
* Returns false when no more bytes are available.
*/
public function read(int $length): string|false;
}

View file

@ -5,27 +5,19 @@ declare(strict_types=1);
namespace Shufflingpixels\IO; namespace Shufflingpixels\IO;
use Psr\Http\Message\StreamInterface;
use Shufflingpixels\IO\Exception\IOException; use Shufflingpixels\IO\Exception\IOException;
/** /**
* Base PSR-7 stream implementation wrapping a PHP file resource. * A ReadWriteSeekerInterface implementation wrapping a PHP file resource.
*
* Concrete subclasses supply the resource and declare which capabilities
* (seekable, readable, writable) are available for their use-case.
*/ */
abstract class Resource implements StreamInterface class Resource implements ReadWriteSeekerInterface
{ {
protected ?int $size = null; protected int $size = -1;
/** /**
* @param resource $resource * @param resource $resource
*/ */
protected function __construct( protected function __construct(protected mixed $resource)
protected mixed $resource,
protected bool $seekable,
protected bool $readable,
protected bool $writable)
{ {
} }
@ -52,17 +44,13 @@ abstract class Resource implements StreamInterface
} }
/** /**
* Returns the byte size of the stream, or null for non-seekable streams. * Returns the total byte length of the stream.
* *
* The result is cached after the first call and invalidated by any write. * The result is cached after the first call and invalidated by any write.
*/ */
public function getSize(): ?int public function length(): int
{ {
if (!$this->isSeekable()) { if ($this->size < 0) {
return null;
}
if ($this->size === null) {
$pos = $this->tell(); $pos = $this->tell();
fseek($this->resource, 0, SEEK_END); fseek($this->resource, 0, SEEK_END);
@ -81,14 +69,6 @@ abstract class Resource implements StreamInterface
return feof($this->resource); 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. * 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. * Moves the file cursor to the given position.
* *
* @param int $whence SEEK_SET, SEEK_CUR, or SEEK_END * @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 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) { if (fseek($this->resource, $position, $whence) < 0) {
throw new IOException("Unable to seek to the given position"); 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. * 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;
}
/** $result = fread($this->resource, $length);
* Returns whether this stream supports writing.
*/ return ($result === '' || $result === false) ? false : $result;
public function isWritable(): bool
{
return $this->writable;
} }
/** /**
* Writes $string at the current cursor position and returns the bytes written. * 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 public function write(string $string): int
{ {
if (!$this->writable) { set_error_handler(static fn () => true);
throw new IOException("Stream is not writeable");
try {
$result = fwrite($this->resource, $string);
} finally {
restore_error_handler();
} }
$result = fwrite($this->resource, $string);
if ($result === false) { if ($result === false) {
throw new IOException("Failed to write to stream"); throw new IOException("Failed to write to stream");
} }
$this->size = null; // invalidate cached length
$this->size = -1;
return $result; 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();
}
} }

28
src/SeekerInterface.php Normal file
View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Shufflingpixels\IO;
/**
* Describes a seekable byte stream that can report and change its cursor position.
*/
interface SeekerInterface
{
/**
* Moves the cursor to the given position.
*
* @param int $whence SEEK_SET, SEEK_CUR, or SEEK_END
* @throws \InvalidArgumentException for an unrecognized $whence value
*/
public function seek(int $offset, int $whence = SEEK_SET): void;
/** Returns the current byte offset of the cursor. */
public function tell(): int;
/** Returns true when the cursor is at the end of the stream. */
public function eof(): bool;
/** Returns the total byte length of the stream. */
public function length(): int;
}

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Shufflingpixels\IO;
/** A writable, seekable byte stream. */
interface WriteSeekerInterface extends WriterInterface, SeekerInterface
{
}

13
src/WriterInterface.php Normal file
View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Shufflingpixels\IO;
/** Describes a sink of bytes that can be written to sequentially. */
interface WriterInterface
{
/** Writes $data and returns the number of bytes written. */
public function write(string $data): int;
}

View file

@ -2,51 +2,32 @@
use Shufflingpixels\IO\BinaryReader; use Shufflingpixels\IO\BinaryReader;
use Shufflingpixels\IO\Buffer; use Shufflingpixels\IO\Buffer;
use Shufflingpixels\IO\ReaderInterface;
it('proxies length tell eof and seek', function () {
$reader = new BinaryReader(new Buffer('abcd'));
expect($reader->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();
});
it('throws for negative read length', function () { it('throws for negative read length', function () {
$reader = new BinaryReader(new Buffer('abc')); $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 () { it('throws when stream returns fewer bytes than requested', function () {
$stream = new class implements \Psr\Http\Message\StreamInterface { $stream = new class implements ReaderInterface {
public function __toString(): string { return ''; } public function read(int $length): string|false { return 'x'; }
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; }
}; };
$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 () { 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); $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 () { it('throws when not enough bytes remain for readPaddedString', function () {

View file

@ -1,5 +1,6 @@
<?php <?php
use Shufflingpixels\IO\BinaryReader;
use Shufflingpixels\IO\BinaryWriter; use Shufflingpixels\IO\BinaryWriter;
use Shufflingpixels\IO\Buffer; use Shufflingpixels\IO\Buffer;
@ -9,25 +10,14 @@ function writer(): array
return [new BinaryWriter($buffer), $buffer]; return [new BinaryWriter($buffer), $buffer];
} }
it('proxies tell and seek', function () { function bufferContents(Buffer $buffer): string
[$writer, $buffer] = writer(); {
$pos = $buffer->tell();
expect($writer->tell())->toBe(0); $buffer->seek(0);
$result = $buffer->read($buffer->length());
$writer->write('abc'); $buffer->seek($pos);
expect($writer->tell())->toBe(3); return $result;
}
$writer->seek(1);
expect($writer->tell())->toBe(1);
});
it('writes raw bytes', function () {
[$writer, $buffer] = writer();
$writer->write('hello');
expect((string) $buffer)->toBe('hello');
});
it('writes 8 bit integers', function () { it('writes 8 bit integers', function () {
[$writer, $buffer] = writer(); [$writer, $buffer] = writer();
@ -37,7 +27,7 @@ it('writes 8 bit integers', function () {
$writer->writeInt8(-1); $writer->writeInt8(-1);
$writer->writeInt8(-128); $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 () { 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->writeInt16LE(-1);
$writer->writeInt16BE(-32768); $writer->writeInt16BE(-32768);
expect((string) $buffer)->toBe( expect(bufferContents($buffer))->toBe(
pack('v', 0x1234) . pack('n', 0x5678) . pack('v', 0xffff) . pack('n', 0x8000) 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->writeInt32LE(-1);
$writer->writeInt32BE(-2147483648); $writer->writeInt32BE(-2147483648);
expect((string) $buffer)->toBe( expect(bufferContents($buffer))->toBe(
pack('V', 0x12345678) . pack('N', 0x10203040) . pack('V', 0xffffffff) . pack('N', 0x80000000) 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); $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 () { 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); $writer->writePaddedString('hello', 5);
expect((string) $buffer)->toBe('hello'); expect(bufferContents($buffer))->toBe('hello');
}); });
it('truncates a string longer than the field length', function () { 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); $writer->writePaddedString('toolong', 4);
expect((string) $buffer)->toBe('tool'); expect(bufferContents($buffer))->toBe('tool');
}); });
it('uses a custom pad character', function () { it('uses a custom pad character', function () {
@ -95,7 +85,7 @@ it('uses a custom pad character', function () {
$writer->writePaddedString('hi', 5, ' '); $writer->writePaddedString('hi', 5, ' ');
expect((string) $buffer)->toBe('hi '); expect(bufferContents($buffer))->toBe('hi ');
}); });
it('throws for a multi-byte pad character', function () { 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->writeInt16LE(-300);
$writer->writeUInt32BE(0xdeadbeef); $writer->writeUInt32BE(0xdeadbeef);
$buffer->rewind(); $buffer->seek(0);
$reader = new \Shufflingpixels\IO\BinaryReader($buffer); $reader = new BinaryReader($buffer);
expect($reader->readUInt8())->toBe(42) expect($reader->readUInt8())->toBe(42)
->and($reader->readInt16LE())->toBe(-300) ->and($reader->readInt16LE())->toBe(-300)

View file

@ -5,14 +5,12 @@ use Shufflingpixels\IO\Buffer;
it('reads, seeks and tracks position', function () { it('reads, seeks and tracks position', function () {
$buffer = new Buffer('abcdef'); $buffer = new Buffer('abcdef');
expect($buffer->getSize())->toBe(6) expect($buffer->length())->toBe(6)
->and($buffer->tell())->toBe(0) ->and($buffer->tell())->toBe(0)
->and($buffer->remaining())->toBe(6)
->and($buffer->eof())->toBeFalse(); ->and($buffer->eof())->toBeFalse();
expect($buffer->read(2))->toBe('ab') expect($buffer->read(2))->toBe('ab')
->and($buffer->tell())->toBe(2) ->and($buffer->tell())->toBe(2);
->and($buffer->remaining())->toBe(4);
$buffer->seek(-1, SEEK_END); $buffer->seek(-1, SEEK_END);
@ -50,6 +48,13 @@ it('returns available bytes when reading beyond end of stream', function () {
->and($buffer->eof())->toBeTrue(); ->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 () { it('writes at current position and updates contents', function () {
$buffer = new Buffer('abcdef'); $buffer = new Buffer('abcdef');
$buffer->seek(2); $buffer->seek(2);
@ -67,14 +72,15 @@ it('writes nothing for empty payload', function () {
expect($buffer->write(''))->toBe(0) expect($buffer->write(''))->toBe(0)
->and($buffer->tell())->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 = new Buffer('abcdef');
$buffer->seek(2); $buffer->seek(2);
expect($buffer->getContents())->toBe('cdef') $remaining = $buffer->read($buffer->length() - $buffer->tell());
->and($buffer->tell())->toBe(6)
->and($buffer->getContents())->toBe(''); expect($remaining)->toBe('cdef')
->and($buffer->eof())->toBeTrue();
}); });

View file

@ -7,13 +7,3 @@ it('defines expected fopen mode values', function () {
->and(FileMode::WRITE->value)->toBe('w') ->and(FileMode::WRITE->value)->toBe('w')
->and(FileMode::RW->value)->toBe('r+'); ->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();
});

View file

@ -4,31 +4,27 @@ use Shufflingpixels\IO\Exception\IOException;
use Shufflingpixels\IO\File; use Shufflingpixels\IO\File;
use Shufflingpixels\IO\FileMode; 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-'); $path = tempnam(sys_get_temp_dir(), 'php-io-');
file_put_contents($path, 'hello'); file_put_contents($path, 'hello');
$file = File::open($path, FileMode::READ); $file = File::open($path, FileMode::READ);
expect($file->isReadable())->toBeTrue() expect($file->length())->toBe(5)
->and($file->isWritable())->toBeFalse()
->and($file->getSize())->toBe(5)
->and($file->read(5))->toBe('hello'); ->and($file->read(5))->toBe('hello');
$file->close(); $file->close();
unlink($path); 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-'); $path = tempnam(sys_get_temp_dir(), 'php-io-');
file_put_contents($path, 'abc'); file_put_contents($path, 'abc');
$file = File::open($path, FileMode::RW); $file = File::open($path, FileMode::RW);
$file->seek(0); $file->seek(0);
expect($file->isReadable())->toBeTrue() expect($file->write('X'))->toBe(1);
->and($file->isWritable())->toBeTrue()
->and($file->write('X'))->toBe(1);
$file->seek(0); $file->seek(0);
expect($file->read(3))->toBe('Xbc'); expect($file->read(3))->toBe('Xbc');
@ -37,15 +33,13 @@ it('opens read-write files and persists writes', function () {
unlink($path); 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-'); $path = tempnam(sys_get_temp_dir(), 'php-io-');
file_put_contents($path, 'abcdef'); file_put_contents($path, 'abcdef');
$file = File::open($path, FileMode::WRITE); $file = File::open($path, FileMode::WRITE);
expect($file->isReadable())->toBeFalse() expect($file->length())->toBe(0)
->and($file->isWritable())->toBeTrue()
->and($file->getSize())->toBe(0)
->and($file->write('xy'))->toBe(2); ->and($file->write('xy'))->toBe(2);
$file->close(); $file->close();
@ -53,7 +47,19 @@ it('opens write mode files and truncates existing contents', function () {
unlink($path); 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'; $path = sys_get_temp_dir() . '/php-io-missing-dir/' . uniqid('', true) . '.txt';
expect(fn () => File::open($path, FileMode::READ))->toThrow(IOException::class); expect(fn () => File::open($path, FileMode::READ))->toThrow(IOException::class);

View file

@ -1,334 +0,0 @@
<?php
use Shufflingpixels\IO\BinaryReader;
use Shufflingpixels\IO\Buffer;
use Shufflingpixels\IO\Exception\IOException;
use Shufflingpixels\IO\LimitedResource;
// --- Constructor validation ---
it('throws IOException when underlying stream is not seekable', function () {
$stream = new class implements \Psr\Http\Message\StreamInterface {
public function isSeekable(): bool { return false; }
public function close(): void {}
public function detach(): mixed { return null; }
public function getSize(): ?int { return null; }
public function eof(): bool { return true; }
public function tell(): int { return 0; }
public function seek(int $offset, int $whence = SEEK_SET): void {}
public function rewind(): void {}
public function isReadable(): bool { return false; }
public function read(int $length): string { return ''; }
public function isWritable(): bool { return false; }
public function write(string $string): int { return 0; }
public function getContents(): string { return ''; }
public function getMetadata(?string $key = null): mixed { return null; }
public function __toString(): string { return ''; }
};
expect(fn() => 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();
});

View file

@ -3,66 +3,90 @@
use Shufflingpixels\IO\Exception\IOException; use Shufflingpixels\IO\Exception\IOException;
use Shufflingpixels\IO\Resource; use Shufflingpixels\IO\Resource;
it('reads, writes, seeks and reports stream state', function () { function makeResource(string $mode = 'r+', string $initial = ''): Resource
$handle = fopen('php://temp', 'r+'); {
fwrite($handle, 'hello'); $handle = fopen('php://temp', $mode);
rewind($handle); if ($initial !== '') {
fwrite($handle, $initial);
rewind($handle);
}
$stream = new class($handle, true, true, true) extends Resource { return new class($handle) extends Resource {
public function __construct($resource, bool $seekable, bool $readable, bool $writeable) public function __construct(mixed $resource) { parent::__construct($resource); }
{
parent::__construct($resource, $seekable, $readable, $writeable);
}
}; };
}
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->tell())->toBe(0)
->and($stream->isSeekable())->toBeTrue()
->and($stream->isReadable())->toBeTrue()
->and($stream->isWritable())->toBeTrue()
->and($stream->read(2))->toBe('he'); ->and($stream->read(2))->toBe('he');
$stream->seek(0); $stream->seek(0);
expect($stream->write('H'))->toBe(1); expect($stream->write('H'))->toBe(1);
$stream->seek(0); $stream->seek(0);
expect($stream->read(5))->toBe('Hello') expect($stream->read(5))->toBe('Hello');
->and($stream->getSize())->toBe(5) });
->and($stream->eof())->toBeFalse();
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(); expect($stream->eof())->toBeTrue();
$stream->close();
}); });
it('throws when seeking or getting length on non-seekable stream', function () { it('returns false when reading at end of stream', function () {
$handle = fopen('php://temp', 'r+'); $stream = makeResource('r+', 'ab');
$stream->read(3);
$stream = new class($handle, false, true, false) extends Resource { expect($stream->read(1))->toBeFalse();
public function __construct($resource, bool $seekable, bool $readable, bool $writeable) });
{
parent::__construct($resource, $seekable, $readable, $writeable); 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() expect(fn () => $stream->write('x'))->toThrow(IOException::class);
->and(fn () => $stream->seek(0))->toThrow(IOException::class);
$stream->close();
}); });
it('throws when writing to a non-writeable stream', function () { it('close releases the resource', function () {
$handle = fopen('php://temp', 'r+'); $stream = makeResource('r+', 'abc');
$stream->close();
$stream = new class($handle, true, true, false) extends Resource { expect(fn () => $stream->read(1))->toThrow(\TypeError::class);
public function __construct($resource, bool $seekable, bool $readable, bool $writeable) });
{
parent::__construct($resource, $seekable, $readable, $writeable); 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);
}); });