diff --git a/CHANGELOG.md b/CHANGELOG.md index e60230f..b99a7fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.3] 2026-04-06 + +### Changed + +* Accept int8/16/32 as `int` uint8/16/32 as `non-negative-int` to simplify upstream libraries. + ## [0.3.2] 2026-04-06 ### Deprecated diff --git a/README.md b/README.md index 93485a0..c9bb95d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ [![PHP Version Requirement](https://img.shields.io/packagist/dependency-v/thesis/endian/php)](https://packagist.org/packages/thesis/endian) [![GitHub Release](https://img.shields.io/github/v/release/thesisphp/endian)](https://github.com/thesisphp/endian/releases) -[![Code Coverage](https://codecov.io/gh/thesis-php/endian/branch/0.1.x/graph/badge.svg)](https://codecov.io/gh/thesis-php/endian/tree/0.1.x) +[![Code Coverage](https://codecov.io/gh/thesis-php/endian/branch/0.3.x/graph/badge.svg)](https://codecov.io/gh/thesis-php/endian/tree/0.3.x) + +Pack and unpack binary integers and floats in any byte order. ## Installation @@ -10,59 +12,65 @@ composer require thesis/endian ``` -## Read/write in any byte order: - -1. In `network` (`big endian`) byte order. +## Usage ```php use Thesis\Endian; -echo Endian\Order::Network->unpackInt32( - Endian\Order::Network->packInt32(-200), -); // -200 +// Big endian (= network byte order) +$bytes = Endian\Order::Big->packInt32(-200); +$value = Endian\Order::Big->unpackInt32($bytes); // -200 + +// Little endian +$bytes = Endian\Order::Little->packFloat(2.2); +$value = Endian\Order::Little->unpackFloat($bytes); // 2.2 + +// Native byte order of the current machine +$order = Endian\Order::native(); // Order::Big or Order::Little ``` -2. In `big endian` byte order. +`Order::Network` is an alias for `Order::Big`. -```php -use Thesis\Endian; +## Design decisions -echo Endian\Order::Big->unpackInt8( - Endian\Order::Big->packInt8(17), -); // 17 -``` +### No narrow int types on input -3. In `little endian` byte order. +Pack methods accept plain `int` rather than narrow PHPStan types like `Int8` or `Int32`. This is intentional: requiring +callers to carry and assert narrow types would flood driver code with redundant checks and type imports. Thus, we've +decided to keep the validation on our side. -```php -use Thesis\Endian; +Bounds are checked with [`assert()`](https://www.php.net/manual/en/function.assert.php), which means zero overhead +in production when assertions are disabled ([`zend.assertions = -1`](https://www.php.net/manual/en/info.configuration.php#ini.zend.assertions)). -echo Endian\Order::Little->unpackFloat( - Endian\Order::Little->packFloat(2.2), -); // 2.2 -``` +### No object wrappers for 8/16/32-bit + +8/16/32-bit integers are represented as native PHP `int`, not value objects. This avoids allocation and method-call +overhead on every pack/unpack — important in tight loops typical of binary protocol parsing. + +### 64-bit integers -4. In `native endian` byte order. +64-bit values use [`BcMath\Number`](https://www.php.net/manual/en/class.bcmath-number.php) +to handle the full unsigned range beyond `PHP_INT_MAX`: ```php use Thesis\Endian; use BcMath\Number; -echo Endian\Order::native() - ->unpackInt64( - Endian\Order::native()->packInt64(new Number('9223372036854775807')), - ) - ->value; // 9223372036854775807 +$bytes = Endian\Order::Big->packUint64(new Number('18446744073709551615')); +$value = Endian\Order::Big->unpackUint64($bytes); // 18446744073709551615 ``` -### Supported types: -- [x] `int8` -- [x] `uint8` -- [x] `int16` -- [x] `uint16` -- [x] `int32` -- [x] `uint32` -- [x] `int64` -- [x] `uint64` -- [x] `float` -- [x] `double` +## Supported types + +| Type | PHP type | Range | +|----------|-----------------|-----------------------------------------------------------------------------------------| +| `int8` | `int` | `−128 .. 127` | +| `uint8` | `int` | `0 .. 255` | +| `int16` | `int` | `−32 768 .. 32767` | +| `uint16` | `int` | `0 .. 65535` | +| `int32` | `int` | `−2147483648 .. 2147483647` | +| `uint32` | `int` | `0 .. 4294967295` | +| `int64` | `BcMath\Number` | `−2⁶³ .. 2⁶³−1` | +| `uint64` | `BcMath\Number` | `0 .. 2⁶⁴−1` | +| `float` | `float` | [32-bit IEEE 754](https://en.wikipedia.org/wiki/Single-precision_floating-point_format) | +| `double` | `float` | [64-bit IEEE 754](https://en.wikipedia.org/wiki/Double-precision_floating-point_format) | diff --git a/src/Internal/Ints.php b/src/Internal/Ints.php new file mode 100644 index 0000000..cb0c401 --- /dev/null +++ b/src/Internal/Ints.php @@ -0,0 +1,108 @@ + + * @phpstan-type Uint8 = int<0, self::UINT8_MAX> + * @phpstan-type Int16 = int + * @phpstan-type Uint16 = int<0, self::UINT16_MAX> + * @phpstan-type Int32 = int + * @phpstan-type Uint32 = int<0, self::UINT32_MAX> + */ +final readonly class Ints +{ + public const int INT8_MIN = -2 ** 7; + public const int INT8_MAX = 2 ** 7 - 1; + public const int UINT8_MAX = 2 ** 8 - 1; + public const int UINT8_MOD = 2 ** 8; + public const int INT16_MIN = -2 ** 15; + public const int INT16_MAX = 2 ** 15 - 1; + public const int UINT16_MAX = 2 ** 16 - 1; + public const int UINT16_MOD = 2 ** 16; + public const int INT32_MIN = -2 ** 31; + public const int INT32_MAX = 2 ** 31 - 1; + public const int UINT32_MAX = 2 ** 32 - 1; + public const int UINT32_MOD = 2 ** 32; + + /** @phpstan-ignore unaryOp.invalid */ + public const Number INT64_MIN = -__TWO ** 63; + public const Number INT64_MAX = __TWO ** 63 - 1; + public const Number UINT64_MAX = __TWO ** 64 - 1; + public const Number UINT64_MOD = __TWO ** 64; + + /** + * @phpstan-assert-if-true Int8 $num + */ + public static function isInt8(int $num): bool + { + return $num >= self::INT8_MIN && $num <= self::INT8_MAX; + } + + /** + * @phpstan-assert-if-true Uint8 $num + */ + public static function isUint8(int $num): bool + { + return $num >= 0 && $num <= self::UINT8_MAX; + } + + /** + * @phpstan-assert-if-true Int16 $num + */ + public static function isInt16(int $num): bool + { + return $num >= self::INT16_MIN && $num <= self::INT16_MAX; + } + + /** + * @phpstan-assert-if-true Uint16 $num + */ + public static function isUint16(int $num): bool + { + return $num >= 0 && $num <= self::UINT16_MAX; + } + + /** + * @phpstan-assert-if-true Int32 $num + */ + public static function isInt32(int $num): bool + { + return $num >= self::INT32_MIN && $num <= self::INT32_MAX; + } + + /** + * @phpstan-assert-if-true Uint32 $num + */ + public static function isUint32(int $num): bool + { + return $num >= 0 && $num <= self::UINT32_MAX; + } + + public static function isInt64(Number $num): bool + { + return $num >= self::INT64_MIN && $num <= self::INT64_MAX; + } + + public static function isUint64(Number $num): bool + { + return $num >= 0 && $num <= self::UINT64_MAX; + } + + private function __construct() {} +} diff --git a/src/Order.php b/src/Order.php index 32c99f5..f7fa2cc 100644 --- a/src/Order.php +++ b/src/Order.php @@ -5,17 +5,17 @@ namespace Thesis\Endian; use BcMath\Number; +use Thesis\Endian\Internal\Ints; /** * @api - * @phpstan-type Int8 = int<-128, 127> - * @phpstan-type Uint8 = int<0, 255> - * @phpstan-type Int16 = int<-32768, 32767> - * @phpstan-type Uint16 = int<0, 65535> - * @phpstan-type Int32 = int<-2147483648, 2147483647> - * @phpstan-type Uint32 = int<0, 4294967295> - * @phpstan-type Int64 = int<-9223372036854775808, 9223372036854775807> - * @phpstan-type Uint64 = int<0, 18446744073709551615> + * + * @phpstan-import-type Int8 from Ints + * @phpstan-import-type Uint8 from Ints + * @phpstan-import-type Int16 from Ints + * @phpstan-import-type Uint16 from Ints + * @phpstan-import-type Int32 from Ints + * @phpstan-import-type Uint32 from Ints */ enum Order { @@ -42,13 +42,14 @@ public static function native(): self } /** - * @param Int8 $num * @return non-empty-string */ public function packInt8(int $num): string { + \assert(Ints::isInt8($num), \sprintf('Expected an int8 value, got %d.', $num)); + if ($num < 0) { - $num += 256; + $num += Ints::UINT8_MOD; } return $this->packUint8($num); @@ -61,19 +62,21 @@ public function packInt8(int $num): string public function unpackInt8(string $v): int { $num = $this->unpackUint8($v); - if ($num >= 128) { - $num -= 256; + if ($num > Ints::INT8_MAX) { + $num -= Ints::UINT8_MOD; } return $num; } /** - * @param Uint8 $num + * @param non-negative-int $num * @return non-empty-string */ public function packUint8(int $num): string { + \assert(Ints::isUint8($num), \sprintf('Expected a uint8 value, got %d.', $num)); + return \chr($num); } @@ -87,13 +90,14 @@ public function unpackUint8(string $v): int } /** - * @param Int16 $num * @return non-empty-string */ public function packInt16(int $num): string { + \assert(Ints::isInt16($num), \sprintf('Expected an int16 value, got %d.', $num)); + if ($num < 0) { - $num += 65536; + $num += Ints::UINT16_MOD; } return $this->packUint16($num); @@ -106,19 +110,21 @@ public function packInt16(int $num): string public function unpackInt16(string $v): int { $num = $this->unpackUint16($v); - if ($num >= 32768) { - $num -= 65536; + if ($num > Ints::INT16_MAX) { + $num -= Ints::UINT16_MOD; } return $num; } /** - * @param Uint16 $num + * @param non-negative-int $num * @return non-empty-string */ public function packUint16(int $num): string { + \assert(Ints::isUint16($num), \sprintf('Expected a uint16 value, got %d.', $num)); + return self::packBytes($num, match ($this) { self::Big => 'n', self::Little => 'v', @@ -139,13 +145,14 @@ public function unpackUint16(string $v): int } /** - * @param Int32 $num * @return non-empty-string */ public function packInt32(int $num): string { + \assert(Ints::isInt32($num), \sprintf('Expected an int32 value, got %d.', $num)); + if ($num < 0) { - $num += 4294967296; + $num += Ints::UINT32_MOD; } return $this->packUint32($num); @@ -158,19 +165,21 @@ public function packInt32(int $num): string public function unpackInt32(string $v): int { $num = $this->unpackUint32($v); - if ($num >= 2147483648) { - $num -= 4294967296; + if ($num > Ints::INT32_MAX) { + $num -= Ints::UINT32_MOD; } return $num; } /** - * @param Uint32 $num + * @param non-negative-int $num * @return non-empty-string */ public function packUint32(int $num): string { + \assert(Ints::isUint32($num), \sprintf('Expected a uint32 value, got %d.', $num)); + return self::packBytes($num, match ($this) { self::Big => 'N', self::Little => 'V', @@ -195,8 +204,10 @@ public function unpackUint32(string $v): int */ public function packInt64(Number $num): string { - if ($num->compare(0) < 0) { - $num += new Number(2)->pow(64); + \assert(Ints::isInt64($num), \sprintf('Expected an int64 value, got %s.', $num)); + + if ($num < 0) { + $num += Ints::UINT64_MOD; } return $this->packUint64($num); @@ -208,8 +219,8 @@ public function packInt64(Number $num): string public function unpackInt64(string $v): Number { $num = $this->unpackUint64($v); - if ($num->compare(new Number(2)->pow(63)) >= 0) { - $num = $num->sub(new Number(2)->pow(64), scale: 0); + if ($num > Ints::INT64_MAX) { + $num -= Ints::UINT64_MOD; } return $num; @@ -220,19 +231,15 @@ public function unpackInt64(string $v): Number */ public function packUint64(Number $num): string { - $bytes = ''; + \assert(Ints::isUint64($num), \sprintf('Expected a uint64 value, got %s.', $num)); - for ($i = 0; $i < 8; ++$i) { - /** @var int<0, 255> $value */ - $value = (int) $num->mod(256)->value; - $bytes .= \chr($value); - $num = $num->div(256, scale: 0); - } + $high = (int) $num->div(Ints::UINT32_MOD, scale: 0)->value; + $low = (int) $num->mod(Ints::UINT32_MOD)->value; /** @var non-empty-string */ return match ($this) { - self::Big => strrev($bytes), - self::Little => $bytes, + self::Big => pack('NN', $high, $low), + self::Little => pack('VV', $low, $high), }; } @@ -241,9 +248,15 @@ public function packUint64(Number $num): string */ public function unpackUint64(string $v): Number { + /** @var array{1: int, 2: int} */ + $parts = match ($this) { + self::Big => unpack('N2', $v), + self::Little => unpack('V2', $v), + }; + return match ($this) { - self::Big => self::unpackUint64BE($v), - self::Little => self::unpackUint64LE($v), + self::Big => new Number($parts[1]) * Ints::UINT32_MOD + $parts[2], + self::Little => new Number($parts[2]) * Ints::UINT32_MOD + $parts[1], }; } @@ -291,35 +304,6 @@ public function unpackDouble(string $v): float }); } - /** - * @internal - * @param non-empty-string $v - */ - private static function unpackUint64BE(string $v): Number - { - $num = new Number(0); - - for ($i = 0; $i < 8; ++$i) { - $num += new Number(\ord($v[$i])) * new Number(256)->pow(7 - $i, scale: 0); - } - - return $num; - } - - /** - * @param non-empty-string $v - */ - private static function unpackUint64LE(string $v): Number - { - $num = new Number(0); - - for ($i = 0; $i < 8; ++$i) { - $num += new Number(\ord($v[$i])) * new Number(256)->pow($i, scale: 0); - } - - return $num; - } - /** * @param non-empty-string $bytes * @param non-empty-string $format diff --git a/tests/Internal/IntsTest.php b/tests/Internal/IntsTest.php new file mode 100644 index 0000000..37c163b --- /dev/null +++ b/tests/Internal/IntsTest.php @@ -0,0 +1,87 @@ +sequence([-128, 127]) as $i) { self::assertSame($i, $endian->unpackInt8($endian->packInt8($i))); } @@ -22,7 +22,7 @@ public function testInt8(): void public function testUint8(): void { - foreach ([Order::Big, Order::Little] as $endian) { + foreach (Order::cases() as $endian) { foreach ($this->sequence([0, 255]) as $i) { self::assertSame($i, $endian->unpackUint8($endian->packUint8($i))); } @@ -31,7 +31,7 @@ public function testUint8(): void public function testInt16(): void { - foreach ([Order::Big, Order::Little] as $endian) { + foreach (Order::cases() as $endian) { foreach ($this->sequence([-32_768, 32_767]) as $i) { self::assertSame($i, $endian->unpackInt16($endian->packInt16($i))); } @@ -40,7 +40,7 @@ public function testInt16(): void public function testUint16(): void { - foreach ([Order::Big, Order::Little] as $endian) { + foreach (Order::cases() as $endian) { foreach ($this->sequence([0, 65_535]) as $i) { self::assertSame($i, $endian->unpackUint16($endian->packUint16($i))); } @@ -49,7 +49,7 @@ public function testUint16(): void public function testInt32(): void { - foreach ([Order::Big, Order::Little] as $endian) { + foreach (Order::cases() as $endian) { foreach ($this->sequence([-32_768, 32_767], [-2_147_483_648], [2_147_483_647]) as $i) { self::assertSame($i, $endian->unpackInt32($endian->packInt32($i))); } @@ -58,7 +58,7 @@ public function testInt32(): void public function testUint32(): void { - foreach ([Order::Big, Order::Little] as $endian) { + foreach (Order::cases() as $endian) { foreach ($this->sequence([0, 65_535], [4_294_967_295]) as $i) { self::assertSame($i, $endian->unpackUint32($endian->packUint32($i))); } @@ -67,7 +67,7 @@ public function testUint32(): void public function testInt64(): void { - foreach ([Order::Big, Order::Little] as $endian) { + foreach (Order::cases() as $endian) { foreach ($this->sequence([-32_768, 32_767], [PHP_INT_MIN], [PHP_INT_MAX]) as $i) { $num = new Number($i); @@ -82,7 +82,7 @@ public function testInt64(): void public function testUint64(): void { - foreach ([Order::Big, Order::Little] as $endian) { + foreach (Order::cases() as $endian) { foreach ($this->sequence([0, 65_535], [PHP_INT_MAX]) as $i) { $num = new Number($i); @@ -97,7 +97,7 @@ public function testUint64(): void public function testFloat(): void { - foreach ([Order::Big, Order::Little] as $endian) { + foreach (Order::cases() as $endian) { foreach ($this->sequence([-32_768, 32_767]) as $i) { self::assertSame($i * 1.0, $endian->unpackFloat($endian->packFloat($i))); } @@ -106,7 +106,7 @@ public function testFloat(): void public function testDouble(): void { - foreach ([Order::Big, Order::Little] as $endian) { + foreach (Order::cases() as $endian) { foreach ($this->sequence([-32_768, 32_767], [2.225_073_858_507_2E-308], [1.797_693_134_862_3], [PHP_INT_MIN], [PHP_INT_MAX]) as $i) { self::assertSame($i * 1.0, $endian->unpackDouble($endian->packDouble($i))); }