Lines 76.19% 112 / 147
Methods 57.14% 12 / 21
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 3 / 3 100.00% 1 / 1 1
 fromString 100.00% 7 / 7 100.00% 1 / 1 1
 fromResource 100.00% 10 / 10 100.00% 1 / 1 2
 isReadableMode 100.00% 1 / 1 100.00% 1 / 1 2
 isWritableMode 100.00% 5 / 5 100.00% 1 / 1 5
 __toString 71.42% 5 / 7 0.00% 0 / 1 4.37
 close 100.00% 11 / 11 100.00% 1 / 1 3
 __destruct 100.00% 1 / 1 100.00% 1 / 1 1
 detach 100.00% 9 / 9 100.00% 1 / 1 1
 getSize 45.45% 5 / 11 0.00% 0 / 1 9.06
 tell 37.50% 3 / 8 0.00% 0 / 1 7.91
 eof 80.00% 4 / 5 0.00% 0 / 1 3.07
 isSeekable 100.00% 1 / 1 100.00% 1 / 1 1
 seek 65.00% 13 / 20 0.00% 0 / 1 18.17
 rewind 100.00% 1 / 1 100.00% 1 / 1 1
 isWritable 100.00% 1 / 1 100.00% 1 / 1 1
 write 72.72% 8 / 11 0.00% 0 / 1 5.51
 isReadable 100.00% 1 / 1 100.00% 1 / 1 1
 read 71.42% 10 / 14 0.00% 0 / 1 6.84
 getContents 75.00% 9 / 12 0.00% 0 / 1 5.39
 getMetadata 50.00% 4 / 8 0.00% 0 / 1 6.00
