Test Failed
Branch Experiments (b14bae)
by Josh
02:03
created

Decoder   A

Complexity

Total Complexity 40

Size/Duplication

Total Lines 297
Duplicated Lines 0 %

Test Coverage

Coverage 98.41%

Importance

Changes 26
Bugs 0 Features 0
Metric Value
eloc 111
dl 0
loc 297
ccs 124
cts 126
cp 0.9841
rs 9.2
c 26
b 0
f 0
wmc 40

17 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 6 1
A decode() 0 15 2
A convertTypeError() 0 12 2
A checkCursorPosition() 0 12 3
A decodeAnything() 0 8 1
A castInteger() 0 9 3
A complianceError() 0 3 1
A checkBoundary() 0 10 2
A decodeInteger() 0 19 3
A decodeFastString() 0 17 3
A dictionaryComplianceError() 0 7 2
A decodeDictionary() 0 37 5
A readDigits() 0 27 4
A digitException() 0 6 2
A decodeList() 0 18 3
A decodeString() 0 7 1
A getSafeBoundary() 0 10 2

How to fix   Complexity   

Complex Class

Complex classes like Decoder often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Decoder, and based on these observations, apply Extract Interface, too.

1
<?php declare(strict_types=1);
2
3
/**
4
* @package   s9e\Bencode
5
* @copyright Copyright (c) The s9e authors
6
* @license   http://www.opensource.org/licenses/mit-license.php The MIT License
7
*/
8
namespace s9e\Bencode;
9
10
use ArrayObject;
11
use Throwable;
12
use TypeError;
13
use const PHP_INT_MAX, PHP_INT_MIN;
14
use function is_int, preg_match, str_contains, str_ends_with, strcmp, strlen, strspn, substr, substr_compare;
15
use s9e\Bencode\Exceptions\ComplianceError;
16
use s9e\Bencode\Exceptions\DecodingException;
17
18
class Decoder
19
{
20
	/**
21
	* @var int Length of the bencoded string
22
	*/
23
	protected int $len;
24
25
	/**
26
	* @var int Safe rightmost boundary
27
	*/
28
	protected int $max;
29
30
	/**
31
	* @var int Position of the cursor while decoding
32
	*/
33
	protected int $offset = 0;
34
35
	/**
36
	* @param string $bencoded Bencoded string to decode
37
	*/
38 108
	public static function decode(string $bencoded): ArrayObject|array|int|string
39
	{
40 108
		$decoder = new static($bencoded);
41
		try
42
		{
43 92
			$value = $decoder->decodeAnything();
44
		}
45 44
		catch (TypeError $e)
46
		{
47 3
			throw static::convertTypeError($e, $decoder->offset);
48
		}
49
50 48
		$decoder->checkCursorPosition();
51
52 40
		return $value;
53
	}
54
55
	/**
56
	* @param string $bencoded Bencoded string being decoded
57
	*/
58 108
	final protected function __construct(protected readonly string $bencoded)
59
	{
60 108
		$this->len = strlen($bencoded);
61 108
		$this->max = $this->getSafeBoundary();
62
63 108
		$this->checkBoundary();
64
	}
65
66
	/**
67
	* Cast given string as an integer and check for clamping
68
	*/
69 31
	final protected function castInteger(string $string, int $clamp): int
70
	{
71 31
		$value = (int) $string;
72 31
		if ($value === $clamp && !is_int(+$string))
73
		{
74 2
			throw new DecodingException('Integer overflow', $this->offset - 1 - strlen($string));
75
		}
76
77 29
		return $value;
78
	}
79
80 108
	protected function checkBoundary(): void
81
	{
82 108
		if ($this->max < 1)
83
		{
84 16
			throw match (substr($this->bencoded, 0, 1))
85
			{
86 1
				''       => new DecodingException('Premature end of data', 0),
87 1
				'-'      => new DecodingException('Illegal character',     0),
88 1
				'e'      => new DecodingException('Illegal character',     0),
89 13
				default  => new DecodingException('Premature end of data', $this->len - 1)
90
			};
91
		}
92
	}
93
94
	/**
95
	* Check the cursor's position after decoding is done
96
	*/
97 48
	protected function checkCursorPosition(): void
98
	{
99 48
		if ($this->offset === $this->len)
100
		{
101 37
			return;
102
		}
103 11
		if ($this->offset > $this->len)
104
		{
105 3
			throw new DecodingException('Premature end of data', $this->len - 1);
106
		}
107
108 8
		$this->complianceError('Superfluous content', $this->offset);
109
	}
110
111 11
	protected function complianceError(string $message, int $offset): void
112
	{
113 11
		throw new ComplianceError($message, $offset);
114
	}
115
116 3
	protected static function convertTypeError(TypeError $e, int $offset): Throwable
117
	{
118
		// A type error can occur in decodeString() if the string length exceeds an int
119 3
		$frame  = $e->getTrace()[0];
120 3
		$caller = $frame['class'] . $frame['type'] . $frame['function'];
121 3
		if ($caller === __CLASS__ . '->decodeString')
122
		{
123 1
			return new DecodingException('String length overflow', $offset - 1);
124
		}
125
126
		// Return any other error as-is
127 2
		return $e;
128
	}
129
130 92
	protected function decodeAnything(): ArrayObject|array|int|string
131
	{
132 92
		return match ($this->bencoded[$this->offset])
133 92
		{
134 92
			'i'     => $this->decodeInteger(),
135 92
			'd'     => $this->decodeDictionary(),
136 92
			'l'     => $this->decodeList(),
137 92
			default => $this->decodeString()
138 92
		};
139
	}
140
141 34
	protected function decodeDictionary(): ArrayObject
142
	{
143 34
		$values  = [];
144 34
		$lastKey = '';
145
146 34
		++$this->offset;
147 33
		while ($this->offset <= $this->max)
148
		{
149
			// Quickly match the most common keys found in dictionaries
150 32
			$key = match ($this->bencoded[$this->offset])
151 32
			{
152 32
				'4'     => $this->decodeFastString('4:path',      6, 'path'    ),
153 32
				'6'     => $this->decodeFastString('6:length',    8, 'length'  ),
154 32
				'8'     => $this->decodeFastString('8:announce', 10, 'announce'),
155 32
				'5'     => $this->decodeFastString('5:files',     7, 'files'   ),
156 32
				'e'     => null,
157 32
				default => $this->decodeString()
158 32
			};
159 26
			if (!isset($key))
160
			{
161 16
				++$this->offset;
162
163 16
				return new ArrayObject($values, ArrayObject::ARRAY_AS_PROPS);
164
			}
165 25
			if (strcmp($lastKey, $key) >= 0)
166
			{
167 8
				$this->dictionaryComplianceError($key, $lastKey);
168
			}
169
170 25
			if ($this->offset <= $this->max)
171
			{
172 23
				$values[$key] = $this->decodeAnything();
173 19
				$lastKey      = $key;
174
			}
175
		}
176
177 4
		throw new DecodingException('Premature end of data', $this->len - 1);
178
	}
179
180
	/**
181
	* @param string $match Bencoded string to match
182
	* @param int    $len   Length of the bencoded string
183
	* @param string $value String value to return if the string matches
184
	*/
185
	protected function decodeFastString(string $match, int $len, string $value): string
186
	{
187
		if (substr_compare($this->bencoded, $match, $this->offset, $len, false) === 0)
188
		{
189 8
			$this->offset += $len;
190
191 8
			return $value;
192
		}
193 5
		if ($this->bencoded[$this->offset + 1] === ':')
194
		{
195 5
			$value = substr($this->bencoded, $this->offset + 2, $len - 2);
196
			$this->offset += $len;
197 6
198
			return $value;
199 4
		}
200 4
201
		return $this->decodeString();
202 4
	}
203
204
	protected function decodeInteger(): int
205 2
	{
206
		if ($this->bencoded[++$this->offset] === '-')
207
		{
208 41
			if ($this->bencoded[++$this->offset] === '0')
209
			{
210 41
				$this->complianceError('Illegal character', $this->offset);
211
			}
212 8
213
			$clamp  = PHP_INT_MIN;
214 4
			$string = '-' . $this->readDigits('e');
215
		}
216
		else
217 6
		{
218 6
			$clamp  = PHP_INT_MAX;
219
			$string = $this->readDigits('e');
220
		}
221
222 33
		return $this->castInteger($string, $clamp);
223 33
	}
224
225
	protected function decodeList(): array
226 31
	{
227
		++$this->offset;
228
229 19
		$list = [];
230
		while ($this->offset <= $this->max)
231 19
		{
232
			if ($this->bencoded[$this->offset] === 'e')
233 19
			{
234 19
				++$this->offset;
235
236 18
				return $list;
237
			}
238 9
239
			$list[] = $this->decodeAnything();
240 9
		}
241
242
		throw new DecodingException('Premature end of data', $this->len - 1);
243 15
	}
244
245
	protected function decodeString(): string
246 5
	{
247
		$len = (int) $this->readDigits(':');
248
		$string = substr($this->bencoded, $this->offset, $len);
249 53
		$this->offset += $len;
250
251 53
		return $string;
252 39
	}
253 39
254
	protected function dictionaryComplianceError(string $key, string $lastKey): void
255 38
	{
256
		// Compute the offset of the start of the string used as key
257
		$offset = $this->offset - strlen(strlen($key) . ':') - strlen($key);
258 4
259
		$msg = ($key === $lastKey) ? 'Duplicate' : 'Out of order';
260
		$this->complianceError($msg . " dictionary entry '" . $key . "'", $offset);
261 4
	}
262
263 4
	protected function digitException(): DecodingException
264 4
	{
265
		// We use the same string as readDigits() purely to save one interned strings
266
		return (str_contains('1463720859', $this->bencoded[$this->offset]))
267 12
		     ? new ComplianceError('Illegal character', $this->offset)
268
		     : new DecodingException('Illegal character', $this->offset);
269
	}
270 12
271 3
	/**
272 12
	* Return the rightmost boundary to the last safe character that can start a value
273
	*
274
	* Will rewind the boundary to skip the rightmost digits, optionally preceded by "i" or "i-"
275
	*/
276
	protected function getSafeBoundary(): int
277
	{
278
		if (str_ends_with($this->bencoded, 'e'))
279
		{
280 108
			return $this->len - 1;
281
		}
282 108
283
		preg_match('(i?-?[0-9]*+$)D', $this->bencoded, $m);
284 57
285
		return $this->len - 1 - strlen($m[0] ?? '');
286
	}
287 51
288
	protected function readDigits(string $terminator): string
289 51
	{
290
		if ($this->bencoded[$this->offset] === '0')
291
		{
292 78
			++$this->offset;
293
			$string = '0';
294 78
		}
295
		else
296 20
		{
297 20
			// Digits sorted by decreasing frequency as observed on a random sample of torrent files
298
			// which speeds it up on PHP < 8.4
299
			$spn = strspn($this->bencoded, '1463720859', $this->offset);
300
			if ($spn === 0)
301
			{
302
				throw new DecodingException('Illegal character', $this->offset);
303 67
			}
304 67
			$string = substr($this->bencoded, $this->offset, $spn);
305
			$this->offset += $spn;
306 12
		}
307
308 57
		if ($this->bencoded[$this->offset] !== $terminator)
309 57
		{
310
			throw $this->digitException();
311
		}
312 68
		++$this->offset;
313
314 12
		return $string;
315
	}
316
}