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

azjezz / psl / 23100354439

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

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

68.6
/src/Psl/Encoding/QuotedPrintable/DecodingReadHandle.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Psl\Encoding\QuotedPrintable;
6

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

12
use function quoted_printable_decode;
13
use function str_ends_with;
14
use function strlen;
15
use function strpos;
16
use function substr;
17

18
use const PHP_EOL;
19

20
/**
21
 * A read handle that decodes quoted-printable data from an inner readable handle.
22
 *
23
 * Reads lines from the inner handle, joins soft-break continuations (lines ending with `=`),
24
 * and decodes complete logical lines via {@see quoted_printable_decode()}.
25
 */
26
final class DecodingReadHandle implements IO\BufferedReadHandleInterface
27
{
28
    use IO\ReadHandleConvenienceMethodsTrait;
29

30
    private IO\Reader $reader;
31
    private string $buffer = '';
32
    private string $accumulated = '';
33
    private bool $eof = false;
34
    private bool $hardBreakPending = false;
35

36
    public function __construct(IO\ReadHandleInterface $handle)
37
    {
38
        $this->reader = new IO\Reader($handle);
16✔
39
    }
40

41
    public function reachedEndOfDataSource(): bool
42
    {
43
        return $this->eof && $this->buffer === '';
6✔
44
    }
45

46
    public function tryRead(null|int $max_bytes = null): string
47
    {
48
        if ($this->buffer === '' && !$this->eof) {
6✔
NEW
49
            $this->fillBuffer();
×
50
        }
51

52
        if ($this->buffer === '') {
6✔
53
            return '';
1✔
54
        }
55

56
        if (null === $max_bytes || $max_bytes >= strlen($this->buffer)) {
5✔
57
            $result = $this->buffer;
5✔
58
            $this->buffer = '';
5✔
59
            return $result;
5✔
60
        }
61

NEW
62
        $result = substr($this->buffer, 0, $max_bytes);
×
NEW
63
        $this->buffer = substr($this->buffer, $max_bytes);
×
NEW
64
        return $result;
×
65
    }
66

67
    public function read(
68
        null|int $max_bytes = null,
69
        CancellationTokenInterface $cancellation = new NullCancellationToken(),
70
    ): string {
71
        if ($this->eof && $this->buffer === '') {
6✔
NEW
72
            return '';
×
73
        }
74

75
        if ($this->buffer === '') {
6✔
76
            $this->fillBuffer($cancellation);
5✔
77
        }
78

79
        return $this->tryRead($max_bytes);
6✔
80
    }
81

82
    public function readByte(CancellationTokenInterface $cancellation = new NullCancellationToken()): string
83
    {
84
        if ($this->buffer === '' && !$this->eof) {
5✔
85
            $this->fillBuffer($cancellation);
4✔
86
        }
87

88
        if ($this->buffer === '') {
5✔
89
            throw new IO\Exception\RuntimeException('Reached EOF without any more data.');
1✔
90
        }
91

92
        $ret = $this->buffer[0];
4✔
93
        if ($ret === $this->buffer) {
4✔
94
            $this->buffer = '';
3✔
95
            return $ret;
3✔
96
        }
97

98
        $this->buffer = substr($this->buffer, 1);
4✔
99
        return $ret;
4✔
100
    }
101

102
    public function readLine(CancellationTokenInterface $cancellation = new NullCancellationToken()): null|string
103
    {
NEW
104
        $line = $this->readUntil(PHP_EOL, $cancellation);
×
NEW
105
        if ($line !== null) {
×
NEW
106
            return $line;
×
107
        }
108

109
        // No EOL found; return whatever remains, or null if empty
NEW
110
        if ($this->buffer === '' && !$this->eof) {
×
NEW
111
            $this->fillBuffer($cancellation);
×
112
        }
113

NEW
114
        if ($this->buffer === '') {
×
NEW
115
            return null;
×
116
        }
117

NEW
118
        $result = $this->buffer;
×
NEW
119
        $this->buffer = '';
×
NEW
120
        return $result;
×
121
    }
122

123
    public function readUntil(
124
        string $suffix,
125
        CancellationTokenInterface $cancellation = new NullCancellationToken(),
126
    ): null|string {
127
        $suffix_len = strlen($suffix);
4✔
128
        $idx = strpos($this->buffer, $suffix);
4✔
129
        if ($idx !== false) {
4✔
NEW
130
            $result = substr($this->buffer, 0, $idx);
×
NEW
131
            $this->buffer = substr($this->buffer, $idx + $suffix_len);
×
NEW
132
            return $result;
×
133
        }
134

135
        while (!$this->eof) {
4✔
136
            $offset = strlen($this->buffer) - $suffix_len + 1;
4✔
137
            $offset = $offset > 0 ? $offset : 0;
4✔
138

139
            $this->fillBuffer($cancellation);
4✔
140

141
            $idx = strpos($this->buffer, $suffix, $offset);
4✔
142
            if ($idx !== false) {
4✔
143
                $result = substr($this->buffer, 0, $idx);
3✔
144
                $this->buffer = substr($this->buffer, $idx + $suffix_len);
3✔
145
                return $result;
3✔
146
            }
147
        }
148

149
        return null;
2✔
150
    }
151

152
    public function readUntilBounded(
153
        string $suffix,
154
        int $max_bytes,
155
        CancellationTokenInterface $cancellation = new NullCancellationToken(),
156
    ): null|string {
157
        $suffix_len = strlen($suffix);
3✔
158
        $idx = strpos($this->buffer, $suffix);
3✔
159
        if ($idx !== false) {
3✔
NEW
160
            if ($idx > $max_bytes) {
×
NEW
161
                throw new IO\Exception\OverflowException(Psl\Str\format(
×
NEW
162
                    'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
×
NEW
163
                    $max_bytes,
×
NEW
164
                    $suffix,
×
NEW
165
                ));
×
166
            }
167

NEW
168
            $result = substr($this->buffer, 0, $idx);
×
NEW
169
            $this->buffer = substr($this->buffer, $idx + $suffix_len);
×
NEW
170
            return $result;
×
171
        }
172

173
        if (strlen($this->buffer) > $max_bytes) {
3✔
NEW
174
            throw new IO\Exception\OverflowException(Psl\Str\format(
×
NEW
175
                'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
×
NEW
176
                $max_bytes,
×
NEW
177
                $suffix,
×
NEW
178
            ));
×
179
        }
180

181
        while (!$this->eof) {
3✔
182
            $offset = strlen($this->buffer) - $suffix_len + 1;
3✔
183
            $offset = $offset > 0 ? $offset : 0;
3✔
184

185
            $this->fillBuffer($cancellation);
3✔
186

187
            $idx = strpos($this->buffer, $suffix, $offset);
3✔
188
            if ($idx !== false) {
3✔
189
                if ($idx > $max_bytes) {
2✔
190
                    throw new IO\Exception\OverflowException(Psl\Str\format(
1✔
191
                        'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
1✔
192
                        $max_bytes,
1✔
193
                        $suffix,
1✔
194
                    ));
1✔
195
                }
196

197
                $result = substr($this->buffer, 0, $idx);
1✔
198
                $this->buffer = substr($this->buffer, $idx + $suffix_len);
1✔
199
                return $result;
1✔
200
            }
201

202
            if (strlen($this->buffer) > $max_bytes) {
1✔
NEW
203
                throw new IO\Exception\OverflowException(Psl\Str\format(
×
NEW
204
                    'Exceeded maximum byte limit (%d) before encountering the suffix ("%s").',
×
NEW
205
                    $max_bytes,
×
NEW
206
                    $suffix,
×
NEW
207
                ));
×
208
            }
209
        }
210

211
        return null;
1✔
212
    }
213

214
    private function fillBuffer(CancellationTokenInterface $cancellation = new NullCancellationToken()): void
215
    {
216
        if ($this->eof) {
16✔
NEW
217
            return;
×
218
        }
219

220
        while (true) {
16✔
221
            $line = $this->reader->readUntil("\n", $cancellation);
16✔
222
            if ($line === null) {
16✔
223
                $remaining = $this->reader->read(null, $cancellation);
16✔
224
                if ($remaining !== '') {
16✔
225
                    $this->accumulated .= str_ends_with($remaining, "\r") ? substr($remaining, 0, -1) : $remaining;
14✔
226
                }
227

228
                if ($this->accumulated !== '') {
16✔
229
                    if ($this->hardBreakPending) {
14✔
230
                        $this->buffer .= "\r\n";
4✔
231
                    }
232

233
                    $this->buffer .= quoted_printable_decode($this->accumulated);
14✔
234
                    $this->accumulated = '';
14✔
235
                }
236

237
                $this->eof = true;
16✔
238

239
                return;
16✔
240
            }
241

242
            $line = str_ends_with($line, "\r") ? substr($line, 0, -1) : $line;
6✔
243

244
            if (str_ends_with($line, '=')) {
6✔
245
                /** @var non-negative-int $len */
246
                $len = strlen($line) - 1;
2✔
247
                $this->accumulated .= substr($line, 0, $len);
2✔
248

249
                continue;
2✔
250
            }
251

252
            $this->accumulated .= $line;
4✔
253

254
            if ($this->hardBreakPending) {
4✔
255
                $this->buffer .= "\r\n";
1✔
256
            }
257

258
            $this->buffer .= quoted_printable_decode($this->accumulated);
4✔
259
            $this->accumulated = '';
4✔
260
            $this->hardBreakPending = true;
4✔
261

262
            return;
4✔
263
        }
264
    }
265
}
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