16final class Stream implements StreamInterface
17{
18    /** @var resource|null Underlying PHP stream resource */
19    private $stream = null;
20
21    /** @var string|null In-memory content for string-backed streams */
22    private ?string $content = null;
23
24    /** @var int Position in in-memory content */
25    private int $position = 0;
26
27    /** @var bool Whether the stream is readable */
28    private bool $readable;
29
30    /** @var bool Whether the stream is writable */
31    private bool $writable;
32
33    /** @var bool Whether the stream is seekable */
34    private bool $seekable;
35
36    /** @var int|null Cached size */
37    private ?int $size = null;
38
39    /**
40     * Private constructor — use fromString() or fromResource() instead.
41     */
42    private function __construct()
43    {
44        $this->readable = false;
45        $this->writable = false;
46        $this->seekable = false;
47    }
48
49    /**
50     * Create a stream from a string.
51     *
52     * @param string $content The string content to wrap
53     * @return self
54     */
55    public static function fromString(string $content): self
56    {
57        $stream = new self();
58        $stream->content = $content;
59        $stream->position = 0;
60        $stream->readable = true;
61        $stream->seekable = true;
62        $stream->size = strlen($content);
63        return $stream;
64    }
65
66    /**
67     * Create a stream from a PHP resource.
68     *
69     * @param resource $resource A PHP stream resource
70     * @return self
71     * @throws \InvalidArgumentException If the given value is not a resource
72     */
73    public static function fromResource($resource): self
74    {
75        if (!is_resource($resource)) {
76            throw new \InvalidArgumentException('Stream resource must be a valid PHP resource');
77        }
78
79        $stream = new self();
80        $stream->stream = $resource;
81        $meta = stream_get_meta_data($resource);
82        $mode = $meta['mode'] ?? '';
83        $stream->seekable = $meta['seekable'] ?? false;
84        $stream->readable = self::isReadableMode($mode);
85        $stream->writable = self::isWritableMode($mode);
86        return $stream;
87    }
88
89    /**
90     * Determine if a stream mode is readable.
91     *
92     * @see https://www.php.net/manual/en/function.fopen.php
93     */
94    private static function isReadableMode(string $mode): bool
95    {
96        return str_starts_with($mode, 'r') || str_contains($mode, '+');
97    }
98
99    /**
100     * Determine if a stream mode is writable.
101     *
102     * @see https://www.php.net/manual/en/function.fopen.php
103     */
104    private static function isWritableMode(string $mode): bool
105    {
106        return str_starts_with($mode, 'a')
107            || str_starts_with($mode, 'w')
108            || str_starts_with($mode, 'x')
109            || str_starts_with($mode, 'c')
110            || str_contains($mode, '+');
111    }
112
113    public function __toString(): string
114    {
115        if ($this->stream !== null) {
116            try {
117                if ($this->isSeekable()) {
118                    $this->seek(0);
119                }
120                return $this->getContents();
121            } catch (\Throwable) {
122                return '';
123            }
124        }
125
126        return $this->content ?? '';
127    }
128
129    public function close(): void
130    {
131        if ($this->stream !== null) {
132            if (is_resource($this->stream)) {
133                fclose($this->stream);
134            }
135            $this->detach();
136            return;
137        }
138
139        $this->content = null;
140        $this->position = 0;
141        $this->size = null;
142        $this->readable = false;
143        $this->writable = false;
144        $this->seekable = false;
145    }
146
147    /**
148     * Closes the stream on destruction.
149     *
150     * @return void
151     */
152    public function __destruct()
153    {
154        $this->close();
155    }
156
157    public function detach()
158    {
159        $resource = $this->stream;
160        $this->stream = null;
161        $this->content = null;
162        $this->position = 0;
163        $this->size = null;
164        $this->readable = false;
165        $this->writable = false;
166        $this->seekable = false;
167        return $resource;
168    }
169
170    public function getSize(): ?int
171    {
172        if ($this->size !== null) {
173            return $this->size;
174        }
175
176        if ($this->stream !== null) {
177            $stats = fstat($this->stream);
178            if ($stats !== false) {
179                $this->size = $stats['size'];
180                return $this->size;
181            }
182        }
183
184        if ($this->content !== null) {
185            $this->size = strlen($this->content);
186            return $this->size;
187        }
188
189        return null;
190    }
191
192    public function tell(): int
193    {
194        if ($this->stream !== null) {
195            $position = ftell($this->stream);
196            if ($position === false) {
197                throw new RuntimeException('Unable to determine stream position');
198            }
199            return $position;
200        }
201
202        if ($this->content !== null) {
203            return $this->position;
204        }
205
206        throw new RuntimeException('Stream is closed or unusable');
207    }
208
209    public function eof(): bool
210    {
211        if ($this->stream !== null) {
212            return feof($this->stream);
213        }
214
215        if ($this->content !== null) {
216            return $this->position >= strlen($this->content);
217        }
218
219        return true;
220    }
221
222    public function isSeekable(): bool
223    {
224        return $this->seekable;
225    }
226
227    public function seek(int $offset, int $whence = SEEK_SET): void
228    {
229        if ($this->stream !== null) {
230            if (! $this->seekable) {
231                throw new RuntimeException('Stream is not seekable');
232            }
233            if (fseek($this->stream, $offset, $whence) !== 0) {
234                throw new RuntimeException('Unable to seek to position');
235            }
236            return;
237        }
238
239        if ($this->content !== null) {
240            if (! $this->seekable) {
241                throw new RuntimeException('Stream is not seekable');
242            }
243            $length = strlen($this->content);
244            $newPosition = match ($whence) {
245                SEEK_SET => $offset,
246                SEEK_CUR => $this->position + $offset,
247                SEEK_END => $length + $offset,
248                default => throw new RuntimeException("Invalid whence value: $whence"),
249            };
250            // An out-of-range position is a seek failure — throw rather
251            // than silently clamp.
252            if ($newPosition < 0 || $newPosition > $length) {
253                throw new RuntimeException("Unable to seek to position $newPosition (stream length: $length)");
254            }
255            $this->position = $newPosition;
256            return;
257        }
258
259        throw new RuntimeException('Stream is closed or unusable');
260    }
261
262    public function rewind(): void
263    {
264        $this->seek(0);
265    }
266
267    public function isWritable(): bool
268    {
269        return $this->writable;
270    }
271
272    public function write(string $string): int
273    {
274        if ($this->stream !== null) {
275            if (! $this->writable) {
276                throw new RuntimeException('Stream is not writable');
277            }
278            $bytes = fwrite($this->stream, $string);
279            if ($bytes === false) {
280                throw new RuntimeException('Unable to write to stream');
281            }
282            $this->size = null;
283            return $bytes;
284        }
285
286        if ($this->content !== null) {
287            throw new RuntimeException('String-backed streams are not writable');
288        }
289
290        throw new RuntimeException('Stream is closed or unusable');
291    }
292
293    public function isReadable(): bool
294    {
295        return $this->readable;
296    }
297
298    public function read(int $length): string
299    {
300        if ($this->stream !== null) {
301            if (! $this->readable) {
302                throw new RuntimeException('Stream is not readable');
303            }
304            $data = fread($this->stream, $length);
305            if ($data === false) {
306                throw new RuntimeException('Unable to read from stream');
307            }
308            return $data;
309        }
310
311        if ($this->content !== null) {
312            if (! $this->readable) {
313                throw new RuntimeException('Stream is not readable');
314            }
315            $data = substr($this->content, $this->position, $length);
316            $this->position += strlen($data);
317            return $data;
318        }
319
320        throw new RuntimeException('Stream is closed or unusable');
321    }
322
323    public function getContents(): string
324    {
325        if ($this->stream !== null) {
326            if (! $this->readable) {
327                throw new RuntimeException('Stream is not readable');
328            }
329            $contents = stream_get_contents($this->stream);
330            if ($contents === false) {
331                throw new RuntimeException('Unable to read stream contents');
332            }
333            return $contents;
334        }
335
336        if ($this->content !== null) {
337            $data = substr($this->content, $this->position);
338            $this->position = strlen($this->content);
339            return $data;
340        }
341
342        throw new RuntimeException('Stream is closed or unusable');
343    }
344
345    public function getMetadata(?string $key = null)
346    {
347        if ($this->stream !== null) {
348            $meta = stream_get_meta_data($this->stream);
349            if ($key === null) {
350                return $meta;
351            }
352            return $meta[$key] ?? null;
353        }
354
355        if ($key === null) {
356            return [];
357        }
358
359        return null;
360    }
361}