diff --git a/.horde.yml b/.horde.yml index 1bcb1ca..4744f0b 100644 --- a/.horde.yml +++ b/.horde.yml @@ -27,7 +27,7 @@ license: uri: http://www.horde.org/licenses/lgpl21 dependencies: required: - php: ^7.4 || ^8 + php: ^8.1 composer: horde/exception: ^3 dev: @@ -40,4 +40,8 @@ dependencies: horde/util: ^3 keywords: - streams + - bytestream vendor: horde +quality: + phpstan: + level: 5 diff --git a/README.md b/README.md new file mode 100644 index 0000000..64bfffc --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# Horde\Stream + +A managed stream abstraction for PHP that provides type guarantees, +buffering strategies, lifecycle management and protocol-oriented +read operations. Part of the [Horde](https://www.horde.org/) project. + +## Why not PSR-7 StreamInterface? + +PSR-7 streams (`Psr\Http\Message\StreamInterface`) model HTTP message bodies: +a thin wrapper around a PHP resource with `read()`, `write()` and `seek()`. +They are the right tool for HTTP request/response transport. + +Horde\Stream targets a different problem space: **parsing and constructing +structured data over byte streams**, the kind of work that IMAP, SMTP, MIME, +and ManageSieve libraries do on every connection. This requires operations +that PSR-7 does not offer: + +- **Delimiter scanning** - read until `}`, `\r\n` or any multi-byte + delimiter without buffering the entire stream into a string first. +- **Positional search** - find the byte offset of a substring, forward or + reverse, without consuming data. +- **Peek without consume** - inspect upcoming bytes and rewind automatically. +- **UTF-8 character-level seeking and reading** - navigate by character count + rather than byte count for multibyte-safe protocol handling. +- **EOL auto-detection** - determine whether a stream uses LF or CRLF line + endings. +- **Polymorphic write** - `add()` accepts a string, a PHP resource or + another `StreamInterface`, copying data efficiently in chunks. +- **Automatic memory management** - `TempString` starts in a pure-PHP string + backend and transparently spills to `php://temp` when size exceeds a + configurable threshold. +- **Safe cloning and serialization** - clone a stream to get an independent + copy; serialize to persist both data and cursor position. + +A future PSR-7 adapter layer can bridge the two worlds when HTTP interop is +needed. See [doc/USAGE.md](doc/USAGE.md) for details and examples. + +## Installation + +```bash +composer require horde/stream +``` + +For the string-backed stream (`StringStream`), also install the stream wrapper: + +```bash +composer require horde/stream_wrapper +``` + +## Quick start + +```php +use Horde\Stream\Temp; + +$stream = new Temp(); +$stream->add("From: alice@example.com\r\nSubject: Hello\r\n\r\nBody text."); +$stream->rewind(); + +// Read header lines +while (!$stream->eof()) { + $line = $stream->getToChar("\r\n", all: false); + if ($line === '') { + break; // blank line = end of headers + } + echo $line, "\n"; +} + +// Read remaining body +echo $stream->substring(), "\n"; +``` + +## Stream implementations + +| Class | Backend | Use case | +|-------|---------|----------| +| `Temp` | `php://temp` | General-purpose buffering. Optional `maxMemory` parameter controls spill-to-disk threshold. | +| `Existing` | Caller-provided resource | Wrap a socket, file handle or any open PHP stream resource. | +| `StringStream` | `horde/stream_wrapper` | String-backed stream with full seek support. Requires `horde/stream_wrapper`. | +| `TempString` | String, spills to `php://temp` | Starts in fast string backend, transparently switches to temp stream when data exceeds `maxMemory`. | + +## Interface hierarchy + +``` +Readable Writable Seekable Stringable + \ | / / + \ | / / + StreamInterface ---------------- + | + AbstractStream + / | \ \ + Temp Existing StringStream TempString +``` + +`StreamInterface` composes `Readable`, `Writable`, `Seekable` and +`Stringable`. Consumer code can type-hint on the narrow interface when only +a subset of capabilities is needed. + +## Documentation + +- [doc/USAGE.md](doc/USAGE.md) - API reference, patterns and protocol + examples. +- [doc/UPGRADING.md](doc/UPGRADING.md) - Migration guide from `Horde_Stream` + (lib/) to `Horde\Stream` (src/). + +## Testing + +```bash +# Unit tests (default suite) +phpunit + +# Integration tests (requires horde/stream_wrapper) +phpunit --testsuite integration +``` + +## License + +LGPL 2.1. See [LICENSE](LICENSE) for details. diff --git a/composer.json b/composer.json index 147f426..68f7f3c 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,9 @@ "autoload": { "psr-0": { "Horde_Stream": "lib/" + }, + "psr-4": { + "Horde\\Stream\\": "src/" } }, "autoload-dev": { @@ -45,5 +48,6 @@ "branch-alias": { "dev-FRAMEWORK_6_0": "2.x-dev" } - } -} \ No newline at end of file + }, + "minimum-stability": "dev" +} diff --git a/doc/UPGRADING.md b/doc/UPGRADING.md new file mode 100644 index 0000000..2467728 --- /dev/null +++ b/doc/UPGRADING.md @@ -0,0 +1,228 @@ +# Upgrading from Horde_Stream to Horde\Stream + +This guide covers the migration from the legacy `Horde_Stream` classes +(`lib/` PSR-0) to the modern `Horde\Stream` namespace (`src/` PSR-4). + +Both namespaces coexist in the same package. No code is removed. You can +migrate at your own pace. + +## Class mapping + +| Legacy (lib/) | Modern (src/) | +|----------------|---------------| +| `Horde_Stream` | `Horde\Stream\AbstractStream` (not instantiated directly) | +| `Horde_Stream_Temp` | `Horde\Stream\Temp` | +| `Horde_Stream_Existing` | `Horde\Stream\Existing` | +| `Horde_Stream_TempString` | `Horde\Stream\TempString` | +| `Horde_Stream_Wrapper_String` | `Horde\Stream\StringStream` (wraps the wrapper internally) | +| `Horde_Stream_Exception` | `Horde\Stream\StreamException` | +| *(none)* | `Horde\Stream\StreamInterface` | +| *(none)* | `Horde\Stream\Readable` | +| *(none)* | `Horde\Stream\Writable` | +| *(none)* | `Horde\Stream\Seekable` | + +## Constructor changes + +Legacy classes accepted an `$opts` array. Modern classes use typed named +parameters. + +### Horde_Stream_Temp + +```php +// Legacy +$stream = new Horde_Stream_Temp(['max_memory' => 2097152]); + +// Modern +$stream = new Horde\Stream\Temp(maxMemory: 2097152); +``` + +### Horde_Stream_Existing + +```php +// Legacy +$stream = new Horde_Stream_Existing(['stream' => $resource]); + +// Modern +$stream = new Horde\Stream\Existing($resource); +``` + +### Horde_Stream_TempString + +```php +// Legacy +$stream = new Horde_Stream_TempString(['max_memory' => 1048576]); + +// Modern +$stream = new Horde\Stream\TempString(maxMemory: 1048576); +``` + +## Property changes + +### utf8_char -> utf8Char + +The magic property `utf8_char` is now a typed public property. + +```php +// Legacy +$stream->utf8_char = true; + +// Modern +$stream->utf8Char = true; +``` + +### stream -> getResource() + +The public `$stream` property is replaced by a method. + +```php +// Legacy +$resource = $stream->stream; + +// Modern +$resource = $stream->getResource(); +``` + +## Method signature changes + +### Return types + +Methods that returned `bool` for success/failure now return `void` and throw +`StreamException` on failure. + +| Method | Legacy return | Modern return | +|--------|-------------|---------------| +| `rewind()` | `bool` | `void` (throws on failure) | +| `seek()` | `bool` | `void` (throws on failure) | +| `end()` | `bool` | `void` (throws on failure) | +| `pos()` | `int\|false` | `int` (throws on failure) | + +### seek() + +The second parameter changed from positional `$curr` to named `$fromCurrent`. +Behavior is identical. + +```php +// Legacy - seek to absolute position 5 +$stream->seek(5, false); + +// Modern - identical call, but the parameter is named +$stream->seek(5, fromCurrent: false); +``` + +The optional third parameter for UTF-8 character-mode seeking changed from +positional to named: + +```php +// Legacy +$stream->seek(2, false, true); + +// Modern +$stream->seek(2, fromCurrent: false, char: true); +``` + +### add() + +The second parameter changed from positional `$reset` to named. The `$data` +parameter now accepts `StreamInterface` in addition to `Horde_Stream`. + +```php +// Legacy +$stream->add($data, true); + +// Modern +$stream->add($data, reset: true); +``` + +### getToChar() + +The second parameter changed from positional `$all` to named: + +```php +// Legacy - don't strip repeated delimiters +$stream->getToChar("\n", false); + +// Modern +$stream->getToChar("\n", all: false); +``` + +### substring() + +The third parameter for UTF-8 character mode changed from positional to named: + +```php +// Legacy +$stream->substring(0, 3, true); + +// Modern +$stream->substring(0, 3, char: true); +``` + +### length() + +The parameter for UTF-8 character counting changed from positional to named: + +```php +// Legacy +$stream->length(true); + +// Modern +$stream->length(utf8: true); +``` + +### search() + +Parameters changed from positional to named: + +```php +// Legacy +$stream->search('X', false, false); + +// Modern +$stream->search('X', reverse: false, reset: false); +``` + +## Type-hinting + +Legacy code that type-hints on `Horde_Stream` should be updated to use +`Horde\Stream\StreamInterface` or the narrower capability interfaces when +appropriate: + +```php +// Legacy +function parse(Horde_Stream $stream): void { ... } + +// Modern - full stream +function parse(StreamInterface $stream): void { ... } + +// Modern - only needs read + position +function parse(Readable&Seekable $stream): void { ... } +``` + +The `add()` method on modern streams accepts both `StreamInterface` and +`Horde_Stream`, so you can pass legacy stream objects to modern code during +migration. + +## Serialization + +Legacy streams implemented `Serializable`. Modern streams use +`__serialize()` / `__unserialize()`. Both serialize data content and cursor +position. The serialized formats are not cross-compatible - you cannot +unserialize a legacy stream into a modern one or vice versa. + +## Exceptions + +Modern code throws `Horde\Stream\StreamException` (extends `RuntimeException`) +instead of `Horde_Stream_Exception`. Expected exhaustion (`getChar()` at EOF) +returns `false`, not an exception. Only actual failures throw: + +- Failed seek, rewind or position query +- Invalid or truncated UTF-8 byte sequence +- Failed resource creation in constructors + +## Removed features + +- **Magic `__get` / `__set`**: The `utf8_char` magic property is replaced by + `public bool $utf8Char`. No other magic properties exist. +- **`Serializable` interface**: Replaced by `__serialize()` / + `__unserialize()`. +- **Array constructor options**: Replaced by typed named parameters. diff --git a/doc/USAGE.md b/doc/USAGE.md new file mode 100644 index 0000000..a007917 --- /dev/null +++ b/doc/USAGE.md @@ -0,0 +1,466 @@ +# Horde\Stream Usage Guide + +## Overview + +Horde\Stream is a managed stream abstraction designed for parsing and +constructing structured byte data. It sits below HTTP-level abstractions +like PSR-7 and above raw `fread`/`fwrite` calls, providing the operations +that protocol parsers and MIME processors need. + +## Creating streams + +### Temp -- general-purpose buffer + +The most common entry point. Backed by `php://temp`, data lives in memory +until PHP's internal threshold triggers a spill to a temporary file. + +```php +use Horde\Stream\Temp; + +// Default: PHP manages the memory/disk threshold +$stream = new Temp(); + +// Custom: spill to disk after 512 KB +$stream = new Temp(maxMemory: 524288); +``` + +### Existing -- wrap an open resource + +Wraps a socket, file handle, pipe or any PHP stream resource. The caller +retains ownership of the resource lifecycle unless `close()` is called on +the wrapper. + +```php +use Horde\Stream\Existing; + +$socket = fsockopen('imap.example.com', 993); +$stream = new Existing($socket); + +// Read the server greeting +$greeting = $stream->getToChar("\r\n", all: false); +``` + +Throws `InvalidArgumentException` if the argument is not an open stream +resource. + +### StringStream -- string-backed + +Uses `horde/stream_wrapper` to expose a PHP string as a seekable stream. +Useful when data is already in memory and you want stream operations without +copying to `php://temp`. + +```php +use Horde\Stream\StringStream; + +$stream = new StringStream("From: user@example.com\r\nSubject: Test\r\n\r\n"); +``` + +Requires `horde/stream_wrapper` (`composer require horde/stream_wrapper`). + +### TempString -- auto-spill hybrid + +Starts with a fast string backend and transparently switches to `php://temp` +when accumulated data exceeds `maxMemory`. Ideal when most streams are small +but occasional large payloads must be handled without blowing memory. + +```php +use Horde\Stream\TempString; + +// Spill to temp after 2 MB (default) +$stream = new TempString(); + +// Spill after 64 KB +$stream = new TempString(maxMemory: 65536); + +$stream->add($smallHeader); // stays in string backend +$stream->add($largeBody); // triggers spill if total exceeds maxMemory + +// Check which backend is active +if ($stream->isUsingTempStream()) { + // data spilled to php://temp +} +``` + +## Writing data + +`add()` accepts strings, PHP resources or other stream objects. Data is +appended at the current position. + +```php +// String +$stream->add("EHLO example.com\r\n"); + +// Another Horde\Stream +$stream->add($otherStream); + +// Raw PHP resource +$stream->add($fileHandle); + +// Write and restore cursor position +$stream->add($data, reset: true); +``` + +With `reset: true`, the cursor returns to its position before the write. +This is useful when building a stream incrementally while another consumer +reads from the beginning. + +## Reading data + +### Character-level + +```php +// Read one byte (or one UTF-8 character when utf8Char is true) +$char = $stream->getChar(); // string|false + +// Peek without advancing the cursor +$next = $stream->peek(); // next 1 byte +$next3 = $stream->peek(3); // next 3 bytes +``` + +`getChar()` returns `false` at EOF -- it does not throw. Only malformed +UTF-8 sequences throw `StreamException`. + +### Range reads + +```php +// From current position to end +$rest = $stream->substring(); + +// 100 bytes from current position +$chunk = $stream->substring(0, 100); + +// Skip 10 bytes ahead, then read 50 +$chunk = $stream->substring(10, 50); + +// From absolute byte offset to end +$all = $stream->getString(0); + +// From absolute position 10 to absolute position 19 (inclusive) +$slice = $stream->getString(10, 19); +``` + +### Delimiter scanning + +`getToChar()` reads from the current position up to (but not including) a +delimiter string, then advances the cursor past it. + +```php +// Read one line (stops at \n, skips the \n) +$line = $stream->getToChar("\n", all: false); + +// Read until CRLF +$line = $stream->getToChar("\r\n"); + +// Read until space, skip consecutive spaces +$token = $stream->getToChar(' ', all: true); + +// Read until space, stop at first (preserving empty tokens) +$token = $stream->getToChar(' ', all: false); +``` + +When the delimiter is not found, `getToChar()` returns all remaining data. + +### Searching + +`search()` finds the byte offset of a substring without consuming data. + +```php +// Forward search from current position +$pos = $stream->search('{'); // int|null + +// Reverse search from current position +$pos = $stream->search('}', reverse: true); + +// Search and move cursor to found position +$pos = $stream->search('LITERAL', reset: false); + +// Multi-byte search +$pos = $stream->search("\r\n.\r\n"); +``` + +Returns `null` when the target is not found. + +## Positioning + +```php +$stream->rewind(); // move to byte 0 +$stream->end(); // move to end +$stream->end(-5); // move to 5 bytes before end +$stream->seek(10); // forward 10 bytes from current +$stream->seek(-3); // back 3 bytes from current +$stream->seek(100, fromCurrent: false); // absolute byte 100 + +$pos = $stream->pos(); // current byte offset +$atEnd = $stream->eof(); // true if past last byte +$size = $stream->length(); // total byte count +``` + +All positioning methods throw `StreamException` on failure. Seeking before +byte 0 clamps to 0 rather than throwing. + +## UTF-8 character mode + +Set `utf8Char` to `true` to make character-oriented methods work in +UTF-8 characters instead of raw bytes. + +```php +$stream = new Temp(); +$stream->add('Aönön'); +$stream->utf8Char = true; + +$stream->rewind(); +$stream->getChar(); // 'A' (1 byte) +$stream->getChar(); // 'ö' (2 bytes) + +$stream->length(); // 7 (bytes) +$stream->length(utf8: true); // 5 (characters) + +$stream->rewind(); +$stream->seek(2, fromCurrent: false, char: true); // byte 3 (after 'A' + 'ö') + +$stream->rewind(); +$stream->substring(0, 3, char: true); // 'Aön' (3 characters) +``` + +Invalid or truncated UTF-8 sequences cause `StreamException`. + +## EOL detection + +```php +$eol = $stream->getEOL(); // "\n", "\r\n" or null +``` + +Scans for the first newline and determines whether the stream uses LF or +CRLF line endings. + +## Lifecycle + +### Cloning + +Cloning produces an independent copy. Modifying or closing the clone does +not affect the original. + +```php +$copy = clone $stream; +$stream->close(); +echo (string) $copy; // still works +``` + +### Serialization + +Streams support `serialize()` / `unserialize()`. Both data content and +cursor position are preserved. + +```php +$frozen = serialize($stream); +$thawed = unserialize($frozen); +echo $thawed->pos(); // same as before serialize +echo (string) $thawed; // same content +``` + +### Resource access + +When you need the underlying PHP resource for functions like +`stream_filter_append()` or `stream_copy_to_stream()`: + +```php +$resource = $stream->getResource(); +stream_filter_append($resource, 'convert.base64-encode'); +``` + +## Type-hinting with capability interfaces + +`StreamInterface` composes three capability interfaces. Consumer code can +type-hint on the narrowest capability it needs: + +```php +use Horde\Stream\Readable; +use Horde\Stream\Seekable; +use Horde\Stream\Writable; +use Horde\Stream\StreamInterface; + +// Full access +function processMessage(StreamInterface $stream): void { ... } + +// Read-only parser +function parseHeaders(Readable $stream): void { ... } + +// Needs to scan and reposition +function findBoundary(Readable&Seekable $stream): ?int { ... } + +// Write-only sink +function appendData(Writable $sink): void { ... } +``` + +## Horde\Stream vs PSR-7 streams + +PSR-7 `Psr\Http\Message\StreamInterface` and `Horde\Stream\StreamInterface` +serve different layers of an application. + +### Design goals + +| | PSR-7 | Horde\Stream | +|---|-------|-------------| +| **Purpose** | HTTP message body transport | Protocol parsing and data construction | +| **Read model** | Bulk: `read($length)`, `getContents()` | Structured: `getToChar()`, `search()`, `peek()`, `getChar()` | +| **Write model** | `write($string)` | `add($string\|$resource\|$stream, reset: ...)` | +| **Capability query** | `isReadable()`, `isWritable()`, `isSeekable()` | Statically guaranteed by interface composition | +| **Error model** | `RuntimeException` on detached stream | `StreamException` on I/O failure; `false` on expected EOF | +| **UTF-8 awareness** | None | Character-level seek, read and length | +| **Clone / serialize** | Not defined | Built-in, position-preserving | +| **EOL detection** | None | `getEOL()` auto-detects LF vs CRLF | +| **Memory strategy** | Caller-managed | `TempString` auto-spill from string to temp | + +### When to use which + +**Use PSR-7 streams** when you are sending or receiving HTTP messages through +a PSR-18 client or PSR-15 middleware. The PSR-7 contract ensures +interoperability across HTTP libraries. + +**Use Horde\Stream** when you are: + +- **Parsing line-oriented protocols** (IMAP, SMTP, ManageSieve, POP3) where + you need to read until a delimiter, search for markers and handle literal + data chunks. +- **Processing MIME structures** where headers must be read line by line and + body parts may be large binary blobs. +- **Buffering data of unpredictable size** where automatic spill from memory + to disk is valuable. +- **Handling multibyte text** in protocol data where character-level + positioning matters (e.g., UTF-8 mailbox names in IMAP). +- **Building protocol output** by assembling strings, resources and other + streams into a single buffer. + +### Bridging the two + +When interoperability with PSR-7 code is needed, use `getResource()` to +extract the underlying PHP resource and wrap it in a PSR-7 stream or copy +content between the two: + +```php +// Horde\Stream -> PSR-7 +$psr7stream = new Horde\Http\Stream($hordeStream->getResource()); + +// PSR-7 -> Horde\Stream +$hordeStream = new Horde\Stream\Existing($psr7stream->detach()); +``` + +## Protocol use cases + +### IMAP response parsing + +IMAP servers send structured responses with literal data blocks denoted by +`{length}\r\n`. A tokenizer uses `getToChar()` and `search()` to extract +these: + +```php +use Horde\Stream\Temp; + +$buffer = new Temp(); +$buffer->add($rawImapResponse, reset: true); + +// Read tokens until we hit a literal marker +$token = $buffer->getToChar('}', all: false); +$literalLen = (int) $token; + +// Read exactly that many bytes of literal data +$literal = $buffer->substring(0, $literalLen); + +// Store large literals in their own stream +$bodyStream = new Temp(); +$bodyStream->add($literal); +``` + +Stream filters can be applied to the underlying resource for encoding +transformations (e.g., detecting whether data requires IMAP literal +quoting): + +```php +$resource = $stream->getResource(); +stream_filter_append($resource, 'horde_imap_client_string', STREAM_FILTER_WRITE); +``` + +### SMTP DATA transmission + +SMTP sends message bodies after the `DATA` command. Large messages are +streamed in chunks rather than loaded fully into memory: + +```php +use Horde\Stream\Existing; + +$connection = new Existing($smtpSocket); + +// Stream message body from a file handle +$message = fopen('/path/to/message.eml', 'r'); +$connection->add($message); +fclose($message); +``` + +### MIME header parsing + +MIME headers are line-oriented with continuation (folded) lines. The stream +makes this natural: + +```php +use Horde\Stream\Temp; + +$stream = new Temp(); +$stream->add($rawHeaders, reset: true); + +$headers = []; +while (!$stream->eof()) { + $line = $stream->getToChar("\n", all: false); + $line = rtrim($line, "\r"); + + if ($line === '') { + break; // blank line ends headers + } + + // Check for folded continuation line + $next = $stream->peek(); + while ($next === ' ' || $next === "\t") { + $line .= ' ' . trim($stream->getToChar("\n", all: false)); + $next = $stream->peek(); + } + + [$name, $value] = explode(':', $line, 2); + $headers[trim($name)] = trim($value); +} +``` + +### ManageSieve command parsing + +ManageSieve (RFC 5804) uses a line-oriented protocol similar to IMAP, with +literal strings for script content: + +```php +use Horde\Stream\Temp; + +$response = new Temp(); +$response->add($rawResponse, reset: true); + +$status = $response->getToChar(' ', all: false); // "OK", "NO" or "BYE" +$detail = $response->getToChar("\r\n", all: false); +``` + +### Large message handling with TempString + +When handling mailbox operations that process many small messages but +occasionally encounter large attachments: + +```php +use Horde\Stream\TempString; + +// Each message gets its own stream. Most stay in the fast string +// backend; large ones spill to temp automatically. +foreach ($messageIds as $id) { + $stream = new TempString(maxMemory: 262144); // 256 KB threshold + $stream->add($fetchResponse); + + // Process headers (small, stays in memory) + // Process body (may be large, may spill -- transparent to caller) + processMessage($stream); + + $stream->close(); +} +``` diff --git a/phpunit.xml.dist b/phpunit.xml.dist index b90c7b2..3b03239 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -23,6 +23,7 @@ lib + src diff --git a/src/AbstractStream.php b/src/AbstractStream.php new file mode 100644 index 0000000..64cbdbf --- /dev/null +++ b/src/AbstractStream.php @@ -0,0 +1,428 @@ + + * @category Horde + * @copyright 2012-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Stream + */ + +namespace Horde\Stream; + +use Horde_Stream; + +abstract class AbstractStream implements StreamInterface +{ + /** @var resource */ + protected mixed $resource; + + public bool $utf8Char = false; + + public function __toString(): string + { + $this->rewind(); + return $this->substring(); + } + + public function add(mixed $data, bool $reset = false): void + { + if ($reset) { + $pos = $this->pos(); + } + + if (is_resource($data)) { + $dpos = ftell($data); + while (!feof($data)) { + $this->add(fread($data, 8192)); + } + fseek($data, $dpos); + } elseif ($data instanceof StreamInterface || $data instanceof Horde_Stream) { + $dpos = $data->pos(); + while (!$data->eof()) { + $this->add($data->substring(0, 65536)); + } + $data->seek($dpos, false); + } else { + fwrite($this->resource, $data); + } + + if ($reset) { + $this->seek($pos, false); + } + } + + /** + * @throws StreamException + */ + public function length(bool $utf8 = false): int + { + $pos = $this->pos(); + + if ($utf8 && $this->utf8Char) { + $this->rewind(); + $len = 0; + while ($this->getChar() !== false) { + ++$len; + } + } else { + if (fseek($this->resource, 0, SEEK_END) !== 0) { + throw new StreamException('Failed to seek to end of stream.'); + } + $len = $this->pos(); + } + + $this->seek($pos, false); + + return $len; + } + + public function getToChar(string $end, bool $all = true): string + { + if (($len = strlen($end)) === 1) { + $out = ''; + do { + if (($tmp = stream_get_line($this->resource, 8192, $end)) === false) { + return $out; + } + + $out .= $tmp; + if ((strlen($tmp) < 8192) || ($this->peek(-1) == $end)) { + break; + } + } while (true); + } else { + $res = $this->search($end); + + if (is_null($res)) { + return $this->substring(); + } + + $out = substr($this->getString(null, $res + $len - 1), 0, $len * -1); + } + + if ($all) { + while ($this->peek($len) == $end) { + $this->seek($len); + } + } + + return $out; + } + + public function peek(int $length = 1): string + { + $out = ''; + + for ($i = 0; $i < $length; ++$i) { + if (($c = $this->getChar()) === false) { + break; + } + $out .= $c; + } + + $this->seek(strlen($out) * -1); + + return $out; + } + + public function search(string $char, bool $reverse = false, bool $reset = true): ?int + { + $found_pos = null; + + if ($len = strlen($char)) { + $pos = $this->pos(); + $single_char = ($len === 1); + + do { + if ($reverse) { + for ($i = $pos - 1; $i >= 0; --$i) { + $this->seek($i, false); + $c = $this->peek(); + if ($c == ($single_char ? $char : substr($char, 0, strlen($c)))) { + $found_pos = $i; + break; + } + } + } else { + $fgetc = ($single_char && !$this->utf8Char); + + while (($c = ($fgetc ? fgetc($this->resource) : $this->getChar())) !== false) { + if ($c == ($single_char ? $char : substr($char, 0, strlen($c)))) { + $found_pos = $this->pos() - ($single_char ? 1 : strlen($c)); + break; + } + } + } + + if ($single_char + || is_null($found_pos) + || ($this->getString($found_pos, $found_pos + $len - 1) == $char)) { + break; + } + + $this->seek($found_pos + ($reverse ? 0 : 1), false); + $found_pos = null; + } while (true); + + $this->seek( + ($reset || is_null($found_pos)) ? $pos : $found_pos, + false + ); + } + + return $found_pos; + } + + public function getString(?int $start = null, ?int $end = null): string + { + if ($start !== null && $start >= 0) { + $this->seek($start, false); + $start = 0; + } + + if ($end === null) { + $len = null; + } else { + $end = ($end >= 0) + ? $end - $this->pos() + 1 + : $this->length() - $this->pos() + $end; + $len = max($end, 0); + } + + return $this->substring($start ?? 0, $len); + } + + public function substring(int $start = 0, ?int $length = null, bool $char = false): string + { + if ($start !== 0) { + $this->seek($start, true, $char); + } + + $out = ''; + $to_end = is_null($length); + + if ($char + && $this->utf8Char + && !$to_end + && ($length >= 0) + && ($length < ($this->length() - $this->pos()))) { + while ($length-- && (($c = $this->getChar()) !== false)) { + $out .= $c; + } + return $out; + } + + if (!$to_end && ($length < 0)) { + $pos = $this->pos(); + $this->end(); + $this->seek($length, true, $char); + $length = $this->pos() - $pos; + $this->seek($pos, false); + if ($length < 0) { + return ''; + } + } + + while (!feof($this->resource) && ($to_end || $length)) { + $read = fread($this->resource, $to_end ? 16384 : $length); + $out .= $read; + if (!$to_end) { + $length -= strlen($read); + } + } + + return $out; + } + + public function getEOL(): ?string + { + $pos = $this->pos(); + + $this->rewind(); + $pos2 = $this->search("\n", false, false); + if ($pos2) { + $this->seek(-1); + $eol = ($this->getChar() == "\r") + ? "\r\n" + : "\n"; + } else { + $eol = is_null($pos2) + ? null + : "\n"; + } + + $this->seek($pos, false); + + return $eol; + } + + /** + * @throws StreamException + */ + public function getChar(): string|false + { + $char = fgetc($this->resource); + if ($char === false || !$this->utf8Char) { + return $char; + } + + $c = ord($char); + if ($c < 0x80) { + return $char; + } + + if ($c < 0xe0) { + $n = 1; + } elseif ($c < 0xf0) { + $n = 2; + } elseif ($c < 0xf8) { + $n = 3; + } else { + throw new StreamException('Invalid UTF-8 lead byte.'); + } + + for ($i = 0; $i < $n; ++$i) { + if (($c = fgetc($this->resource)) === false) { + throw new StreamException('Truncated UTF-8 byte sequence.'); + } + $char .= $c; + } + + return $char; + } + + /** + * @throws StreamException + */ + public function pos(): int + { + $pos = ftell($this->resource); + if ($pos === false) { + throw new StreamException('Failed to get stream position.'); + } + return $pos; + } + + /** + * @throws StreamException + */ + public function rewind(): void + { + if (!rewind($this->resource)) { + throw new StreamException('Failed to rewind stream.'); + } + } + + /** + * @throws StreamException + */ + public function seek(int $offset, bool $fromCurrent = true, bool $char = false): void + { + if ($offset === 0) { + if (!$fromCurrent) { + $this->rewind(); + } + return; + } + + if ($offset < 0) { + if (!$fromCurrent) { + return; + } + if (abs($offset) > $this->pos()) { + $this->rewind(); + return; + } + } + + if ($char && $this->utf8Char) { + if ($offset > 0) { + if (!$fromCurrent) { + $this->rewind(); + } + do { + $this->getChar(); + } while (--$offset); + } else { + $pos = $this->pos(); + $offset = abs($offset); + while ($pos-- && $offset) { + fseek($this->resource, -1, SEEK_CUR); + if ((ord($this->peek()) & 0xC0) != 0x80) { + --$offset; + } + } + } + return; + } + + if (fseek($this->resource, $offset, $fromCurrent ? SEEK_CUR : SEEK_SET) !== 0) { + if ($fromCurrent && $offset > 0) { + $this->end(); + return; + } + throw new StreamException('Failed to seek in stream.'); + } + } + + /** + * @throws StreamException + */ + public function end(int $offset = 0): void + { + if (fseek($this->resource, $offset, SEEK_END) !== 0) { + throw new StreamException('Failed to seek to end of stream.'); + } + } + + public function eof(): bool + { + return feof($this->resource); + } + + public function close(): void + { + if (is_resource($this->resource)) { + fclose($this->resource); + } + } + + /** + * @return resource + */ + public function getResource(): mixed + { + return $this->resource; + } + + public function __clone() + { + $data = strval($this); + $this->resource = fopen('php://temp', 'r+'); + fwrite($this->resource, $data); + } + + public function __serialize(): array + { + $pos = $this->pos(); + return [ + 'data' => strval($this), + 'pos' => $pos, + ]; + } + + public function __unserialize(array $data): void + { + $this->resource = fopen('php://temp', 'r+'); + fwrite($this->resource, $data['data']); + $this->seek($data['pos'], false); + } +} diff --git a/src/Existing.php b/src/Existing.php new file mode 100644 index 0000000..fd1045b --- /dev/null +++ b/src/Existing.php @@ -0,0 +1,34 @@ + + * @category Horde + * @copyright 2012-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Stream + */ + +namespace Horde\Stream; + +use InvalidArgumentException; + +final class Existing extends AbstractStream +{ + /** + * @param resource $resource An open PHP stream resource. + */ + public function __construct(mixed $resource) + { + if (!is_resource($resource) || get_resource_type($resource) !== 'stream') { + throw new InvalidArgumentException('Expected an open stream resource.'); + } + $this->resource = $resource; + } +} diff --git a/src/Readable.php b/src/Readable.php new file mode 100644 index 0000000..5d340f7 --- /dev/null +++ b/src/Readable.php @@ -0,0 +1,37 @@ + + * @category Horde + * @copyright 2012-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Stream + */ + +namespace Horde\Stream; + +interface Readable +{ + public function getChar(): string|false; + + public function peek(int $length = 1): string; + + public function getString(?int $start = null, ?int $end = null): string; + + public function substring(int $start = 0, ?int $length = null): string; + + public function getToChar(string $end, bool $all = true): string; + + public function search(string $char, bool $reverse = false, bool $reset = true): ?int; + + public function eof(): bool; + + public function length(): int; +} diff --git a/src/Seekable.php b/src/Seekable.php new file mode 100644 index 0000000..dad89bc --- /dev/null +++ b/src/Seekable.php @@ -0,0 +1,29 @@ + + * @category Horde + * @copyright 2012-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Stream + */ + +namespace Horde\Stream; + +interface Seekable +{ + public function pos(): int; + + public function rewind(): void; + + public function seek(int $offset, bool $fromCurrent = true): void; + + public function end(int $offset = 0): void; +} diff --git a/src/StreamException.php b/src/StreamException.php new file mode 100644 index 0000000..134632d --- /dev/null +++ b/src/StreamException.php @@ -0,0 +1,22 @@ + + * @category Horde + * @copyright 2012-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Stream + */ + +namespace Horde\Stream; + +use RuntimeException; + +class StreamException extends RuntimeException {} diff --git a/src/StreamInterface.php b/src/StreamInterface.php new file mode 100644 index 0000000..73d5072 --- /dev/null +++ b/src/StreamInterface.php @@ -0,0 +1,32 @@ + + * @category Horde + * @copyright 2012-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Stream + */ + +namespace Horde\Stream; + +use Stringable; + +interface StreamInterface extends Readable, Writable, Seekable, Stringable +{ + public function close(): void; + + public function getEOL(): ?string; + + /** + * @return resource + */ + public function getResource(): mixed; +} diff --git a/src/StringStream.php b/src/StringStream.php new file mode 100644 index 0000000..12246a2 --- /dev/null +++ b/src/StringStream.php @@ -0,0 +1,33 @@ + + * @category Horde + * @copyright 2014-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Stream + */ + +namespace Horde\Stream; + +use Horde\Stream\Wrapper\StringWrapper; +use InvalidArgumentException; + +final class StringStream extends AbstractStream +{ + public function __construct(string $data = '') + { + $resource = StringWrapper::getStream($data); + if (!is_resource($resource)) { + throw new StreamException('Failed to create string stream.'); + } + $this->resource = $resource; + } +} diff --git a/src/Temp.php b/src/Temp.php new file mode 100644 index 0000000..7499ed0 --- /dev/null +++ b/src/Temp.php @@ -0,0 +1,38 @@ + + * @category Horde + * @copyright 2012-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Stream + */ + +namespace Horde\Stream; + +final class Temp extends AbstractStream +{ + /** + * @throws StreamException + */ + public function __construct(?int $maxMemory = null) + { + $uri = 'php://temp'; + if ($maxMemory !== null) { + $uri .= '/maxmemory:' . $maxMemory; + } + + $resource = fopen($uri, 'r+'); + if ($resource === false) { + throw new StreamException('Failed to open temporary stream.'); + } + $this->resource = $resource; + } +} diff --git a/src/TempString.php b/src/TempString.php new file mode 100644 index 0000000..d2261ea --- /dev/null +++ b/src/TempString.php @@ -0,0 +1,78 @@ + + * @category Horde + * @copyright 2014-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Stream + */ + +namespace Horde\Stream; + +use Horde_Stream; + +final class TempString extends AbstractStream +{ + private ?StringStream $stringBackend; + private int $maxMemory; + + public function __construct(int $maxMemory = 2_097_152) + { + $this->maxMemory = $maxMemory; + $this->stringBackend = new StringStream(); + $this->resource = $this->stringBackend->getResource(); + } + + public function add(mixed $data, bool $reset = false): void + { + if ($this->stringBackend !== null && is_string($data)) { + if ((strlen($data) + $this->stringBackend->length()) < $this->maxMemory) { + $this->stringBackend->add($data, $reset); + return; + } + + $pos = $this->stringBackend->pos(); + $contents = (string) $this->stringBackend; + $this->stringBackend = null; + + $temp = new Temp($this->maxMemory); + $this->resource = $temp->getResource(); + fwrite($this->resource, $contents); + $this->seek($pos, false); + } + + parent::add($data, $reset); + } + + public function isUsingTempStream(): bool + { + return $this->stringBackend === null; + } + + public function close(): void + { + if ($this->stringBackend !== null) { + $this->stringBackend->close(); + $this->stringBackend = null; + } + parent::close(); + } + + public function __clone() + { + if ($this->stringBackend !== null) { + $this->stringBackend = clone $this->stringBackend; + $this->resource = $this->stringBackend->getResource(); + } else { + parent::__clone(); + } + } +} diff --git a/src/Writable.php b/src/Writable.php new file mode 100644 index 0000000..26156c4 --- /dev/null +++ b/src/Writable.php @@ -0,0 +1,28 @@ + + * @category Horde + * @copyright 2012-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Stream + */ + +namespace Horde\Stream; + +use Horde_Stream; + +interface Writable +{ + /** + * @param string|StreamInterface|Horde_Stream|resource $data + */ + public function add(mixed $data, bool $reset = false): void; +} diff --git a/test/integration/StringStreamTest.php b/test/integration/StringStreamTest.php new file mode 100644 index 0000000..f45bc84 --- /dev/null +++ b/test/integration/StringStreamTest.php @@ -0,0 +1,97 @@ +assertInstanceOf(StreamInterface::class, $stream); + $this->assertEquals(0, $stream->length()); + } + + public function testConstructorWithInitialData(): void + { + $stream = new StringStream('hello'); + $this->assertEquals(5, $stream->length()); + $this->assertEquals('hello', $stream->getString(0)); + } + + public function testGetResourceReturnsResource(): void + { + $stream = new StringStream(); + $this->assertIsResource($stream->getResource()); + } + + // -- Read/write -- + + public function testAddAndReadBack(): void + { + $stream = new StringStream(); + $stream->add('hello world'); + + $this->assertEquals(11, $stream->length()); + $this->assertEquals('hello world', $stream->getString(0)); + } + + public function testMultipleAdds(): void + { + $stream = new StringStream(); + $stream->add('hello'); + $stream->add(' world'); + + $this->assertEquals('hello world', (string) $stream); + } + + public function testSeekAndRead(): void + { + $stream = new StringStream('ABCDEF'); + $stream->seek(2, false); + + $this->assertEquals('CDEF', $stream->getString()); + } + + // -- Lifecycle -- + + public function testToStringReturnsFullContent(): void + { + $stream = new StringStream('test'); + $this->assertEquals('test', (string) $stream); + } + + public function testCloneProducesIndependentCopy(): void + { + $stream = new StringStream(); + $stream->add('data'); + + $clone = clone $stream; + $stream->close(); + + $this->assertEquals('data', (string) $clone); + } + + public function testSerializeRoundTrip(): void + { + $stream = new StringStream(); + $stream->add('hello'); + $stream->seek(3, false); + + $restored = unserialize(serialize($stream)); + + $this->assertEquals(3, $restored->pos()); + $this->assertEquals('hello', $restored->getString(0)); + } +} diff --git a/test/unit/ExistingStreamTest.php b/test/unit/ExistingStreamTest.php new file mode 100644 index 0000000..61bbcb8 --- /dev/null +++ b/test/unit/ExistingStreamTest.php @@ -0,0 +1,106 @@ +assertInstanceOf(StreamInterface::class, $stream); + $this->assertEquals('hello', $stream->getString(0)); + + fclose($resource); + } + + public function testConstructorRejectsNonResource(): void + { + $this->expectException(InvalidArgumentException::class); + new Existing('not a resource'); + } + + public function testConstructorRejectsClosedResource(): void + { + $resource = fopen('php://temp', 'r+'); + fclose($resource); + + $this->expectException(InvalidArgumentException::class); + new Existing($resource); + } + + public function testGetResourceReturnsOriginal(): void + { + $resource = fopen('php://temp', 'r+'); + $stream = new Existing($resource); + + $this->assertSame($resource, $stream->getResource()); + + fclose($resource); + } + + // -- Read/write through wrapper -- + + public function testAddAndReadBack(): void + { + $resource = fopen('php://temp', 'r+'); + $stream = new Existing($resource); + + $stream->add('hello world'); + $this->assertEquals(11, $stream->length()); + $this->assertEquals('hello world', $stream->getString(0)); + + fclose($resource); + } + + public function testPreservesExistingContent(): void + { + $resource = fopen('php://temp', 'r+'); + fwrite($resource, 'existing'); + + $stream = new Existing($resource); + $stream->add(' appended'); + + $this->assertEquals('existing appended', $stream->getString(0)); + + fclose($resource); + } + + // -- Lifecycle -- + + public function testCloseClosesResource(): void + { + $resource = fopen('php://temp', 'r+'); + $stream = new Existing($resource); + + $stream->close(); + $this->assertFalse(is_resource($resource)); + } + + public function testToStringReturnsFullContent(): void + { + $resource = fopen('php://temp', 'r+'); + $stream = new Existing($resource); + $stream->add('test data'); + + $this->assertEquals('test data', (string) $stream); + + fclose($resource); + } +} diff --git a/test/unit/TempStreamTest.php b/test/unit/TempStreamTest.php new file mode 100644 index 0000000..1223a48 --- /dev/null +++ b/test/unit/TempStreamTest.php @@ -0,0 +1,642 @@ +assertInstanceOf(StreamInterface::class, $stream); + $this->assertEquals(0, $stream->pos()); + } + + public function testMaxMemoryParameter(): void + { + $stream = new Temp(maxMemory: 1024); + $stream->add('hello'); + $this->assertEquals('hello', (string) $stream); + } + + // -- add + read -- + + public function testAddStringAndReadBack(): void + { + $stream = new Temp(); + $stream->add('hello world'); + + $this->assertEquals(11, $stream->length()); + $this->assertEquals('hello world', $stream->getString(0)); + } + + public function testAddWithResetPreservesPosition(): void + { + $stream = new Temp(); + $stream->add('abc'); + + $stream->add('xyz', reset: true); + + $this->assertEquals(3, $stream->pos()); + $this->assertEquals('abcxyz', (string) $stream); + } + + public function testAddFromResource(): void + { + $resource = fopen('php://temp', 'r+'); + fwrite($resource, 'hello world'); + fseek($resource, 0); + + $stream = new Temp(); + $stream->add($resource); + + $this->assertEquals(11, $stream->length()); + $this->assertEquals('hello world', $stream->getString(0)); + $this->assertEquals(0, ftell($resource)); + + fclose($resource); + } + + public function testAddFromStreamInterface(): void + { + $source = new Temp(); + $source->add('foo'); + $source->rewind(); + + $target = new Temp(); + $target->add($source, reset: true); + + $this->assertEquals(3, $target->length()); + $this->assertEquals('foo', $target->getString(0)); + $this->assertEquals(0, $source->pos()); + } + + // -- getString -- + + public function testGetStringFromStart(): void + { + $stream = new Temp(); + $stream->add('A B C'); + + $this->assertEquals('A B C', $stream->getString(0)); + } + + public function testGetStringFromCurrentPosition(): void + { + $stream = new Temp(); + $stream->add('A B C'); + $stream->seek(2, false); + + $this->assertEquals('B C', $stream->getString()); + } + + public function testGetStringWithNegativeEnd(): void + { + $stream = new Temp(); + $stream->add('A B C'); + $stream->seek(2, false); + + $this->assertEquals('B', $stream->getString(null, -2)); + } + + public function testGetStringAtEndWithNegativeEnd(): void + { + $stream = new Temp(); + $stream->add('A B C'); + $stream->end(); + + $this->assertEquals('', $stream->getString(null, -1)); + } + + // -- substring -- + + public function testSubstringWithOffsetAndLength(): void + { + $stream = new Temp(); + $stream->add('1234567890'); + $stream->rewind(); + + $this->assertEquals('123', $stream->substring(0, 3)); + $this->assertEquals('456', $stream->substring(0, 3)); + $this->assertEquals('7890', $stream->substring(0)); + } + + public function testSubstringWithPositiveStartOffset(): void + { + $stream = new Temp(); + $stream->add('1234567890'); + $stream->rewind(); + + $this->assertEquals('456', $stream->substring(3, 3)); + } + + public function testSubstringWithNegativeStartOffset(): void + { + $stream = new Temp(); + $stream->add('1234567890'); + $stream->rewind(); + + $this->assertEquals('123', $stream->substring(-3, 3)); + } + + public function testSubstringWithNegativeLength(): void + { + $stream = new Temp(); + $stream->add('1234567890'); + $stream->rewind(); + + $this->assertEquals('1234567', $stream->substring(0, -3)); + } + + public function testSubstringNegativeLengthLargerThanRemaining(): void + { + $stream = new Temp(); + $stream->add('1234567890'); + $stream->seek(8, false); + + $this->assertEquals('', $stream->substring(0, -5)); + } + + // -- pos / rewind / seek / end / eof -- + + public function testPosReturnsCurrentPosition(): void + { + $stream = new Temp(); + $stream->add('123'); + + $this->assertEquals(3, $stream->pos()); + } + + public function testRewindMovesToStart(): void + { + $stream = new Temp(); + $stream->add('123'); + + $stream->rewind(); + $this->assertEquals(0, $stream->pos()); + } + + public function testSeekForwardFromCurrent(): void + { + $stream = new Temp(); + $stream->add('12345'); + $stream->rewind(); + + $stream->seek(3); + $this->assertEquals(3, $stream->pos()); + } + + public function testSeekBackwardFromCurrent(): void + { + $stream = new Temp(); + $stream->add('123'); + + $stream->seek(-2); + $this->assertEquals(1, $stream->pos()); + } + + public function testSeekFromAbsolutePosition(): void + { + $stream = new Temp(); + $stream->add('12345'); + + $stream->seek(1, false); + $this->assertEquals(1, $stream->pos()); + } + + public function testSeekPastStartClampsToZero(): void + { + $stream = new Temp(); + $stream->add('123'); + + $stream->seek(-100); + $this->assertEquals(0, $stream->pos()); + } + + public function testSeekZeroFromStartRewinds(): void + { + $stream = new Temp(); + $stream->add('123'); + + $stream->seek(0, false); + $this->assertEquals(0, $stream->pos()); + } + + public function testEndMovesToEnd(): void + { + $stream = new Temp(); + $stream->add('123'); + $stream->rewind(); + + $stream->end(); + $this->assertEquals(3, $stream->pos()); + } + + public function testEndWithNegativeOffset(): void + { + $stream = new Temp(); + $stream->add('123'); + $stream->rewind(); + + $stream->end(-1); + $this->assertEquals(2, $stream->pos()); + } + + public function testEofFalseBeforeEnd(): void + { + $stream = new Temp(); + $stream->add('123'); + $stream->rewind(); + + $this->assertFalse($stream->eof()); + } + + public function testEofTrueAfterReadingPastEnd(): void + { + $stream = new Temp(); + $stream->add('123'); + + $stream->getChar(); + $this->assertTrue($stream->eof()); + } + + // -- getChar / peek -- + + public function testGetCharReturnsSingleByte(): void + { + $stream = new Temp(); + $stream->add('ABC'); + $stream->rewind(); + + $this->assertEquals('A', $stream->getChar()); + $this->assertEquals('B', $stream->getChar()); + $this->assertEquals('C', $stream->getChar()); + } + + public function testGetCharReturnsFalseAtEof(): void + { + $stream = new Temp(); + $stream->add('A'); + + $this->assertFalse($stream->getChar()); + } + + public function testPeekDoesNotAdvancePosition(): void + { + $stream = new Temp(); + $stream->add('ABC'); + $stream->rewind(); + + $this->assertEquals('A', $stream->peek()); + $this->assertEquals('A', $stream->peek()); + $this->assertEquals(0, $stream->pos()); + } + + public function testPeekMultipleChars(): void + { + $stream = new Temp(); + $stream->add('ABC'); + $stream->rewind(); + + $this->assertEquals('AB', $stream->peek(2)); + $this->assertEquals(0, $stream->pos()); + } + + // -- search -- + + public function testSearchSingleCharForward(): void + { + $stream = new Temp(); + $stream->add('0123456789'); + $stream->rewind(); + + $this->assertEquals(5, $stream->search('5')); + $this->assertEquals(0, $stream->pos()); + } + + public function testSearchSingleCharReverse(): void + { + $stream = new Temp(); + $stream->add('0123456789'); + $stream->end(); + + $this->assertEquals(3, $stream->search('3', reverse: true)); + } + + public function testSearchMultiCharString(): void + { + $stream = new Temp(); + $stream->add('0123456789'); + $stream->rewind(); + + $this->assertEquals(3, $stream->search('34')); + } + + public function testSearchWithResetFalseMovesPosition(): void + { + $stream = new Temp(); + $stream->add('0123456789'); + $stream->rewind(); + + $this->assertEquals(5, $stream->search('5', reset: false)); + $this->assertEquals(5, $stream->pos()); + } + + public function testSearchNotFoundReturnsNull(): void + { + $stream = new Temp(); + $stream->add('0123456789'); + $stream->rewind(); + + $this->assertNull($stream->search('X')); + } + + public function testSearchNotFoundFromMiddle(): void + { + $stream = new Temp(); + $stream->add('0123456789'); + $stream->rewind(); + + $stream->search('5', reset: false); + $this->assertNull($stream->search('3', reset: false)); + } + + // -- getToChar -- + + public function testGetToCharSingleCharDelimiter(): void + { + $stream = new Temp(); + $stream->add('A B'); + $stream->rewind(); + + $this->assertEquals('A', $stream->getToChar(' ')); + $this->assertEquals('B', $stream->getToChar(' ')); + } + + public function testGetToCharMultiCharDelimiter(): void + { + $stream = new Temp(); + $stream->add("ABC\r\nDEF"); + $stream->rewind(); + + $this->assertEquals('ABC', $stream->getToChar("\r\n")); + } + + public function testGetToCharStripsRepeatedDelimiters(): void + { + $stream = new Temp(); + $stream->add('A B'); + $stream->rewind(); + + $this->assertEquals('A', $stream->getToChar(' ', all: true)); + $this->assertEquals('B', $stream->getToChar(' ', all: true)); + } + + public function testGetToCharAllFalseStopsAtFirst(): void + { + $stream = new Temp(); + $stream->add("A\n\n\nB"); + $stream->rewind(); + + $this->assertEquals('A', $stream->getToChar("\n", all: false)); + $this->assertEquals('', $stream->getToChar("\n", all: false)); + $this->assertEquals('', $stream->getToChar("\n", all: false)); + $this->assertEquals('B', $stream->getToChar("\n", all: false)); + } + + public function testGetToCharDelimiterNotFound(): void + { + $stream = new Temp(); + $stream->add('ABCDEF'); + $stream->rewind(); + + $this->assertEquals('ABCDEF', $stream->getToChar('XY')); + } + + public function testGetToCharLongStringAcrossBufferBoundary(): void + { + $long = str_repeat('A', 15000); + $stream = new Temp(); + $stream->add($long . "B\n"); + $stream->rewind(); + + $this->assertEquals($long, $stream->getToChar('B', all: false)); + } + + // -- EOL detection -- + + public function testGetEolDetectsLf(): void + { + $stream = new Temp(); + $stream->add("123\n456"); + + $this->assertEquals("\n", $stream->getEOL()); + } + + public function testGetEolDetectsCrlf(): void + { + $stream = new Temp(); + $stream->add("123\r\n456"); + + $this->assertEquals("\r\n", $stream->getEOL()); + } + + public function testGetEolReturnsNullWhenNone(): void + { + $stream = new Temp(); + $stream->add('123456'); + + $this->assertNull($stream->getEOL()); + } + + public function testGetEolDetectsLeadingLf(): void + { + $stream = new Temp(); + $stream->add("\n123456\n"); + + $this->assertEquals("\n", $stream->getEOL()); + } + + // -- UTF-8 mode -- + + public function testUtf8CharPropertyDefaultsFalse(): void + { + $stream = new Temp(); + $this->assertFalse($stream->utf8Char); + } + + public function testUtf8CharPropertyCanBeSet(): void + { + $stream = new Temp(); + $stream->utf8Char = true; + $this->assertTrue($stream->utf8Char); + } + + public function testGetCharReadsTwoByte(): void + { + $stream = new Temp(); + $stream->add('Aö'); + $stream->rewind(); + $stream->utf8Char = true; + + $this->assertEquals('A', $stream->getChar()); + $this->assertEquals('ö', $stream->getChar()); + } + + public function testGetCharReadsThreeByte(): void + { + $stream = new Temp(); + $stream->add('A€B'); + $stream->rewind(); + $stream->utf8Char = true; + + $this->assertEquals('A', $stream->getChar()); + $this->assertEquals('€', $stream->getChar()); + $this->assertEquals('B', $stream->getChar()); + } + + public function testGetCharReadsFourByte(): void + { + $stream = new Temp(); + $stream->add("A\xF0\x90\x8D\x88B"); + $stream->rewind(); + $stream->utf8Char = true; + + $this->assertEquals('A', $stream->getChar()); + $this->assertEquals("\xF0\x90\x8D\x88", $stream->getChar()); + $this->assertEquals('B', $stream->getChar()); + } + + public function testLengthUtf8CountsCharacters(): void + { + $stream = new Temp(); + $stream->add('Aönön'); + $stream->utf8Char = true; + + $this->assertEquals(7, $stream->length()); + $this->assertEquals(5, $stream->length(utf8: true)); + } + + public function testSeekUtf8CharMode(): void + { + $stream = new Temp(); + $stream->add('Aönön'); + $stream->utf8Char = true; + + $stream->seek(2, false, char: true); + $this->assertEquals(3, $stream->pos()); + + $stream->seek(2, true, char: true); + $this->assertEquals(6, $stream->pos()); + + $stream->seek(-2, true, char: true); + $this->assertEquals(3, $stream->pos()); + } + + public function testSubstringUtf8CharMode(): void + { + $stream = new Temp(); + $stream->add('AönönAönön'); + $stream->utf8Char = true; + $stream->rewind(); + + $this->assertEquals('Aön', $stream->substring(0, 3, char: true)); + } + + public function testGetCharThrowsOnInvalidLeadByte(): void + { + $stream = new Temp(); + $stream->add("\xFC"); + $stream->rewind(); + $stream->utf8Char = true; + + $this->expectException(StreamException::class); + $stream->getChar(); + } + + public function testGetCharThrowsOnTruncatedSequence(): void + { + $stream = new Temp(); + $stream->add("\xC3"); + $stream->rewind(); + $stream->utf8Char = true; + + $this->expectException(StreamException::class); + $stream->getChar(); + } + + // -- Lifecycle -- + + public function testToStringReturnsFullContent(): void + { + $stream = new Temp(); + $stream->add('hello'); + + $this->assertEquals('hello', (string) $stream); + } + + public function testGetResourceReturnsResource(): void + { + $stream = new Temp(); + $this->assertIsResource($stream->getResource()); + } + + public function testCloneProducesIndependentCopy(): void + { + $stream = new Temp(); + $stream->add('123'); + + $clone = clone $stream; + $stream->close(); + + $this->assertEquals('123', (string) $clone); + } + + public function testSerializeRoundTrip(): void + { + $stream = new Temp(); + $stream->add('hello'); + $stream->seek(2, false); + + $restored = unserialize(serialize($stream)); + + $this->assertEquals(2, $restored->pos()); + $this->assertEquals('hello', $restored->getString(0)); + } + + public function testCloseClosesResource(): void + { + $stream = new Temp(); + $resource = $stream->getResource(); + + $stream->close(); + $this->assertFalse(is_resource($resource)); + } + + // -- Large stream -- + + public function testLargeStreamAddAndRead(): void + { + $stream = new Temp(); + $stream->add(str_repeat('1234567890', 10000)); + $stream->rewind(); + + $this->assertEquals(100000, $stream->length()); + + $stream2 = new Temp(); + $stream2->add($stream); + $this->assertEquals(100000, $stream2->length()); + } +} diff --git a/test/unit/TempStringStreamTest.php b/test/unit/TempStringStreamTest.php new file mode 100644 index 0000000..45c77e1 --- /dev/null +++ b/test/unit/TempStringStreamTest.php @@ -0,0 +1,202 @@ +assertInstanceOf(StreamInterface::class, $stream); + $this->assertEquals(0, $stream->pos()); + } + + public function testStartsWithStringBackend(): void + { + $stream = new TempString(); + $this->assertFalse($stream->isUsingTempStream()); + } + + public function testGetResourceReturnsResource(): void + { + $stream = new TempString(); + $this->assertIsResource($stream->getResource()); + } + + // -- Before spill (string backend) -- + + public function testSmallAddStaysInStringBackend(): void + { + $stream = new TempString(); + $stream->add('hello'); + + $this->assertFalse($stream->isUsingTempStream()); + $this->assertEquals('hello', (string) $stream); + } + + public function testReadWriteSeekBeforeSpill(): void + { + $stream = new TempString(); + $stream->add('ABCDEF'); + $stream->seek(2, false); + + $this->assertEquals('CDEF', $stream->getString()); + $this->assertFalse($stream->isUsingTempStream()); + } + + public function testMultipleSmallAddsBeforeSpill(): void + { + $stream = new TempString(maxMemory: 1024); + $stream->add('aaa'); + $stream->add('bbb'); + + $this->assertFalse($stream->isUsingTempStream()); + $this->assertEquals('aaabbb', (string) $stream); + } + + // -- Spill transition -- + + public function testSpillOnExceedingMaxMemory(): void + { + $stream = new TempString(maxMemory: 10); + $stream->add('12345'); + $this->assertFalse($stream->isUsingTempStream()); + + $stream->add('67890EXTRA'); + $this->assertTrue($stream->isUsingTempStream()); + } + + public function testDataIntegrityAcrossSpill(): void + { + $stream = new TempString(maxMemory: 10); + $stream->add('ABCDE'); + $stream->add('FGHIJKLMNO'); + + $this->assertTrue($stream->isUsingTempStream()); + $this->assertEquals('ABCDEFGHIJKLMNO', (string) $stream); + } + + public function testPositionPreservedAcrossSpill(): void + { + $stream = new TempString(maxMemory: 20); + $stream->add('hello'); + + $stream->add(str_repeat('X', 30), reset: true); + + $this->assertTrue($stream->isUsingTempStream()); + $this->assertEquals(5, $stream->pos()); + $this->assertEquals('hello' . str_repeat('X', 30), (string) $stream); + } + + public function testSubsequentWritesAfterSpill(): void + { + $stream = new TempString(maxMemory: 10); + $stream->add(str_repeat('A', 20)); + $this->assertTrue($stream->isUsingTempStream()); + + $stream->add('MORE'); + $this->assertEquals(24, $stream->length()); + } + + // -- maxMemory parameter -- + + public function testCustomMaxMemory(): void + { + $stream = new TempString(maxMemory: 5); + $stream->add('1234'); + $this->assertFalse($stream->isUsingTempStream()); + + $stream->add('56'); + $this->assertTrue($stream->isUsingTempStream()); + } + + public function testMaxMemoryOneSpillsImmediately(): void + { + $stream = new TempString(maxMemory: 1); + $stream->add('AB'); + + $this->assertTrue($stream->isUsingTempStream()); + $this->assertEquals('AB', (string) $stream); + } + + // -- Non-string data bypasses string backend -- + + public function testAddResourceTriggersParentAdd(): void + { + $resource = fopen('php://temp', 'r+'); + fwrite($resource, 'from resource'); + fseek($resource, 0); + + $stream = new TempString(maxMemory: 1024); + $stream->add($resource); + + $this->assertEquals('from resource', $stream->getString(0)); + + fclose($resource); + } + + // -- Lifecycle -- + + public function testCloneBeforeSpillProducesIndependentCopy(): void + { + $stream = new TempString(); + $stream->add('data'); + + $clone = clone $stream; + $stream->close(); + + $this->assertFalse($clone->isUsingTempStream()); + $this->assertEquals('data', (string) $clone); + } + + public function testCloneAfterSpillProducesIndependentCopy(): void + { + $stream = new TempString(maxMemory: 5); + $stream->add('too long for string backend'); + + $clone = clone $stream; + $stream->close(); + + $this->assertTrue($clone->isUsingTempStream()); + $this->assertEquals('too long for string backend', (string) $clone); + } + + public function testCloseBeforeSpill(): void + { + $stream = new TempString(); + $stream->add('data'); + $resource = $stream->getResource(); + + $stream->close(); + $this->assertFalse(is_resource($resource)); + } + + public function testCloseAfterSpill(): void + { + $stream = new TempString(maxMemory: 1); + $stream->add('data'); + $resource = $stream->getResource(); + + $stream->close(); + $this->assertFalse(is_resource($resource)); + } + + public function testToString(): void + { + $stream = new TempString(); + $stream->add('hello'); + $this->assertEquals('hello', (string) $stream); + } +}