mirror of
https://github.com/shufflingpixels/php-io.git
synced 2026-10-19 14:36:46 +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
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue