diff --git a/src/BinaryReader.php b/src/BinaryReader.php index 45461a6..eb7a558 100644 --- a/src/BinaryReader.php +++ b/src/BinaryReader.php @@ -9,12 +9,23 @@ use InvalidArgumentException; use Psr\Http\Message\StreamInterface; use RuntimeException; +/** + * Reads primitive binary types from a PSR-7 stream. + * + * 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) { } + /** + * 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(); @@ -25,24 +36,37 @@ class BinaryReader 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); } /** - * @throws \InvalidArgumentException - * @throws \RuntimeException + * 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 { @@ -60,11 +84,17 @@ class BinaryReader return $data; } + /** + * Reads an unsigned 8-bit integer (0–255). + */ public function readUInt8(): int { return \ord($this->read(1)); } + /** + * Reads a signed 8-bit integer (-128–127). + */ public function readInt8(): int { $value = $this->readUInt8(); @@ -72,16 +102,25 @@ class BinaryReader return $value >= 0x80 ? $value - 0x100 : $value; } + /** + * Reads an unsigned 16-bit little-endian integer. + */ public function readUInt16LE(): int { return \unpack('v', $this->read(2))[1]; } + /** + * Reads an unsigned 16-bit big-endian integer. + */ public function readUInt16BE(): int { return \unpack('n', $this->read(2))[1]; } + /** + * Reads a signed 16-bit little-endian integer. + */ public function readInt16LE(): int { $value = $this->readUInt16LE(); @@ -89,6 +128,9 @@ class BinaryReader return $value >= 0x8000 ? $value - 0x1_0000 : $value; } + /** + * Reads a signed 16-bit big-endian integer. + */ public function readInt16BE(): int { $value = $this->readUInt16BE(); @@ -96,16 +138,25 @@ class BinaryReader return $value >= 0x8000 ? $value - 0x1_0000 : $value; } + /** + * Reads an unsigned 32-bit little-endian integer. + */ public function readUInt32LE(): int { return \unpack('V', $this->read(4))[1]; } + /** + * Reads an unsigned 32-bit big-endian integer. + */ public function readUInt32BE(): int { return \unpack('N', $this->read(4))[1]; } + /** + * Reads a signed 32-bit little-endian integer. + */ public function readInt32LE(): int { $value = $this->readUInt32LE(); @@ -113,6 +164,9 @@ class BinaryReader return $value >= 0x8000_0000 ? $value - 0x1_0000_0000 : $value; } + /** + * Reads a signed 32-bit big-endian integer. + */ public function readInt32BE(): int { $value = $this->readUInt32BE(); @@ -121,7 +175,9 @@ class BinaryReader } /** - * Reads a string of fixed length, trimming any padding characters from the end. + * Reads exactly $length bytes and strips trailing $pad_chars from the result. + * + * The cursor always advances by $length bytes regardless of how much padding is trimmed. */ public function readPaddedString(int $length, string $pad_chars = "\x00") : string { diff --git a/src/BinaryWriter.php b/src/BinaryWriter.php index 3e1de07..a90c668 100644 --- a/src/BinaryWriter.php +++ b/src/BinaryWriter.php @@ -8,79 +8,133 @@ namespace Shufflingpixels\IO; use InvalidArgumentException; use Psr\Http\Message\StreamInterface; +/** + * Writes primitive binary types to a PSR-7 stream. + * + * 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. + */ class BinaryWriter { public function __construct(protected StreamInterface $stream) { } + /** + * 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)); } + /** + * Writes a signed 8-bit integer (-128–127). + */ public function writeInt8(int $value): int { return $this->writeUInt8($value < 0 ? $value + 0x100 : $value); } + /** + * Writes an unsigned 16-bit little-endian integer. + */ public function writeUInt16LE(int $value): int { return $this->write(\pack('v', $value)); } + /** + * Writes an unsigned 16-bit big-endian integer. + */ public function writeUInt16BE(int $value): int { return $this->write(\pack('n', $value)); } + /** + * Writes a signed 16-bit little-endian integer. + */ public function writeInt16LE(int $value): int { return $this->writeUInt16LE($value < 0 ? $value + 0x1_0000 : $value); } + /** + * Writes a signed 16-bit big-endian integer. + */ public function writeInt16BE(int $value): int { return $this->writeUInt16BE($value < 0 ? $value + 0x1_0000 : $value); } + /** + * Writes an unsigned 32-bit little-endian integer. + */ public function writeUInt32LE(int $value): int { return $this->write(\pack('V', $value)); } + /** + * Writes an unsigned 32-bit big-endian integer. + */ public function writeUInt32BE(int $value): int { return $this->write(\pack('N', $value)); } + /** + * Writes a signed 32-bit little-endian integer. + */ public function writeInt32LE(int $value): int { return $this->writeUInt32LE($value < 0 ? $value + 0x1_0000_0000 : $value); } + /** + * Writes a signed 32-bit big-endian integer. + */ public function writeInt32BE(int $value): int { return $this->writeUInt32BE($value < 0 ? $value + 0x1_0000_0000 : $value); } /** - * Writes a string padded or truncated to exactly $length bytes. + * Writes $data padded or truncated to exactly $length bytes. + * + * Strings shorter than $length are right-padded with $pad_char. + * Strings longer than $length are truncated to $length bytes. + * + * @throws \InvalidArgumentException if $pad_char is not exactly one byte */ public function writePaddedString(string $data, int $length, string $pad_char = "\x00"): int { diff --git a/src/Buffer.php b/src/Buffer.php index cc8c4dd..f97505c 100644 --- a/src/Buffer.php +++ b/src/Buffer.php @@ -10,6 +10,12 @@ use OutOfBoundsException; use Psr\Http\Message\StreamInterface; use RuntimeException; +/** + * An in-memory PSR-7 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 { private int $position = 0; @@ -19,11 +25,17 @@ class Buffer implements StreamInterface { } + /** + * 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) { @@ -37,16 +49,25 @@ class Buffer implements StreamInterface 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. + */ public function length(): int { return \strlen($this->data); } + /** + * Returns the number of bytes between the current cursor position and the end. + */ public function remaining() : int { if ($this->detached) { @@ -56,11 +77,19 @@ class Buffer implements StreamInterface 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; } + /** + * Returns the current byte offset of the cursor. + * + * @throws \RuntimeException if the stream is detached + */ public function tell() : int { $this->ensureAttached(); @@ -68,11 +97,22 @@ class Buffer implements StreamInterface 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(); @@ -91,16 +131,30 @@ 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. + * + * @throws \InvalidArgumentException if $length is negative + * @throws \RuntimeException if the stream is detached + */ public function read(int $length): string { $this->ensureAttached(); @@ -119,16 +173,30 @@ class Buffer implements StreamInterface return $result; } + /** + * Always returns true — buffers are always writable. + */ public function isWritable(): bool { return true; } + /** + * Alias for {@see isWritable()}. + */ public function isWriteable(): bool { return $this->isWritable(); } + /** + * 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(); @@ -151,6 +219,11 @@ class Buffer implements StreamInterface 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(); @@ -165,6 +238,11 @@ class Buffer implements StreamInterface 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 = [ @@ -177,6 +255,9 @@ class Buffer implements StreamInterface 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) { @@ -186,6 +267,9 @@ class Buffer implements StreamInterface return $this->data; } + /** + * @throws \RuntimeException + */ private function ensureAttached(): void { if ($this->detached) { diff --git a/src/Exception/EndOfStreamException.php b/src/Exception/EndOfStreamException.php index e4b4025..6cefe60 100644 --- a/src/Exception/EndOfStreamException.php +++ b/src/Exception/EndOfStreamException.php @@ -5,6 +5,9 @@ declare(strict_types=1); namespace Shufflingpixels\IO\Exception; +/** + * Thrown when a read is attempted past the end of a stream. + */ class EndOfStreamException extends IOException { } diff --git a/src/Exception/IOException.php b/src/Exception/IOException.php index 80f71e9..8d41f12 100644 --- a/src/Exception/IOException.php +++ b/src/Exception/IOException.php @@ -5,6 +5,9 @@ declare(strict_types=1); namespace Shufflingpixels\IO\Exception; +/** + * Thrown when a stream or file operation fails. + */ class IOException extends \Exception { } diff --git a/src/File.php b/src/File.php index f03cfad..db59fcb 100644 --- a/src/File.php +++ b/src/File.php @@ -7,10 +7,13 @@ namespace Shufflingpixels\IO; use Shufflingpixels\IO\Exception\IOException; +/** A PSR-7 stream backed by a file on disk, opened via {@see FileMode}. */ class File extends Resource { /** - * @throws IOException + * Opens a file and returns a stream for it. + * + * @throws IOException if the file cannot be opened */ public static function open(string $filename, FileMode $mode = FileMode::RW) : self { diff --git a/src/FileMode.php b/src/FileMode.php index 9c254d7..29b41cc 100644 --- a/src/FileMode.php +++ b/src/FileMode.php @@ -5,23 +5,30 @@ declare(strict_types=1); namespace Shufflingpixels\IO; +/** + * File open modes passed to {@see File::open()}. + * + * READ — read-only, file must exist. + * WRITE — write-only, truncates or creates the file. + * RW — read/write, file must exist. + */ enum FileMode : string { case READ = 'r'; case WRITE = 'w'; case RW = 'r+'; - public function seekable() + public function seekable(): bool { return true; } - public function readable() + public function readable(): bool { return $this === self::READ || $this === self::RW; } - public function writeable() + public function writeable(): bool { return $this === self::WRITE || $this === self::RW; } diff --git a/src/LimitedResource.php b/src/LimitedResource.php index a230454..1a99c78 100644 --- a/src/LimitedResource.php +++ b/src/LimitedResource.php @@ -12,11 +12,25 @@ 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, @@ -33,11 +47,17 @@ class LimitedResource implements StreamInterface } } + /** + * 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) { @@ -50,16 +70,27 @@ class LimitedResource implements StreamInterface 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(); @@ -67,11 +98,22 @@ class LimitedResource implements StreamInterface 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(); @@ -90,16 +132,31 @@ class LimitedResource implements StreamInterface $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(); @@ -120,16 +177,27 @@ class LimitedResource implements StreamInterface 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(); @@ -141,11 +209,19 @@ class LimitedResource implements StreamInterface 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) { @@ -160,6 +236,9 @@ class LimitedResource implements StreamInterface } } + /** + * @throws \RuntimeException + */ private function ensureAttached(): void { if ($this->detached) { diff --git a/src/Resource.php b/src/Resource.php index 5ca143b..0f49c76 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -8,6 +8,12 @@ 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. + */ abstract class Resource implements StreamInterface { protected ?int $size = null; @@ -23,6 +29,9 @@ abstract class Resource implements StreamInterface { } + /** + * Closes the underlying file resource. Subsequent calls are no-ops. + */ public function close(): void { if ($this->resource !== null) { @@ -31,6 +40,9 @@ abstract class Resource implements StreamInterface } } + /** + * Detaches and returns the underlying file resource, leaving the stream unusable. + */ public function detach(): mixed { $resource = $this->resource; @@ -39,6 +51,11 @@ abstract class Resource implements StreamInterface return $resource; } + /** + * Returns the byte size of the stream, or null for non-seekable streams. + * + * The result is cached after the first call and invalidated by any write. + */ public function getSize(): ?int { if (!$this->isSeekable()) { @@ -56,21 +73,36 @@ abstract class Resource implements StreamInterface return $this->size; } + /** + * Returns true when the underlying resource is at end-of-file. + */ public function eof(): bool { 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. + */ public function tell() : int { return ftell($this->resource); } + /** + * 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 + */ public function seek(int $position, int $whence = SEEK_SET): void { if (!$this->isSeekable()) { @@ -82,26 +114,45 @@ abstract class Resource implements StreamInterface } } + /** + * Returns whether this stream supports reading. + */ public function isReadable(): bool { return $this->readable; } + /** + * Reads up to $length bytes from the current cursor position. + */ public function read(int $length): string { return fread($this->resource, $length); } + /** + * Returns whether this stream supports writing. + */ public function isWritable(): bool { return $this->writeable; } + /** + * Alias for {@see isWritable()}. + */ public function isWriteable(): bool { return $this->isWritable(); } + /** + * Writes $string at the current cursor position and returns the bytes written. + * + * Invalidates the cached size so {@see getSize()} reflects the new length. + * + * @throws IOException if the stream is not writable or the write fails + */ public function write(string $string): int { if (!$this->writeable) { @@ -117,16 +168,27 @@ abstract class Resource implements StreamInterface 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); @@ -134,6 +196,9 @@ abstract class Resource implements StreamInterface return $key !== null ? $data[$key] ?? null : $data; } + /** + * Returns all remaining stream contents as a string. + */ public function __toString(): string { return $this->getContents();