• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

azjezz / psl / 23021387759

12 Mar 2026 08:03PM UTC coverage: 98.413% (-0.1%) from 98.551%
23021387759

Pull #620

github

azjezz
feat(io): added `Reader::readUntilBounded()` method

Signed-off-by: azjezz <azjezz@protonmail.com>
Pull Request #620: feat(io): added `Reader::readUntilBounded()` method

31 of 47 new or added lines in 1 file covered. (65.96%)

9552 of 9706 relevant lines covered (98.41%)

34.71 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.6
/src/Psl/IO/Reader.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Psl\IO;
6

7
use Override;
8
use Psl\Async;
9
use Psl\DateTime\Duration;
10
use Psl\Str;
11

12
use function strlen;
13
use function strpos;
14
use function substr;
15

16
use const PHP_EOL;
17

18
final class Reader implements ReadHandleInterface
19
{
20
    use ReadHandleConvenienceMethodsTrait;
21

22
    private readonly ReadHandleInterface $handle;
23

24
    private bool $eof = false;
25
    private string $buffer = '';
26

27
    public function __construct(ReadHandleInterface $handle)
28
    {
29
        $this->handle = $handle;
25✔
30
    }
31

32
    /**
33
     * {@inheritDoc}
34
     *
35
     * @mago-expect lint:no-empty-catch-clause
36
     */
37
    #[Override]
38
    public function reachedEndOfDataSource(): bool
39
    {
40
        if ($this->eof) {
8✔
41
            return true;
2✔
42
        }
43

44
        if ('' !== $this->buffer) {
7✔
45
            return false;
1✔
46
        }
47

48
        // @codeCoverageIgnoreStart
49
        try {
50
            $this->buffer = $this->handle->read();
51
            if ('' === $this->buffer) {
52
                return $this->eof = $this->handle->reachedEndOfDataSource();
53
            }
54
        } catch (Exception\ExceptionInterface) {
55
            // ignore; it'll be thrown again when attempting a real read.
56
        }
57

58
        // @codeCoverageIgnoreEnd
59

60
        return false;
1✔
61
    }
62

63
    /**
64
     * {@inheritDoc}
65
     */
66
    #[Override]
67
    public function readFixedSize(int $size, null|Duration $timeout = null): string
68
    {
69
        $timer = new Async\OptionalIncrementalTimeout($timeout, function (): void {
3✔
70
            // @codeCoverageIgnoreStart
71
            throw new Exception\TimeoutException(Str\format(
72
                'Reached timeout before reading requested amount of data',
73
                '' === $this->buffer ? 'any' : 'all',
74
            ));
75
            // @codeCoverageIgnoreEnd
76
        });
3✔
77

78
        do {
79
            $length = strlen($this->buffer);
3✔
80
            if ($length >= $size || $this->eof) {
3✔
81
                break;
3✔
82
            }
83

84
            /** @var positive-int $to_read */
85
            $to_read = $size - $length;
2✔
86
            $this->fillBuffer($to_read, $timer->getRemaining());
2✔
87
        } while (true);
2✔
88

89
        if ($this->eof) {
3✔
90
            throw new Exception\RuntimeException('Reached end of file before requested size.');
1✔
91
        }
92

93
        $buffer_size = strlen($this->buffer);
2✔
94
        if ($size === $buffer_size) {
2✔
95
            $ret = $this->buffer;
1✔
96
            $this->buffer = '';
1✔
97
            return $ret;
1✔
98
        }
99

100
        $ret = substr($this->buffer, 0, $size);
1✔
101
        $this->buffer = substr($this->buffer, $size);
1✔
102
        return $ret;
1✔
103
    }
104

105
    /**
106
     * Read a single byte from the handle.
107
     *
108
     * @throws Exception\AlreadyClosedException If the handle has been already closed.
109
     * @throws Exception\RuntimeException If an error occurred during the operation, or reached end of file.
110
     * @throws Exception\TimeoutException If $timeout is reached before being able to read from the handle.
111
     */
112
    public function readByte(null|Duration $timeout = null): string
113
    {
114
        if ('' === $this->buffer && !$this->eof) {
3✔
115
            $this->fillBuffer(null, $timeout);
2✔
116
        }
117

118
        if ('' === $this->buffer) {
3✔
119
            throw new Exception\RuntimeException('Reached EOF without any more data.');
1✔
120
        }
121

122
        $ret = $this->buffer[0];
2✔
123
        if ($ret === $this->buffer) {
2✔
124
            $this->buffer = '';
1✔
125
            return $ret;
1✔
126
        }
127

128
        $this->buffer = substr($this->buffer, 1);
1✔
129
        return $ret;
1✔
130
    }
131

132
    /**
133
     * @return string|null the read data on success, or null if the end of file is reached before finding the current line terminator.
134
     *
135
     * @throws Exception\AlreadyClosedException If the handle has been already closed.
136
     * @throws Exception\RuntimeException If an error occurred during the operation.
137
     * @throws Exception\TimeoutException If $timeout is reached before being able to read from the handle.
138
     */
139
    public function readLine(null|Duration $timeout = null): null|string
140
    {
141
        $timer = new Async\OptionalIncrementalTimeout($timeout, static function (): void {
4✔
142
            // @codeCoverageIgnoreStart
143
            throw new Exception\TimeoutException(
144
                'Reached timeout before encountering reaching the current line terminator.',
145
            );
146
            // @codeCoverageIgnoreEnd
147
        });
4✔
148

149
        $line = $this->readUntil(PHP_EOL, $timer->getRemaining());
4✔
150
        if (null !== $line) {
4✔
151
            return $line;
1✔
152
        }
153

154
        $content = $this->read(null, $timer->getRemaining());
3✔
155
        return '' === $content ? null : $content;
3✔
156
    }
157

158
    /**
159
     * Read until the specified suffix is seen.
160
     *
161
     * The trailing suffix is read (so won't be returned by other calls), but is not
162
     * included in the return value.
163
     *
164
     * This call returns null if the suffix is not seen, even if there is other
165
     * data.
166
     *
167
     * @throws Exception\AlreadyClosedException If the handle has been already closed.
168
     * @throws Exception\RuntimeException If an error occurred during the operation.
169
     * @throws Exception\TimeoutException If $timeout is reached before being able to read from the handle.
170
     */
171
    public function readUntil(string $suffix, null|Duration $timeout = null): null|string
172
    {
173
        $buf = $this->buffer;
5✔
174
        $idx = strpos($buf, $suffix);
5✔
175
        $suffix_len = strlen($suffix);
5✔
176
        if (false !== $idx) {
5✔
177
            $this->buffer = substr($buf, $idx + $suffix_len);
1✔
178
            return substr($buf, 0, $idx);
1✔
179
        }
180

181
        $timer = new Async\OptionalIncrementalTimeout($timeout, static function () use ($suffix): void {
5✔
182
            // @codeCoverageIgnoreStart
183
            throw new Exception\TimeoutException(Str\format(
184
                "Reached timeout before encountering the suffix (\"%s\").",
185
                $suffix,
186
            ));
187
            // @codeCoverageIgnoreEnd
188
        });
5✔
189

190
        do {
191
            // + 1 as it would have been matched in the previous iteration if it
192
            // fully fit in the chunk
193
            $offset = strlen($buf) - $suffix_len + 1;
5✔
194
            $offset = $offset > 0 ? $offset : 0;
5✔
195
            $chunk = $this->handle->read(null, $timer->getRemaining());
5✔
196
            if ('' === $chunk) {
5✔
197
                $this->buffer = $buf;
4✔
198
                return null;
4✔
199
            }
200

201
            $buf .= $chunk;
3✔
202
            $idx = strpos($buf, $suffix, $offset);
3✔
203
        } while (false === $idx);
3✔
204

205
        $this->buffer = substr($buf, $idx + $suffix_len);
1✔
206

207
        return substr($buf, 0, $idx);
1✔
208
    }
209

210
    /**
211
     * Read until the specified suffix is seen, with a maximum number of bytes to read.
212
     *
213
     * The trailing suffix is read (so won't be returned by other calls), but is not
214
     * included in the return value.
215
     *
216
     * This call returns null if the suffix is not seen before EOF.
217
     *
218
     * @param positive-int $max_bytes Maximum number of bytes to read before throwing OverflowException.
219
     *
220
     * @throws Exception\AlreadyClosedException If the handle has been already closed.
221
     * @throws Exception\RuntimeException If an error occurred during the operation.
222
     * @throws Exception\TimeoutException If $timeout is reached before being able to read from the handle.
223
     * @throws Exception\OverflowException If $max_bytes is exceeded without finding the suffix.
224
     */
225
    public function readUntilBounded(string $suffix, int $max_bytes, null|Duration $timeout = null): null|string
226
    {
227
        $buf = $this->buffer;
11✔
228
        $suffix_len = strlen($suffix);
11✔
229
        $idx = strpos($buf, $suffix);
11✔
230
        if (false !== $idx) {
11✔
231
            if ($idx > $max_bytes) {
1✔
NEW
232
                throw new Exception\OverflowException(Str\format(
×
NEW
233
                    'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
×
NEW
234
                    $max_bytes,
×
NEW
235
                    $suffix,
×
NEW
236
                ));
×
237
            }
238

239
            $this->buffer = substr($buf, $idx + $suffix_len);
1✔
240
            return substr($buf, 0, $idx);
1✔
241
        }
242

243
        if (strlen($buf) > $max_bytes) {
11✔
NEW
244
            throw new Exception\OverflowException(Str\format(
×
NEW
245
                'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
×
NEW
246
                $max_bytes,
×
NEW
247
                $suffix,
×
NEW
248
            ));
×
249
        }
250

251
        $timer = new Async\OptionalIncrementalTimeout($timeout, static function () use ($suffix): void {
11✔
252
            // @codeCoverageIgnoreStart
253
            throw new Exception\TimeoutException(Str\format(
254
                "Reached timeout before encountering the suffix (\"%s\").",
255
                $suffix,
256
            ));
257
            // @codeCoverageIgnoreEnd
258
        });
11✔
259

260
        do {
261
            $offset = strlen($buf) - $suffix_len + 1;
11✔
262
            $offset = $offset > 0 ? $offset : 0;
11✔
263
            $chunk = $this->handle->read(null, $timer->getRemaining());
11✔
264
            if ('' === $chunk) {
11✔
265
                $this->buffer = $buf;
3✔
266
                return null;
3✔
267
            }
268

269
            $buf .= $chunk;
10✔
270
            $idx = strpos($buf, $suffix, $offset);
10✔
271

272
            if (false !== $idx) {
10✔
273
                if ($idx > $max_bytes) {
9✔
274
                    $this->buffer = $buf;
3✔
275
                    throw new Exception\OverflowException(Str\format(
3✔
276
                        'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
3✔
277
                        $max_bytes,
3✔
278
                        $suffix,
3✔
279
                    ));
3✔
280
                }
281

282
                break;
6✔
283
            }
284

285
            if (strlen($buf) > $max_bytes) {
1✔
NEW
286
                $this->buffer = $buf;
×
NEW
287
                throw new Exception\OverflowException(Str\format(
×
NEW
288
                    'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
×
NEW
289
                    $max_bytes,
×
NEW
290
                    $suffix,
×
NEW
291
                ));
×
292
            }
293
        } while (true);
1✔
294

295
        /** @var int<0, max> $idx*/
296
        $this->buffer = substr($buf, $idx + $suffix_len);
6✔
297

298
        return substr($buf, 0, $idx);
6✔
299
    }
300

301
    /**
302
     * {@inheritDoc}
303
     */
304
    #[Override]
305
    public function read(null|int $max_bytes = null, null|Duration $timeout = null): string
306
    {
307
        if ($this->eof) {
11✔
308
            return '';
2✔
309
        }
310

311
        if ('' === $this->buffer) {
11✔
312
            $this->fillBuffer(null, $timeout);
5✔
313
        }
314

315
        // We either have a buffer, or reached EOF; either way, behavior matches
316
        // read, so just delegate
317
        return $this->tryRead($max_bytes);
11✔
318
    }
319

320
    /**
321
     * {@inheritDoc}
322
     */
323
    #[Override]
324
    public function tryRead(null|int $max_bytes = null): string
325
    {
326
        if ($this->eof) {
12✔
327
            return '';
4✔
328
        }
329

330
        if ('' === $this->buffer) {
11✔
331
            $this->buffer = $this->getHandle()->tryRead();
1✔
332
            if ('' === $this->buffer) {
1✔
333
                return '';
1✔
334
            }
335
        }
336

337
        $buffer = $this->buffer;
10✔
338
        if (null === $max_bytes || $max_bytes >= strlen($buffer)) {
10✔
339
            $this->buffer = '';
10✔
340
            return $buffer;
10✔
341
        }
342

343
        $this->buffer = substr($buffer, $max_bytes);
1✔
344

345
        return substr($buffer, 0, $max_bytes);
1✔
346
    }
347

348
    public function getHandle(): ReadHandleInterface
349
    {
350
        return $this->handle;
2✔
351
    }
352

353
    /**
354
     * @param null|positive-int $desired_bytes
355
     *
356
     * @throws Exception\AlreadyClosedException If the handle has been already closed.
357
     * @throws Exception\RuntimeException If an error occurred during the operation.
358
     * @throws Exception\TimeoutException If $timeout is reached before being able to read from the handle.
359
     */
360
    private function fillBuffer(null|int $desired_bytes, null|Duration $timeout): void
361
    {
362
        $chunk = $this->handle->read($desired_bytes, $timeout);
8✔
363
        $this->buffer .= $chunk;
8✔
364
        if ('' === $chunk) {
8✔
365
            $this->eof = $this->handle->reachedEndOfDataSource();
6✔
366
        }
367
    }
368
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc