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

azjezz / psl / 23100454484

15 Mar 2026 01:16AM UTC coverage: 95.628% (-2.8%) from 98.421%
23100454484

Pull #629

github

azjezz
feat(encoding): introduce streaming IO handles for Base64, QuotedPrintable, and Hex

Signed-off-by: azjezz <azjezz@protonmail.com>
Pull Request #629: feat(encoding): introduce streaming IO handles for Base64, QuotedPrintable, and Hex

479 of 797 new or added lines in 13 files covered. (60.1%)

2 existing lines in 1 file now uncovered.

10455 of 10933 relevant lines covered (95.63%)

32.65 hits per line

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

69.49
/src/Psl/Encoding/Hex/DecodingReadHandle.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Psl\Encoding\Hex;
6

7
use Psl;
8
use Psl\Async\CancellationTokenInterface;
9
use Psl\Async\NullCancellationToken;
10
use Psl\Encoding\Exception;
11
use Psl\IO;
12

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

17
use const PHP_EOL;
18

19
/**
20
 * A read handle that decodes hex-encoded data from an inner readable handle.
21
 *
22
 * Reads chunks from the inner handle and decodes complete 2-byte hex pairs.
23
 * Buffers any odd trailing character for the next read.
24
 * On EOF, throws if there is an incomplete hex pair remaining.
25
 */
26
final class DecodingReadHandle implements IO\BufferedReadHandleInterface
27
{
28
    use IO\ReadHandleConvenienceMethodsTrait;
29

30
    private string $buffer = '';
31
    private string $remainder = '';
32
    private bool $eof = false;
33

34
    public function __construct(
35
        private readonly IO\ReadHandleInterface $handle,
36
    ) {}
16✔
37

38
    /**
39
     * {@inheritDoc}
40
     */
41
    public function reachedEndOfDataSource(): bool
42
    {
43
        return $this->eof && $this->buffer === '';
6✔
44
    }
45

46
    /**
47
     * {@inheritDoc}
48
     */
49
    public function tryRead(null|int $max_bytes = null): string
50
    {
NEW
51
        if ($this->buffer === '' && !$this->eof) {
×
NEW
52
            $this->fillBuffer();
×
53
        }
54

NEW
55
        if ($this->buffer === '') {
×
NEW
56
            return '';
×
57
        }
58

NEW
59
        if (null === $max_bytes || $max_bytes >= strlen($this->buffer)) {
×
NEW
60
            $result = $this->buffer;
×
NEW
61
            $this->buffer = '';
×
NEW
62
            return $result;
×
63
        }
64

NEW
65
        $result = substr($this->buffer, 0, $max_bytes);
×
NEW
66
        $this->buffer = substr($this->buffer, $max_bytes);
×
NEW
67
        return $result;
×
68
    }
69

70
    /**
71
     * {@inheritDoc}
72
     */
73
    public function read(
74
        null|int $max_bytes = null,
75
        CancellationTokenInterface $cancellation = new NullCancellationToken(),
76
    ): string {
77
        if ($this->eof && $this->buffer === '') {
7✔
NEW
78
            return '';
×
79
        }
80

81
        if ($this->buffer === '') {
7✔
82
            $this->fillBuffer($cancellation);
7✔
83
        }
84

85
        if ($this->buffer === '') {
6✔
86
            return '';
6✔
87
        }
88

89
        if (null === $max_bytes || $max_bytes >= strlen($this->buffer)) {
5✔
90
            $result = $this->buffer;
5✔
91
            $this->buffer = '';
5✔
92
            return $result;
5✔
93
        }
94

95
        $result = substr($this->buffer, 0, $max_bytes);
2✔
96
        $this->buffer = substr($this->buffer, $max_bytes);
2✔
97
        return $result;
2✔
98
    }
99

100
    public function readByte(CancellationTokenInterface $cancellation = new NullCancellationToken()): string
101
    {
102
        if ($this->buffer === '' && !$this->eof) {
4✔
103
            $this->fillBuffer($cancellation);
2✔
104
        }
105

106
        if ($this->buffer === '') {
4✔
107
            throw new IO\Exception\RuntimeException('Reached EOF without any more data.');
1✔
108
        }
109

110
        $ret = $this->buffer[0];
3✔
111
        if ($ret === $this->buffer) {
3✔
112
            $this->buffer = '';
1✔
113
            return $ret;
1✔
114
        }
115

116
        $this->buffer = substr($this->buffer, 1);
3✔
117
        return $ret;
3✔
118
    }
119

120
    public function readLine(CancellationTokenInterface $cancellation = new NullCancellationToken()): null|string
121
    {
122
        $line = $this->readUntil(PHP_EOL, $cancellation);
3✔
123
        if ($line !== null) {
3✔
124
            return $line;
2✔
125
        }
126

127
        // No EOL found; return whatever remains, or null if empty
128
        if ($this->buffer === '' && !$this->eof) {
2✔
NEW
129
            $this->fillBuffer($cancellation);
×
130
        }
131

132
        if ($this->buffer === '') {
2✔
133
            return null;
2✔
134
        }
135

136
        $result = $this->buffer;
2✔
137
        $this->buffer = '';
2✔
138
        return $result;
2✔
139
    }
140

141
    public function readUntil(
142
        string $suffix,
143
        CancellationTokenInterface $cancellation = new NullCancellationToken(),
144
    ): null|string {
145
        $suffix_len = strlen($suffix);
5✔
146
        $idx = strpos($this->buffer, $suffix);
5✔
147
        if ($idx !== false) {
5✔
148
            $result = substr($this->buffer, 0, $idx);
1✔
149
            $this->buffer = substr($this->buffer, $idx + $suffix_len);
1✔
150
            return $result;
1✔
151
        }
152

153
        while (!$this->eof) {
5✔
154
            $offset = strlen($this->buffer) - $suffix_len + 1;
5✔
155
            $offset = $offset > 0 ? $offset : 0;
5✔
156

157
            $this->fillBuffer($cancellation);
5✔
158

159
            $idx = strpos($this->buffer, $suffix, $offset);
5✔
160
            if ($idx !== false) {
5✔
161
                $result = substr($this->buffer, 0, $idx);
3✔
162
                $this->buffer = substr($this->buffer, $idx + $suffix_len);
3✔
163
                return $result;
3✔
164
            }
165
        }
166

167
        return null;
4✔
168
    }
169

170
    public function readUntilBounded(
171
        string $suffix,
172
        int $max_bytes,
173
        CancellationTokenInterface $cancellation = new NullCancellationToken(),
174
    ): null|string {
175
        $suffix_len = strlen($suffix);
3✔
176
        $idx = strpos($this->buffer, $suffix);
3✔
177
        if ($idx !== false) {
3✔
NEW
178
            if ($idx > $max_bytes) {
×
NEW
179
                throw new IO\Exception\OverflowException(Psl\Str\format(
×
NEW
180
                    'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
×
NEW
181
                    $max_bytes,
×
NEW
182
                    $suffix,
×
NEW
183
                ));
×
184
            }
185

NEW
186
            $result = substr($this->buffer, 0, $idx);
×
NEW
187
            $this->buffer = substr($this->buffer, $idx + $suffix_len);
×
NEW
188
            return $result;
×
189
        }
190

191
        if (strlen($this->buffer) > $max_bytes) {
3✔
NEW
192
            throw new IO\Exception\OverflowException(Psl\Str\format(
×
NEW
193
                'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
×
NEW
194
                $max_bytes,
×
NEW
195
                $suffix,
×
NEW
196
            ));
×
197
        }
198

199
        while (!$this->eof) {
3✔
200
            $offset = strlen($this->buffer) - $suffix_len + 1;
3✔
201
            $offset = $offset > 0 ? $offset : 0;
3✔
202

203
            $this->fillBuffer($cancellation);
3✔
204

205
            $idx = strpos($this->buffer, $suffix, $offset);
3✔
206
            if ($idx !== false) {
3✔
207
                if ($idx > $max_bytes) {
2✔
208
                    throw new IO\Exception\OverflowException(Psl\Str\format(
1✔
209
                        'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
1✔
210
                        $max_bytes,
1✔
211
                        $suffix,
1✔
212
                    ));
1✔
213
                }
214

215
                $result = substr($this->buffer, 0, $idx);
1✔
216
                $this->buffer = substr($this->buffer, $idx + $suffix_len);
1✔
217
                return $result;
1✔
218
            }
219

220
            if (strlen($this->buffer) > $max_bytes) {
1✔
NEW
221
                throw new IO\Exception\OverflowException(Psl\Str\format(
×
NEW
222
                    'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
×
NEW
223
                    $max_bytes,
×
NEW
224
                    $suffix,
×
NEW
225
                ));
×
226
            }
227
        }
228

229
        return null;
1✔
230
    }
231

232
    /**
233
     * @throws Exception\RangeException If the hex data contains invalid characters or has an odd length at EOF.
234
     */
235
    private function fillBuffer(CancellationTokenInterface $cancellation = new NullCancellationToken()): void
236
    {
237
        if ($this->eof) {
16✔
NEW
238
            return;
×
239
        }
240

241
        $chunk = $this->handle->read(4096, $cancellation);
16✔
242
        if ($chunk === '' && $this->handle->reachedEndOfDataSource()) {
16✔
243
            $this->eof = true;
12✔
244
            // Decode any remaining bytes.
245
            if ($this->remainder !== '') {
12✔
NEW
246
                $this->buffer .= decode($this->remainder);
×
NEW
247
                $this->remainder = '';
×
248
            }
249

250
            return;
12✔
251
        }
252

253
        $data = $this->remainder . $chunk;
14✔
254

255
        // Decode complete 2-byte hex pairs only.
256
        $length = strlen($data);
14✔
257
        $usable = $length - ($length % 2);
14✔
258

259
        if ($usable > 0) {
14✔
260
            $this->buffer .= decode(substr($data, 0, $usable));
14✔
261
            $this->remainder = substr($data, $usable);
13✔
262
        } else {
NEW
263
            $this->remainder = $data;
×
264
        }
265
    }
266
}
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