mirror of
https://github.com/shufflingpixels/php-io.git
synced 2026-08-15 03:38:12 +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:
parent
a27c0a87ea
commit
07ec405512
25 changed files with 451 additions and 1168 deletions
124
README.md
124
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
|
||||
<?php
|
||||
|
||||
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
|
||||
$b = $reader->readInt8(); // -128
|
||||
$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
|
||||
|
||||
use Shufflingpixels\IO\Buffer;
|
||||
use Shufflingpixels\IO\SeekMode;
|
||||
|
||||
$buffer = new Buffer('abcdef');
|
||||
|
||||
$buffer->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
|
||||
<?php
|
||||
|
||||
use Shufflingpixels\IO\BinaryReader;
|
||||
use Shufflingpixels\IO\Buffer;
|
||||
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 | 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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@
|
|||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"psr/http-message": "^2.0"
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^3.0"
|
||||
|
|
|
|||
60
composer.lock
generated
60
composer.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
177
src/Buffer.php
177
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
src/File.php
11
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
src/LimitedReader.php
Normal file
51
src/LimitedReader.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/ReadSeekerInterface.php
Normal file
10
src/ReadSeekerInterface.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shufflingpixels\IO;
|
||||
|
||||
/** A readable, seekable byte stream. */
|
||||
interface ReadSeekerInterface extends ReaderInterface, SeekerInterface
|
||||
{
|
||||
}
|
||||
10
src/ReadWriteSeekerInterface.php
Normal file
10
src/ReadWriteSeekerInterface.php
Normal 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
|
||||
{
|
||||
}
|
||||
11
src/ReadWriterInterface.php
Normal file
11
src/ReadWriterInterface.php
Normal 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
16
src/ReaderInterface.php
Normal 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;
|
||||
}
|
||||
119
src/Resource.php
119
src/Resource.php
|
|
@ -5,27 +5,19 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shufflingpixels\IO;
|
||||
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
use Shufflingpixels\IO\Exception\IOException;
|
||||
|
||||
/**
|
||||
* Base PSR-7 stream implementation wrapping a PHP file resource.
|
||||
*
|
||||
* Concrete subclasses supply the resource and declare which capabilities
|
||||
* (seekable, readable, writable) are available for their use-case.
|
||||
* A ReadWriteSeekerInterface implementation wrapping a PHP file resource.
|
||||
*/
|
||||
abstract class Resource implements StreamInterface
|
||||
class Resource implements ReadWriteSeekerInterface
|
||||
{
|
||||
protected ?int $size = null;
|
||||
protected int $size = -1;
|
||||
|
||||
/**
|
||||
* @param resource $resource
|
||||
*/
|
||||
protected function __construct(
|
||||
protected mixed $resource,
|
||||
protected bool $seekable,
|
||||
protected bool $readable,
|
||||
protected bool $writable)
|
||||
protected function __construct(protected mixed $resource)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -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.
|
||||
*/
|
||||
public function getSize(): ?int
|
||||
public function length(): int
|
||||
{
|
||||
if (!$this->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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
src/SeekerInterface.php
Normal file
28
src/SeekerInterface.php
Normal 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;
|
||||
}
|
||||
10
src/WriteSeekerInterface.php
Normal file
10
src/WriteSeekerInterface.php
Normal 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
13
src/WriterInterface.php
Normal 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;
|
||||
}
|
||||
|
|
@ -2,51 +2,32 @@
|
|||
|
||||
use Shufflingpixels\IO\BinaryReader;
|
||||
use Shufflingpixels\IO\Buffer;
|
||||
|
||||
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();
|
||||
});
|
||||
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 () {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use Shufflingpixels\IO\BinaryReader;
|
||||
use Shufflingpixels\IO\BinaryWriter;
|
||||
use Shufflingpixels\IO\Buffer;
|
||||
|
||||
|
|
@ -9,25 +10,14 @@ function writer(): array
|
|||
return [new BinaryWriter($buffer), $buffer];
|
||||
}
|
||||
|
||||
it('proxies tell and seek', function () {
|
||||
[$writer, $buffer] = writer();
|
||||
|
||||
expect($writer->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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue