From 5713695bfd18e9e0308a1ecabd3bccaf706d86d4 Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 14:35:35 +0200 Subject: [PATCH 01/15] Support little-endian and 64-bit integer reads --- src/BinaryReader.php | 37 ++++++++++++++++++--- src/IntType.php | 68 ++++++++++++++++++++++++++++++++++++++ tests/BinaryReaderTest.php | 38 ++++++++++++++++++--- 3 files changed, 134 insertions(+), 9 deletions(-) create mode 100644 src/IntType.php diff --git a/src/BinaryReader.php b/src/BinaryReader.php index edb13e0..7262403 100644 --- a/src/BinaryReader.php +++ b/src/BinaryReader.php @@ -77,9 +77,9 @@ public function readBytes(int $count): BinaryString public function readBytesWithLength(bool $use16BitLength = false): BinaryString { if ($use16BitLength) { - $length = $this->readUint16BE(); + $length = $this->readInt(IntType::UINT16); } else { - $length = $this->readByte(); + $length = $this->readInt(IntType::UINT8); } try { @@ -90,10 +90,37 @@ public function readBytesWithLength(bool $use16BitLength = false): BinaryString } } - public function readUint16BE(): int + public function readInt(IntType $type): int { - $bytes = $this->readBytes(2)->value; - return (ord($bytes[0]) << 8) | ord($bytes[1]); + $bytesCount = $type->bytes(); + if ($bytesCount > PHP_INT_SIZE) { + throw new RuntimeException( + sprintf('Cannot read %d-byte integers on %d-byte platform', $bytesCount, PHP_INT_SIZE) + ); + } + + $bytes = $this->readBytes($bytesCount)->value; + + if ($type->isLittleEndian()) { + $bytes = strrev($bytes); + } + + $value = 0; + for ($i = 0; $i < $bytesCount; $i++) { + $value = ($value << 8) | ord($bytes[$i]); + } + + if ($type->isSigned()) { + $bits = $bytesCount * 8; + if ($bits < PHP_INT_SIZE * 8) { + $signBit = 1 << ($bits - 1); + if (($value & $signBit) !== 0) { + $value -= 1 << $bits; + } + } + } + + return $value; } public function readString(int $length): BinaryString diff --git a/src/IntType.php b/src/IntType.php new file mode 100644 index 0000000..0a6d4fc --- /dev/null +++ b/src/IntType.php @@ -0,0 +1,68 @@ + 1, + self::UINT16, + self::INT16, + self::UINT16_LE, + self::INT16_LE => 2, + self::UINT32, + self::INT32, + self::UINT32_LE, + self::INT32_LE => 4, + self::UINT64, + self::INT64, + self::UINT64_LE, + self::INT64_LE => 8, + }; + } + + public function isSigned(): bool + { + return match ($this) { + self::INT8, + self::INT16, + self::INT32, + self::INT16_LE, + self::INT32_LE, + self::INT64, + self::INT64_LE => true, + default => false, + }; + } + + public function isLittleEndian(): bool + { + return match ($this) { + self::UINT16_LE, + self::INT16_LE, + self::UINT32_LE, + self::INT32_LE, + self::UINT64_LE, + self::INT64_LE => true, + default => false, + }; + } +} diff --git a/tests/BinaryReaderTest.php b/tests/BinaryReaderTest.php index 5422e35..3f09cb3 100644 --- a/tests/BinaryReaderTest.php +++ b/tests/BinaryReaderTest.php @@ -4,6 +4,7 @@ use KDuma\BinaryTools\BinaryReader; use KDuma\BinaryTools\BinaryString; +use KDuma\BinaryTools\IntType; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -94,11 +95,40 @@ public function testReadBytesWithLength() } } - public function testReadUint16BE() + public static function readIntProvider(): iterable { - $this->reader = new BinaryReader(BinaryString::fromString("\x04\xd2")); - $this->assertEquals(1234, $this->reader->readUint16BE()); - $this->assertEquals(2, $this->reader->position); + yield 'uint8 max' => [255, "\xFF", IntType::UINT8]; + yield 'int8 positive' => [127, "\x7F", IntType::INT8]; + yield 'int8 negative' => [-1, "\xFF", IntType::INT8]; + yield 'uint16 positive' => [1234, "\x04\xD2", IntType::UINT16]; + yield 'uint16 little endian positive' => [1234, "\xD2\x04", IntType::UINT16_LE]; + yield 'int16 positive' => [1234, "\x04\xD2", IntType::INT16]; + yield 'int16 little endian positive' => [1234, "\xD2\x04", IntType::INT16_LE]; + yield 'int16 negative' => [-1234, "\xFB\x2E", IntType::INT16]; + yield 'int16 little endian negative' => [-1234, "\x2E\xFB", IntType::INT16_LE]; + yield 'uint32 positive' => [0xDEADBEEF, "\xDE\xAD\xBE\xEF", IntType::UINT32]; + yield 'uint32 little endian positive' => [0xDEADBEEF, "\xEF\xBE\xAD\xDE", IntType::UINT32_LE]; + yield 'int32 positive' => [1234, "\x00\x00\x04\xD2", IntType::INT32]; + yield 'int32 little endian positive' => [1234, "\xD2\x04\x00\x00", IntType::INT32_LE]; + yield 'int32 negative' => [-1234, "\xFF\xFF\xFB\x2E", IntType::INT32]; + yield 'int32 little endian negative' => [-1234, "\x2E\xFB\xFF\xFF", IntType::INT32_LE]; + yield 'uint64 positive' => [0x0123456789ABCDEF, "\x01\x23\x45\x67\x89\xAB\xCD\xEF", IntType::UINT64]; + yield 'uint64 little endian positive' => [0x0123456789ABCDEF, "\xEF\xCD\xAB\x89\x67\x45\x23\x01", IntType::UINT64_LE]; + yield 'int64 positive' => [1234, "\x00\x00\x00\x00\x00\x00\x04\xD2", IntType::INT64]; + yield 'int64 little endian positive' => [1234, "\xD2\x04\x00\x00\x00\x00\x00\x00", IntType::INT64_LE]; + yield 'int64 negative' => [-1234, "\xFF\xFF\xFF\xFF\xFF\xFF\xFB\x2E", IntType::INT64]; + yield 'int64 little endian negative' => [-1234, "\x2E\xFB\xFF\xFF\xFF\xFF\xFF\xFF", IntType::INT64_LE]; + } + + /** + * @dataProvider readIntProvider + */ + public function testReadInt(int $expected, string $payload, IntType $type): void + { + $this->reader = new BinaryReader(BinaryString::fromString($payload)); + + $this->assertSame($expected, $this->reader->readInt($type)); + $this->assertSame(strlen($payload), $this->reader->position); } public function testPeekByte() From 91451cc33514c15fa6904557444c3b762dc98c42 Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 16:59:46 +0200 Subject: [PATCH 02/15] Finish implementation in BinaryReader, Implement in BinaryWriter --- src/BinaryReader.php | 14 ++- src/BinaryWriter.php | 41 +++++- src/IntType.php | 40 ++++++ tests/BinaryReaderTest.php | 45 +++++++ tests/BinaryWriterTest.php | 111 +++++++++++++++- tests/IntTypeTest.php | 252 +++++++++++++++++++++++++++++++++++++ 6 files changed, 494 insertions(+), 9 deletions(-) create mode 100644 tests/IntTypeTest.php diff --git a/src/BinaryReader.php b/src/BinaryReader.php index 9e489bd..ed4bb1e 100644 --- a/src/BinaryReader.php +++ b/src/BinaryReader.php @@ -92,13 +92,15 @@ public function readBytesWithLength(bool $use16BitLength = false): BinaryString public function readInt(IntType $type): int { - $bytesCount = $type->bytes(); - if ($bytesCount > PHP_INT_SIZE) { + if (!$type->isSupported()) { + // @codeCoverageIgnoreStart throw new RuntimeException( - sprintf('Cannot read %d-byte integers on %d-byte platform', $bytesCount, PHP_INT_SIZE) + sprintf('Cannot read %d-byte integers on %d-byte platform', $type->bytes(), PHP_INT_SIZE) ); + // @codeCoverageIgnoreEnd } + $bytesCount = $type->bytes(); $bytes = $this->readBytes($bytesCount)->value; if ($type->isLittleEndian()) { @@ -123,6 +125,12 @@ public function readInt(IntType $type): int return $value; } + #[\Deprecated('Use readInt(IntType::UINT16) instead')] + public function readUint16BE(): int + { + return $this->readInt(IntType::UINT16); + } + public function readString(int $length): BinaryString { $bytes = $this->readBytes($length); diff --git a/src/BinaryWriter.php b/src/BinaryWriter.php index 0b41a53..c74233f 100644 --- a/src/BinaryWriter.php +++ b/src/BinaryWriter.php @@ -58,17 +58,48 @@ public function writeBytesWithLength(BinaryString $bytes, bool $use16BitLength = return $this; } - public function writeUint16BE(int $value): self + public function writeInt(IntType $type, int $value): self { - if ($value < 0 || $value > 65535) { - throw new \InvalidArgumentException('Uint16 value must be between 0 and 65535'); + if (!$type->isSupported()) { + // @codeCoverageIgnoreStart + throw new \RuntimeException( + sprintf('Cannot write %d-byte integers on %d-byte platform', $type->bytes(), PHP_INT_SIZE) + ); + // @codeCoverageIgnoreEnd + } + + $bytesCount = $type->bytes(); + + if (!$type->isValid($value)) { + throw new \InvalidArgumentException( + sprintf('Value %d is out of range for %s', $value, $type->name) + ); + } + + // Handle negative values for signed types + if ($type->isSigned() && $value < 0) { + $value = (1 << ($bytesCount * 8)) + $value; + } + + $bytes = ''; + for ($i = $bytesCount - 1; $i >= 0; $i--) { + $bytes .= chr(($value >> ($i * 8)) & 0xFF); + } + + if ($type->isLittleEndian()) { + $bytes = strrev($bytes); } - $this->buffer .= chr(($value >> 8) & 0xFF); - $this->buffer .= chr($value & 0xFF); + $this->buffer .= $bytes; return $this; } + #[\Deprecated('Use writeInt(IntType::UINT16, $value) instead')] + public function writeUint16BE(int $value): self + { + return $this->writeInt(IntType::UINT16, $value); + } + public function writeString(BinaryString $string): self { if (!mb_check_encoding($string->value, 'UTF-8')) { diff --git a/src/IntType.php b/src/IntType.php index 0a6d4fc..e917882 100644 --- a/src/IntType.php +++ b/src/IntType.php @@ -65,4 +65,44 @@ public function isLittleEndian(): bool default => false, }; } + + public function isSupported(): bool + { + return $this->bytes() <= PHP_INT_SIZE; + } + + public function minValue(): int + { + if ($this->isSigned()) { + $bits = $this->bytes() * 8; + if ($bits >= PHP_INT_SIZE * 8) { + return PHP_INT_MIN; + } + return -(1 << ($bits - 1)); + } + + return 0; + } + + public function maxValue(): int + { + $bits = $this->bytes() * 8; + + if ($this->isSigned()) { + if ($bits >= PHP_INT_SIZE * 8) { + return PHP_INT_MAX; + } + return (1 << ($bits - 1)) - 1; + } else { + if ($bits >= PHP_INT_SIZE * 8) { + return PHP_INT_MAX; + } + return (1 << $bits) - 1; + } + } + + public function isValid(int $value): bool + { + return $value >= $this->minValue() && $value <= $this->maxValue(); + } } diff --git a/tests/BinaryReaderTest.php b/tests/BinaryReaderTest.php index 3f09cb3..d351552 100644 --- a/tests/BinaryReaderTest.php +++ b/tests/BinaryReaderTest.php @@ -131,6 +131,29 @@ public function testReadInt(int $expected, string $payload, IntType $type): void $this->assertSame(strlen($payload), $this->reader->position); } + public function testReadIntUnsupportedSize(): void + { + // This test only runs on 32-bit systems where 64-bit integers are not supported + if (PHP_INT_SIZE >= 8) { + $this->markTestSkipped('64-bit integers are supported on this platform'); + } + + $this->reader = new BinaryReader(BinaryString::fromString("\x01\x23\x45\x67\x89\xAB\xCD\xEF")); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot read 8-byte integers on 4-byte platform'); + + $this->reader->readInt(IntType::UINT64); + } + + public function testReadUint16BEDeprecated(): void + { + $this->reader = new BinaryReader(BinaryString::fromString("\x04\xD2")); + + $this->assertSame(1234, $this->reader->readUint16BE()); + $this->assertSame(2, $this->reader->position); + } + public function testPeekByte() { $this->assertEquals(0x01, $this->reader->peekByte()); @@ -337,6 +360,28 @@ public function testGetPosition() $this->assertEquals(2, $this->reader->position); } + public function testSetPosition() + { + // Test direct position property assignment + $this->reader->position = 2; + $this->assertEquals(2, $this->reader->position); + + // Test validation through direct assignment + try { + $this->reader->position = -1; + $this->fail("Expected exception not thrown"); + } catch (\RuntimeException $exception) { + $this->assertEquals('Invalid seek position: -1', $exception->getMessage()); + } + + try { + $this->reader->position = 5; + $this->fail("Expected exception not thrown"); + } catch (\RuntimeException $exception) { + $this->assertEquals('Invalid seek position: 5', $exception->getMessage()); + } + } + public function testGetRemainingBytes() { $this->reader->seek(0); diff --git a/tests/BinaryWriterTest.php b/tests/BinaryWriterTest.php index 16180d7..7d6e920 100644 --- a/tests/BinaryWriterTest.php +++ b/tests/BinaryWriterTest.php @@ -4,7 +4,9 @@ use KDuma\BinaryTools\BinaryString; use KDuma\BinaryTools\BinaryWriter; +use KDuma\BinaryTools\IntType; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversClass(BinaryWriter::class)] @@ -63,7 +65,7 @@ public function testWriteUint16BE() $this->writer->writeUint16BE(65535 + 1); $this->fail("Expected exception not thrown"); } catch (\InvalidArgumentException $exception) { - $this->assertEquals('Uint16 value must be between 0 and 65535', $exception->getMessage()); + $this->assertEquals('Value 65536 is out of range for UINT16', $exception->getMessage()); $this->assertEquals(0, $this->writer->getLength()); } } @@ -179,4 +181,111 @@ public function testWriteString() $this->assertEquals(0, $this->writer->getLength()); } } + + public static function writeIntProvider(): iterable + { + yield 'uint8 zero' => [0, "\x00", IntType::UINT8]; + yield 'uint8 max' => [255, "\xFF", IntType::UINT8]; + yield 'int8 positive' => [127, "\x7F", IntType::INT8]; + yield 'int8 negative' => [-1, "\xFF", IntType::INT8]; + yield 'int8 min' => [-128, "\x80", IntType::INT8]; + yield 'uint16 positive' => [1234, "\x04\xD2", IntType::UINT16]; + yield 'uint16 little endian positive' => [1234, "\xD2\x04", IntType::UINT16_LE]; + yield 'int16 positive' => [1234, "\x04\xD2", IntType::INT16]; + yield 'int16 little endian positive' => [1234, "\xD2\x04", IntType::INT16_LE]; + yield 'int16 negative' => [-1234, "\xFB\x2E", IntType::INT16]; + yield 'int16 little endian negative' => [-1234, "\x2E\xFB", IntType::INT16_LE]; + yield 'uint32 positive' => [0xDEADBEEF, "\xDE\xAD\xBE\xEF", IntType::UINT32]; + yield 'uint32 little endian positive' => [0xDEADBEEF, "\xEF\xBE\xAD\xDE", IntType::UINT32_LE]; + yield 'int32 positive' => [1234, "\x00\x00\x04\xD2", IntType::INT32]; + yield 'int32 little endian positive' => [1234, "\xD2\x04\x00\x00", IntType::INT32_LE]; + yield 'int32 negative' => [-1234, "\xFF\xFF\xFB\x2E", IntType::INT32]; + yield 'int32 little endian negative' => [-1234, "\x2E\xFB\xFF\xFF", IntType::INT32_LE]; + yield 'uint64 positive' => [0x0123456789ABCDEF, "\x01\x23\x45\x67\x89\xAB\xCD\xEF", IntType::UINT64]; + yield 'uint64 little endian positive' => [0x0123456789ABCDEF, "\xEF\xCD\xAB\x89\x67\x45\x23\x01", IntType::UINT64_LE]; + yield 'int64 positive' => [1234, "\x00\x00\x00\x00\x00\x00\x04\xD2", IntType::INT64]; + yield 'int64 little endian positive' => [1234, "\xD2\x04\x00\x00\x00\x00\x00\x00", IntType::INT64_LE]; + yield 'int64 negative' => [-1234, "\xFF\xFF\xFF\xFF\xFF\xFF\xFB\x2E", IntType::INT64]; + yield 'int64 little endian negative' => [-1234, "\x2E\xFB\xFF\xFF\xFF\xFF\xFF\xFF", IntType::INT64_LE]; + } + + /** + * @dataProvider writeIntProvider + */ + public function testWriteInt(int $value, string $expected, IntType $type): void + { + if (!$type->isSupported()) { + $this->markTestSkipped(sprintf('IntType %s is not supported on this platform', $type->name)); + } + + $this->writer->reset(); + $this->writer->writeInt($type, $value); + $this->assertEquals($expected, $this->writer->getBuffer()->toString()); + } + + public function testWriteIntUnsupportedType(): void + { + // This test only runs on 32-bit systems where 64-bit integers are not supported + if (PHP_INT_SIZE >= 8) { + $this->markTestSkipped('64-bit integers are supported on this platform'); + } + + $this->writer->reset(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot write 8-byte integers on 4-byte platform'); + + $this->writer->writeInt(IntType::UINT64, 1234); + } + + public function testWriteIntOutOfRange(): void + { + $this->writer->reset(); + + // Test uint8 overflow + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Value 256 is out of range for UINT8'); + + $this->writer->writeInt(IntType::UINT8, 256); + } + + public function testWriteIntNegativeUnsigned(): void + { + $this->writer->reset(); + + // Test negative value for unsigned type + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Value -1 is out of range for UINT8'); + + $this->writer->writeInt(IntType::UINT8, -1); + } + + public function testWriteIntSignedOverflow(): void + { + $this->writer->reset(); + + // Test int8 overflow + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Value 128 is out of range for INT8'); + + $this->writer->writeInt(IntType::INT8, 128); + } + + public function testWriteIntSignedUnderflow(): void + { + $this->writer->reset(); + + // Test int8 underflow + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Value -129 is out of range for INT8'); + + $this->writer->writeInt(IntType::INT8, -129); + } + + public function testWriteUint16BEDeprecated(): void + { + $this->writer->reset(); + $this->writer->writeUint16BE(1234); + $this->assertEquals("\x04\xD2", $this->writer->getBuffer()->toString()); + } } diff --git a/tests/IntTypeTest.php b/tests/IntTypeTest.php new file mode 100644 index 0000000..d557954 --- /dev/null +++ b/tests/IntTypeTest.php @@ -0,0 +1,252 @@ + + */ + public static function bytesProvider(): array + { + return [ + 'UINT8' => ['type' => IntType::UINT8, 'bytes' => 1], + 'INT8' => ['type' => IntType::INT8, 'bytes' => 1], + 'UINT16' => ['type' => IntType::UINT16, 'bytes' => 2], + 'INT16' => ['type' => IntType::INT16, 'bytes' => 2], + 'UINT16_LE' => ['type' => IntType::UINT16_LE, 'bytes' => 2], + 'INT16_LE' => ['type' => IntType::INT16_LE, 'bytes' => 2], + 'UINT32' => ['type' => IntType::UINT32, 'bytes' => 4], + 'INT32' => ['type' => IntType::INT32, 'bytes' => 4], + 'UINT32_LE' => ['type' => IntType::UINT32_LE, 'bytes' => 4], + 'INT32_LE' => ['type' => IntType::INT32_LE, 'bytes' => 4], + 'UINT64' => ['type' => IntType::UINT64, 'bytes' => 8], + 'INT64' => ['type' => IntType::INT64, 'bytes' => 8], + 'UINT64_LE' => ['type' => IntType::UINT64_LE, 'bytes' => 8], + 'INT64_LE' => ['type' => IntType::INT64_LE, 'bytes' => 8], + ]; + } + + #[DataProvider('bytesProvider')] + public function testBytes(IntType $type, int $bytes): void + { + $this->assertSame($bytes, $type->bytes()); + } + + /** + * @return array + */ + public static function signedProvider(): array + { + return [ + 'UINT8' => ['type' => IntType::UINT8, 'signed' => false], + 'INT8' => ['type' => IntType::INT8, 'signed' => true], + 'UINT16' => ['type' => IntType::UINT16, 'signed' => false], + 'INT16' => ['type' => IntType::INT16, 'signed' => true], + 'UINT16_LE' => ['type' => IntType::UINT16_LE, 'signed' => false], + 'INT16_LE' => ['type' => IntType::INT16_LE, 'signed' => true], + 'UINT32' => ['type' => IntType::UINT32, 'signed' => false], + 'INT32' => ['type' => IntType::INT32, 'signed' => true], + 'UINT32_LE' => ['type' => IntType::UINT32_LE, 'signed' => false], + 'INT32_LE' => ['type' => IntType::INT32_LE, 'signed' => true], + 'UINT64' => ['type' => IntType::UINT64, 'signed' => false], + 'INT64' => ['type' => IntType::INT64, 'signed' => true], + 'UINT64_LE' => ['type' => IntType::UINT64_LE, 'signed' => false], + 'INT64_LE' => ['type' => IntType::INT64_LE, 'signed' => true], + ]; + } + + #[DataProvider('signedProvider')] + public function testIsSigned(IntType $type, bool $signed): void + { + $this->assertSame($signed, $type->isSigned()); + } + + /** + * @return array + */ + public static function endianProvider(): array + { + return [ + 'UINT8' => ['type' => IntType::UINT8, 'littleEndian' => false], + 'INT8' => ['type' => IntType::INT8, 'littleEndian' => false], + 'UINT16' => ['type' => IntType::UINT16, 'littleEndian' => false], + 'INT16' => ['type' => IntType::INT16, 'littleEndian' => false], + 'UINT16_LE' => ['type' => IntType::UINT16_LE, 'littleEndian' => true], + 'INT16_LE' => ['type' => IntType::INT16_LE, 'littleEndian' => true], + 'UINT32' => ['type' => IntType::UINT32, 'littleEndian' => false], + 'INT32' => ['type' => IntType::INT32, 'littleEndian' => false], + 'UINT32_LE' => ['type' => IntType::UINT32_LE, 'littleEndian' => true], + 'INT32_LE' => ['type' => IntType::INT32_LE, 'littleEndian' => true], + 'UINT64' => ['type' => IntType::UINT64, 'littleEndian' => false], + 'INT64' => ['type' => IntType::INT64, 'littleEndian' => false], + 'UINT64_LE' => ['type' => IntType::UINT64_LE, 'littleEndian' => true], + 'INT64_LE' => ['type' => IntType::INT64_LE, 'littleEndian' => true], + ]; + } + + #[DataProvider('endianProvider')] + public function testIsLittleEndian(IntType $type, bool $littleEndian): void + { + $this->assertSame($littleEndian, $type->isLittleEndian()); + } + + /** + * @return array + */ + public static function supportedProvider(): array + { + $is64Bit = PHP_INT_SIZE >= 8; + + return [ + 'UINT8' => ['type' => IntType::UINT8, 'supported' => true], + 'INT8' => ['type' => IntType::INT8, 'supported' => true], + 'UINT16' => ['type' => IntType::UINT16, 'supported' => true], + 'INT16' => ['type' => IntType::INT16, 'supported' => true], + 'UINT16_LE' => ['type' => IntType::UINT16_LE, 'supported' => true], + 'INT16_LE' => ['type' => IntType::INT16_LE, 'supported' => true], + 'UINT32' => ['type' => IntType::UINT32, 'supported' => true], + 'INT32' => ['type' => IntType::INT32, 'supported' => true], + 'UINT32_LE' => ['type' => IntType::UINT32_LE, 'supported' => true], + 'INT32_LE' => ['type' => IntType::INT32_LE, 'supported' => true], + 'UINT64' => ['type' => IntType::UINT64, 'supported' => $is64Bit], + 'INT64' => ['type' => IntType::INT64, 'supported' => $is64Bit], + 'UINT64_LE' => ['type' => IntType::UINT64_LE, 'supported' => $is64Bit], + 'INT64_LE' => ['type' => IntType::INT64_LE, 'supported' => $is64Bit], + ]; + } + + #[DataProvider('supportedProvider')] + public function testIsSupported(IntType $type, bool $supported): void + { + $this->assertSame($supported, $type->isSupported()); + } + + /** + * @return array + */ + public static function minValueProvider(): array + { + return [ + 'UINT8' => ['type' => IntType::UINT8, 'minValue' => 0], + 'INT8' => ['type' => IntType::INT8, 'minValue' => -128], + 'UINT16' => ['type' => IntType::UINT16, 'minValue' => 0], + 'INT16' => ['type' => IntType::INT16, 'minValue' => -32768], + 'UINT16_LE' => ['type' => IntType::UINT16_LE, 'minValue' => 0], + 'INT16_LE' => ['type' => IntType::INT16_LE, 'minValue' => -32768], + 'UINT32' => ['type' => IntType::UINT32, 'minValue' => 0], + 'INT32' => ['type' => IntType::INT32, 'minValue' => -2147483648], + 'UINT32_LE' => ['type' => IntType::UINT32_LE, 'minValue' => 0], + 'INT32_LE' => ['type' => IntType::INT32_LE, 'minValue' => -2147483648], + 'UINT64' => ['type' => IntType::UINT64, 'minValue' => 0], + 'INT64' => ['type' => IntType::INT64, 'minValue' => PHP_INT_MIN], + 'UINT64_LE' => ['type' => IntType::UINT64_LE, 'minValue' => 0], + 'INT64_LE' => ['type' => IntType::INT64_LE, 'minValue' => PHP_INT_MIN], + ]; + } + + #[DataProvider('minValueProvider')] + public function testMinValue(IntType $type, int $minValue): void + { + $this->assertSame($minValue, $type->minValue()); + } + + /** + * @return array + */ + public static function maxValueProvider(): array + { + $is64Bit = PHP_INT_SIZE >= 8; + + return [ + 'UINT8' => ['type' => IntType::UINT8, 'maxValue' => 255], + 'INT8' => ['type' => IntType::INT8, 'maxValue' => 127], + 'UINT16' => ['type' => IntType::UINT16, 'maxValue' => 65535], + 'INT16' => ['type' => IntType::INT16, 'maxValue' => 32767], + 'UINT16_LE' => ['type' => IntType::UINT16_LE, 'maxValue' => 65535], + 'INT16_LE' => ['type' => IntType::INT16_LE, 'maxValue' => 32767], + 'UINT32' => ['type' => IntType::UINT32, 'maxValue' => 4294967295], + 'INT32' => ['type' => IntType::INT32, 'maxValue' => 2147483647], + 'UINT32_LE' => ['type' => IntType::UINT32_LE, 'maxValue' => 4294967295], + 'INT32_LE' => ['type' => IntType::INT32_LE, 'maxValue' => 2147483647], + 'UINT64' => ['type' => IntType::UINT64, 'maxValue' => PHP_INT_MAX], // Limited by PHP_INT_MAX + 'INT64' => ['type' => IntType::INT64, 'maxValue' => PHP_INT_MAX], + 'UINT64_LE' => ['type' => IntType::UINT64_LE, 'maxValue' => PHP_INT_MAX], + 'INT64_LE' => ['type' => IntType::INT64_LE, 'maxValue' => PHP_INT_MAX], + ]; + } + + #[DataProvider('maxValueProvider')] + public function testMaxValue(IntType $type, int $maxValue): void + { + $this->assertSame($maxValue, $type->maxValue()); + } + + /** + * @return array + */ + public static function isValidProvider(): array + { + return [ + 'UINT8' => ['type' => IntType::UINT8, 'validValue' => 100, 'belowMin' => -1, 'aboveMax' => 256], + 'INT8' => ['type' => IntType::INT8, 'validValue' => 50, 'belowMin' => -129, 'aboveMax' => 128], + 'UINT16' => ['type' => IntType::UINT16, 'validValue' => 1000, 'belowMin' => -1, 'aboveMax' => 65536], + 'INT16' => ['type' => IntType::INT16, 'validValue' => 1000, 'belowMin' => -32769, 'aboveMax' => 32768], + 'UINT16_LE' => ['type' => IntType::UINT16_LE, 'validValue' => 1000, 'belowMin' => -1, 'aboveMax' => 65536], + 'INT16_LE' => ['type' => IntType::INT16_LE, 'validValue' => 1000, 'belowMin' => -32769, 'aboveMax' => 32768], + 'UINT32' => ['type' => IntType::UINT32, 'validValue' => 100000, 'belowMin' => -1, 'aboveMax' => 4294967296], + 'INT32' => ['type' => IntType::INT32, 'validValue' => 100000, 'belowMin' => -2147483649, 'aboveMax' => 2147483648], + 'UINT32_LE' => ['type' => IntType::UINT32_LE, 'validValue' => 100000, 'belowMin' => -1, 'aboveMax' => 4294967296], + 'INT32_LE' => ['type' => IntType::INT32_LE, 'validValue' => 100000, 'belowMin' => -2147483649, 'aboveMax' => 2147483648], + ]; + } + + #[DataProvider('isValidProvider')] + public function testIsValid(IntType $type, int $validValue, int $belowMin, int $aboveMax): void + { + // Test valid value + $this->assertTrue($type->isValid($validValue)); + + // Test boundary values + $this->assertTrue($type->isValid($type->minValue())); + $this->assertTrue($type->isValid($type->maxValue())); + + // Test invalid values (if they don't cause overflow issues) + if ($belowMin > PHP_INT_MIN) { + $this->assertFalse($type->isValid($belowMin)); + } + if ($aboveMax < PHP_INT_MAX) { + $this->assertFalse($type->isValid($aboveMax)); + } + } + + public function testIsValidFor64BitTypes(): void + { + if (PHP_INT_SIZE < 8) { + $this->markTestSkipped('64-bit integers not supported on this platform'); + } + + $types = [IntType::UINT64, IntType::INT64, IntType::UINT64_LE, IntType::INT64_LE]; + + foreach ($types as $type) { + // Test valid values + $this->assertTrue($type->isValid(0)); + $this->assertTrue($type->isValid(1234)); + + // Test boundary values + $this->assertTrue($type->isValid($type->minValue())); + $this->assertTrue($type->isValid($type->maxValue())); + + // For signed types, test negative values + if ($type->isSigned()) { + $this->assertTrue($type->isValid(-1234)); + } + } + } +} From ad84ac85449c45df74dc9d2cc963e4ce59c1f145 Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 17:08:19 +0200 Subject: [PATCH 03/15] Import Deprecated attribute --- src/BinaryReader.php | 3 ++- src/BinaryWriter.php | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/BinaryReader.php b/src/BinaryReader.php index ed4bb1e..228f0f8 100644 --- a/src/BinaryReader.php +++ b/src/BinaryReader.php @@ -2,6 +2,7 @@ namespace KDuma\BinaryTools; +use Deprecated; use RuntimeException; final class BinaryReader @@ -125,7 +126,7 @@ public function readInt(IntType $type): int return $value; } - #[\Deprecated('Use readInt(IntType::UINT16) instead')] + #[Deprecated('Use readInt(IntType::UINT16) instead')] public function readUint16BE(): int { return $this->readInt(IntType::UINT16); diff --git a/src/BinaryWriter.php b/src/BinaryWriter.php index c74233f..debcc0f 100644 --- a/src/BinaryWriter.php +++ b/src/BinaryWriter.php @@ -2,6 +2,8 @@ namespace KDuma\BinaryTools; +use Deprecated; + final class BinaryWriter { private string $buffer = ''; @@ -94,7 +96,7 @@ public function writeInt(IntType $type, int $value): self return $this; } - #[\Deprecated('Use writeInt(IntType::UINT16, $value) instead')] + #[Deprecated('Use writeInt(IntType::UINT16, $value) instead')] public function writeUint16BE(int $value): self { return $this->writeInt(IntType::UINT16, $value); From 9203ee4462f6875a95131ac870356a654dc53164 Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 17:33:33 +0200 Subject: [PATCH 04/15] Reinplement (read|write)(Bytes|String)WithLength --- src/BinaryReader.php | 30 +++++++----- src/BinaryWriter.php | 35 ++++++++------ tests/BinaryReaderTest.php | 94 ++++++++++++++++++++++++++++++++++++++ tests/BinaryWriterTest.php | 78 ++++++++++++++++++++++++++++++- 4 files changed, 210 insertions(+), 27 deletions(-) diff --git a/src/BinaryReader.php b/src/BinaryReader.php index 228f0f8..082e6fa 100644 --- a/src/BinaryReader.php +++ b/src/BinaryReader.php @@ -75,22 +75,24 @@ public function readBytes(int $count): BinaryString return BinaryString::fromString($result); } - public function readBytesWithLength(bool $use16BitLength = false): BinaryString + public function readBytesWith(IntType $length): BinaryString { - if ($use16BitLength) { - $length = $this->readInt(IntType::UINT16); - } else { - $length = $this->readInt(IntType::UINT8); - } + $dataLength = $this->readInt($length); try { - return $this->readBytes($length); + return $this->readBytes($dataLength); } catch (\RuntimeException $exception) { - $this->position -= ($use16BitLength ? 2 : 1); + $this->position -= $length->bytes(); throw $exception; } } + #[\Deprecated('Use readBytesWith(IntType::UINT8) or readBytesWith(IntType::UINT16) instead')] + public function readBytesWithLength(bool $use16BitLength = false): BinaryString + { + return $this->readBytesWith($use16BitLength ? IntType::UINT16 : IntType::UINT8); + } + public function readInt(IntType $type): int { if (!$type->isSupported()) { @@ -145,18 +147,24 @@ public function readString(int $length): BinaryString return $bytes; } - public function readStringWithLength(bool $use16BitLength = false): BinaryString + public function readStringWith(IntType $length): BinaryString { - $string = $this->readBytesWithLength($use16BitLength); + $string = $this->readBytesWith($length); if (!mb_check_encoding($string->value, 'UTF-8')) { - $this->position -= ($use16BitLength ? 2 : 1) + $string->size(); + $this->position -= $length->bytes() + $string->size(); throw new RuntimeException('Invalid UTF-8 string'); } return $string; } + #[\Deprecated('Use readStringWith(IntType::UINT8) or readStringWith(IntType::UINT16) instead')] + public function readStringWithLength(bool $use16BitLength = false): BinaryString + { + return $this->readStringWith($use16BitLength ? IntType::UINT16 : IntType::UINT8); + } + public function peekByte(): int { if ($this->position >= $this->length) { diff --git a/src/BinaryWriter.php b/src/BinaryWriter.php index debcc0f..f96c4a1 100644 --- a/src/BinaryWriter.php +++ b/src/BinaryWriter.php @@ -40,26 +40,27 @@ public function writeBytes(BinaryString $bytes): self return $this; } - public function writeBytesWithLength(BinaryString $bytes, bool $use16BitLength = false): self + public function writeBytesWith(BinaryString $bytes, IntType $length): self { - $length = $bytes->size(); - if ($use16BitLength) { - if ($length > 65535) { - throw new \InvalidArgumentException('String too long for 16-bit length field'); - } - $this->writeUint16BE($length); - } else { - if ($length > 255) { - throw new \InvalidArgumentException('String too long for 8-bit length field'); - } - $this->writeByte($length); + $dataLength = $bytes->size(); + $maxLength = $length->maxValue(); + + if ($dataLength > $maxLength) { + throw new \InvalidArgumentException("Data too long for {$length->name} length field (max: {$maxLength})"); } + $this->writeInt($length, $dataLength); $this->writeBytes($bytes); return $this; } + #[\Deprecated('Use writeBytesWith($bytes, IntType::UINT8) or writeBytesWith($bytes, IntType::UINT16) instead')] + public function writeBytesWithLength(BinaryString $bytes, bool $use16BitLength = false): self + { + return $this->writeBytesWith($bytes, $use16BitLength ? IntType::UINT16 : IntType::UINT8); + } + public function writeInt(IntType $type, int $value): self { if (!$type->isSupported()) { @@ -113,14 +114,20 @@ public function writeString(BinaryString $string): self return $this; } - public function writeStringWithLength(BinaryString $string, bool $use16BitLength = false): self + public function writeStringWith(BinaryString $string, IntType $length): self { if (!mb_check_encoding($string->value, 'UTF-8')) { throw new \InvalidArgumentException('String must be valid UTF-8'); } - $this->writeBytesWithLength($string, $use16BitLength); + $this->writeBytesWith($string, $length); return $this; } + + #[\Deprecated('Use writeStringWith($string, IntType::UINT8) or writeStringWith($string, IntType::UINT16) instead')] + public function writeStringWithLength(BinaryString $string, bool $use16BitLength = false): self + { + return $this->writeStringWith($string, $use16BitLength ? IntType::UINT16 : IntType::UINT8); + } } diff --git a/tests/BinaryReaderTest.php b/tests/BinaryReaderTest.php index d351552..705117b 100644 --- a/tests/BinaryReaderTest.php +++ b/tests/BinaryReaderTest.php @@ -6,6 +6,7 @@ use KDuma\BinaryTools\BinaryString; use KDuma\BinaryTools\IntType; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversClass(BinaryReader::class)] @@ -393,4 +394,97 @@ public function testGetRemainingBytes() $this->reader->seek(4); $this->assertEquals(0, $this->reader->remaining_bytes); } + + /** + * @return array + */ + public static function readBytesWithProvider(): array + { + return [ + 'UINT8 short' => ['data' => "\x03abc", 'length' => IntType::UINT8, 'expected' => 'abc'], + 'UINT8 empty' => ['data' => "\x00", 'length' => IntType::UINT8, 'expected' => ''], + 'UINT16 short' => ['data' => "\x00\x03abc", 'length' => IntType::UINT16, 'expected' => 'abc'], + 'UINT16_LE short' => ['data' => "\x03\x00abc", 'length' => IntType::UINT16_LE, 'expected' => 'abc'], + 'UINT32 short' => ['data' => "\x00\x00\x00\x03abc", 'length' => IntType::UINT32, 'expected' => 'abc'], + ]; + } + + #[DataProvider('readBytesWithProvider')] + public function testReadBytesWith(string $data, IntType $length, string $expected): void + { + $reader = new BinaryReader(BinaryString::fromString($data)); + $result = $reader->readBytesWith($length); + $this->assertEquals($expected, $result->toString()); + $this->assertEquals(strlen($data), $reader->position); + } + + #[DataProvider('readBytesWithProvider')] + public function testReadStringWith(string $data, IntType $length, string $expected): void + { + $reader = new BinaryReader(BinaryString::fromString($data)); + $result = $reader->readStringWith($length); + $this->assertEquals($expected, $result->toString()); + $this->assertEquals(strlen($data), $reader->position); + } + + public function testReadBytesWithInsufficientData(): void + { + $reader = new BinaryReader(BinaryString::fromString("\x05abc")); // Claims 5 bytes but only has 3 + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unexpected end of data while reading 5 bytes'); + + $reader->readBytesWith(IntType::UINT8); + } + + public function testReadStringWithInvalidUTF8(): void + { + $reader = new BinaryReader(BinaryString::fromString("\x02\xFF\xFE")); // 2 bytes of invalid UTF-8 + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid UTF-8 string'); + + $reader->readStringWith(IntType::UINT8); + } + + public function testReadBytesWithLengthDeprecated(): void + { + $reader = new BinaryReader(BinaryString::fromString("\x03abc")); + $result = $reader->readBytesWithLength(); + $this->assertEquals('abc', $result->toString()); + + $reader = new BinaryReader(BinaryString::fromString("\x00\x03abc")); + $result = $reader->readBytesWithLength(true); + $this->assertEquals('abc', $result->toString()); + } + + public function testReadStringWithLengthDeprecated(): void + { + $reader = new BinaryReader(BinaryString::fromString("\x03abc")); + $result = $reader->readStringWithLength(); + $this->assertEquals('abc', $result->toString()); + + $reader = new BinaryReader(BinaryString::fromString("\x00\x03abc")); + $result = $reader->readStringWithLength(true); + $this->assertEquals('abc', $result->toString()); + } + + public function testRoundTripCompatibility(): void + { + $writer = new \KDuma\BinaryTools\BinaryWriter(); + $testData = BinaryString::fromString("Hello, World!"); + + // Test UINT8 + $writer->writeBytesWith($testData, IntType::UINT8); + $reader = new BinaryReader($writer->getBuffer()); + $result = $reader->readBytesWith(IntType::UINT8); + $this->assertTrue($testData->equals($result)); + + // Test UINT16 + $writer->reset(); + $writer->writeBytesWith($testData, IntType::UINT16); + $reader = new BinaryReader($writer->getBuffer()); + $result = $reader->readBytesWith(IntType::UINT16); + $this->assertTrue($testData->equals($result)); + } } diff --git a/tests/BinaryWriterTest.php b/tests/BinaryWriterTest.php index 7d6e920..8da3bbb 100644 --- a/tests/BinaryWriterTest.php +++ b/tests/BinaryWriterTest.php @@ -132,7 +132,7 @@ public function testWriteBytesWithLength() $this->writer->writeBytesWithLength(BinaryString::fromString(str_repeat("\x00", 255 + 1))); $this->fail("Expected exception not thrown"); } catch (\InvalidArgumentException $exception) { - $this->assertEquals('String too long for 8-bit length field', $exception->getMessage()); + $this->assertEquals('Data too long for UINT8 length field (max: 255)', $exception->getMessage()); $this->assertEquals(0, $this->writer->getLength()); } @@ -145,7 +145,7 @@ public function testWriteBytesWithLength() $this->writer->writeBytesWithLength(BinaryString::fromString(str_repeat("\x00", 65535 + 1)), true); $this->fail("Expected exception not thrown"); } catch (\InvalidArgumentException $exception) { - $this->assertEquals('String too long for 16-bit length field', $exception->getMessage()); + $this->assertEquals('Data too long for UINT16 length field (max: 65535)', $exception->getMessage()); $this->assertEquals(0, $this->writer->getLength()); } } @@ -288,4 +288,78 @@ public function testWriteUint16BEDeprecated(): void $this->writer->writeUint16BE(1234); $this->assertEquals("\x04\xD2", $this->writer->getBuffer()->toString()); } + + /** + * @return array + */ + public static function writeBytesWithProvider(): array + { + return [ + 'UINT8 short' => ['data' => 'abc', 'length' => IntType::UINT8, 'expected' => "\x03abc"], + 'UINT8 empty' => ['data' => '', 'length' => IntType::UINT8, 'expected' => "\x00"], + 'UINT16 short' => ['data' => 'abc', 'length' => IntType::UINT16, 'expected' => "\x00\x03abc"], + 'UINT16_LE short' => ['data' => 'abc', 'length' => IntType::UINT16_LE, 'expected' => "\x03\x00abc"], + 'UINT32 short' => ['data' => 'abc', 'length' => IntType::UINT32, 'expected' => "\x00\x00\x00\x03abc"], + ]; + } + + #[DataProvider('writeBytesWithProvider')] + public function testWriteBytesWith(string $data, IntType $length, string $expected): void + { + $this->writer->reset(); + $this->writer->writeBytesWith(BinaryString::fromString($data), $length); + $this->assertEquals($expected, $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithOverflow(): void + { + $this->writer->reset(); + $data = BinaryString::fromString(str_repeat('a', 256)); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Data too long for UINT8 length field (max: 255)'); + + $this->writer->writeBytesWith($data, IntType::UINT8); + } + + #[DataProvider('writeBytesWithProvider')] + public function testWriteStringWith(string $data, IntType $length, string $expected): void + { + $this->writer->reset(); + $this->writer->writeStringWith(BinaryString::fromString($data), $length); + $this->assertEquals($expected, $this->writer->getBuffer()->toString()); + } + + public function testWriteStringWithInvalidUTF8(): void + { + $this->writer->reset(); + $invalidUTF8 = BinaryString::fromString("\xFF\xFE"); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('String must be valid UTF-8'); + + $this->writer->writeStringWith($invalidUTF8, IntType::UINT8); + } + + public function testWriteBytesWithLengthDeprecated(): void + { + $this->writer->reset(); + $this->writer->writeBytesWithLength(BinaryString::fromString("abc")); + $this->assertEquals("\x03abc", $this->writer->getBuffer()->toString()); + + $this->writer->reset(); + $this->writer->writeBytesWithLength(BinaryString::fromString("abc"), true); + $this->assertEquals("\x00\x03abc", $this->writer->getBuffer()->toString()); + } + + public function testWriteStringWithLengthDeprecated(): void + { + $this->writer->reset(); + $this->writer->writeStringWithLength(BinaryString::fromString("abc")); + $this->assertEquals("\x03abc", $this->writer->getBuffer()->toString()); + + $this->writer->reset(); + $this->writer->writeStringWithLength(BinaryString::fromString("abc"), true); + $this->assertEquals("\x00\x03abc", $this->writer->getBuffer()->toString()); + } } From 1e626c00409458537dabb71dc6dc6af13af3658e Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 17:38:52 +0200 Subject: [PATCH 05/15] Reorganize deprecations --- src/BinaryReader.php | 37 ++++++++++++++++++++----------------- src/BinaryWriter.php | 27 +++++++++++++++------------ 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/src/BinaryReader.php b/src/BinaryReader.php index 082e6fa..ff1ab95 100644 --- a/src/BinaryReader.php +++ b/src/BinaryReader.php @@ -87,12 +87,6 @@ public function readBytesWith(IntType $length): BinaryString } } - #[\Deprecated('Use readBytesWith(IntType::UINT8) or readBytesWith(IntType::UINT16) instead')] - public function readBytesWithLength(bool $use16BitLength = false): BinaryString - { - return $this->readBytesWith($use16BitLength ? IntType::UINT16 : IntType::UINT8); - } - public function readInt(IntType $type): int { if (!$type->isSupported()) { @@ -128,11 +122,6 @@ public function readInt(IntType $type): int return $value; } - #[Deprecated('Use readInt(IntType::UINT16) instead')] - public function readUint16BE(): int - { - return $this->readInt(IntType::UINT16); - } public function readString(int $length): BinaryString { @@ -159,12 +148,6 @@ public function readStringWith(IntType $length): BinaryString return $string; } - #[\Deprecated('Use readStringWith(IntType::UINT8) or readStringWith(IntType::UINT16) instead')] - public function readStringWithLength(bool $use16BitLength = false): BinaryString - { - return $this->readStringWith($use16BitLength ? IntType::UINT16 : IntType::UINT8); - } - public function peekByte(): int { if ($this->position >= $this->length) { @@ -196,4 +179,24 @@ public function seek(int $position): void { $this->position = $position; } + + // Deprecated methods + + #[Deprecated('Use readInt(IntType::UINT16) instead')] + public function readUint16BE(): int + { + return $this->readInt(IntType::UINT16); + } + + #[Deprecated('Use readBytesWith(length: IntType::UINT8) or readBytesWith(length: IntType::UINT16) instead')] + public function readBytesWithLength(bool $use16BitLength = false): BinaryString + { + return $this->readBytesWith($use16BitLength ? IntType::UINT16 : IntType::UINT8); + } + + #[Deprecated('Use readStringWith(length: IntType::UINT8) or readStringWith(length: IntType::UINT16) instead')] + public function readStringWithLength(bool $use16BitLength = false): BinaryString + { + return $this->readStringWith($use16BitLength ? IntType::UINT16 : IntType::UINT8); + } } diff --git a/src/BinaryWriter.php b/src/BinaryWriter.php index f96c4a1..4219752 100644 --- a/src/BinaryWriter.php +++ b/src/BinaryWriter.php @@ -55,12 +55,6 @@ public function writeBytesWith(BinaryString $bytes, IntType $length): self return $this; } - #[\Deprecated('Use writeBytesWith($bytes, IntType::UINT8) or writeBytesWith($bytes, IntType::UINT16) instead')] - public function writeBytesWithLength(BinaryString $bytes, bool $use16BitLength = false): self - { - return $this->writeBytesWith($bytes, $use16BitLength ? IntType::UINT16 : IntType::UINT8); - } - public function writeInt(IntType $type, int $value): self { if (!$type->isSupported()) { @@ -97,11 +91,6 @@ public function writeInt(IntType $type, int $value): self return $this; } - #[Deprecated('Use writeInt(IntType::UINT16, $value) instead')] - public function writeUint16BE(int $value): self - { - return $this->writeInt(IntType::UINT16, $value); - } public function writeString(BinaryString $string): self { @@ -125,7 +114,21 @@ public function writeStringWith(BinaryString $string, IntType $length): self return $this; } - #[\Deprecated('Use writeStringWith($string, IntType::UINT8) or writeStringWith($string, IntType::UINT16) instead')] + // Deprecated methods + + #[Deprecated('Use writeInt(IntType::UINT16, $value) instead')] + public function writeUint16BE(int $value): self + { + return $this->writeInt(IntType::UINT16, $value); + } + + #[Deprecated('Use writeBytesWith($bytes, length: IntType::UINT8) or writeBytesWith($bytes, length: IntType::UINT16) instead')] + public function writeBytesWithLength(BinaryString $bytes, bool $use16BitLength = false): self + { + return $this->writeBytesWith($bytes, $use16BitLength ? IntType::UINT16 : IntType::UINT8); + } + + #[Deprecated('Use writeStringWith($string, length: IntType::UINT8) or writeStringWith($string, length: IntType::UINT16) instead')] public function writeStringWithLength(BinaryString $string, bool $use16BitLength = false): self { return $this->writeStringWith($string, $use16BitLength ? IntType::UINT16 : IntType::UINT8); From 4841dfcbbe9dde8605131550649355d895f54e87 Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 17:47:20 +0200 Subject: [PATCH 06/15] Fix complaints from AI --- src/BinaryReader.php | 9 ++++++++- tests/BinaryReaderTest.php | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/BinaryReader.php b/src/BinaryReader.php index ff1ab95..3bbee6d 100644 --- a/src/BinaryReader.php +++ b/src/BinaryReader.php @@ -111,7 +111,7 @@ public function readInt(IntType $type): int if ($type->isSigned()) { $bits = $bytesCount * 8; - if ($bits < PHP_INT_SIZE * 8) { + if ($bits <= PHP_INT_SIZE * 8) { $signBit = 1 << ($bits - 1); if (($value & $signBit) !== 0) { $value -= 1 << $bits; @@ -119,6 +119,13 @@ public function readInt(IntType $type): int } } + // Validate unsigned integers that may have wrapped due to PHP integer limits + if (!$type->isSigned() && !$type->isValid($value)) { + throw new RuntimeException( + sprintf('Value exceeds maximum for %s on this platform', $type->name) + ); + } + return $value; } diff --git a/tests/BinaryReaderTest.php b/tests/BinaryReaderTest.php index 705117b..7756484 100644 --- a/tests/BinaryReaderTest.php +++ b/tests/BinaryReaderTest.php @@ -487,4 +487,19 @@ public function testRoundTripCompatibility(): void $result = $reader->readBytesWith(IntType::UINT16); $this->assertTrue($testData->equals($result)); } + + public function testReadIntUnsignedOverflow(): void + { + // Test UINT64 overflow detection on 64-bit systems + if (PHP_INT_SIZE < 8) { + $this->markTestSkipped('64-bit integers are not supported on this platform'); + } + + $reader = new BinaryReader(BinaryString::fromHex("FFFFFFFFFFFFFFFF")); // Would wrap to -1 + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Value exceeds maximum for UINT64 on this platform'); + + $reader->readInt(IntType::UINT64); + } } From f99ccb8fbf7e3622b52ad0349fbd879190e5cb5d Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 18:53:40 +0200 Subject: [PATCH 07/15] Add support for terminated bytes and strings --- src/BinaryReader.php | 90 +++++++++++--- src/BinaryString.php | 9 ++ src/BinaryWriter.php | 59 ++++++--- src/Terminator.php | 84 +++++++++++++ tests/BinaryReaderTest.php | 241 +++++++++++++++++++++++++++++++++++++ tests/BinaryStringTest.php | 8 ++ tests/BinaryWriterTest.php | 128 ++++++++++++++++++++ tests/TerminatorTest.php | 147 ++++++++++++++++++++++ 8 files changed, 736 insertions(+), 30 deletions(-) create mode 100644 src/Terminator.php create mode 100644 tests/TerminatorTest.php diff --git a/src/BinaryReader.php b/src/BinaryReader.php index 3bbee6d..5271a7e 100644 --- a/src/BinaryReader.php +++ b/src/BinaryReader.php @@ -75,15 +75,28 @@ public function readBytes(int $count): BinaryString return BinaryString::fromString($result); } - public function readBytesWith(IntType $length): BinaryString - { - $dataLength = $this->readInt($length); + public function readBytesWith( + ?IntType $length = null, + Terminator|BinaryString|null $terminator = null, + Terminator|BinaryString|null $optional_terminator = null, + ): BinaryString { + $modes = array_filter([ + 'length' => $length, + 'terminator' => $terminator, + 'optional_terminator' => $optional_terminator, + ], static fn ($value) => $value !== null); + + if (count($modes) !== 1) { + throw new \InvalidArgumentException('Exactly one of length terminator or optional_terminator must be provided'); + } - try { - return $this->readBytes($dataLength); - } catch (\RuntimeException $exception) { - $this->position -= $length->bytes(); - throw $exception; + $selectedMode = array_key_first($modes); + + + if ($selectedMode === 'length') { + return $this->_readWithLength($length); + } elseif ($selectedMode === 'terminator' || $selectedMode === 'optional_terminator') { + return $this->_readWithTerminator($modes[$selectedMode], $selectedMode === 'optional_terminator'); } } @@ -143,12 +156,16 @@ public function readString(int $length): BinaryString return $bytes; } - public function readStringWith(IntType $length): BinaryString - { - $string = $this->readBytesWith($length); + public function readStringWith( + ?IntType $length = null, + Terminator|BinaryString|null $terminator = null, + Terminator|BinaryString|null $optional_terminator = null, + ): BinaryString { + $startPosition = $this->position; + $string = $this->readBytesWith($length, $terminator, $optional_terminator); if (!mb_check_encoding($string->value, 'UTF-8')) { - $this->position -= $length->bytes() + $string->size(); + $this->position = $startPosition; throw new RuntimeException('Invalid UTF-8 string'); } @@ -198,12 +215,57 @@ public function readUint16BE(): int #[Deprecated('Use readBytesWith(length: IntType::UINT8) or readBytesWith(length: IntType::UINT16) instead')] public function readBytesWithLength(bool $use16BitLength = false): BinaryString { - return $this->readBytesWith($use16BitLength ? IntType::UINT16 : IntType::UINT8); + return $this->readBytesWith($use16BitLength ? IntType::UINT16 : IntType::UINT8, null, null); } #[Deprecated('Use readStringWith(length: IntType::UINT8) or readStringWith(length: IntType::UINT16) instead')] public function readStringWithLength(bool $use16BitLength = false): BinaryString { - return $this->readStringWith($use16BitLength ? IntType::UINT16 : IntType::UINT8); + return $this->readStringWith($use16BitLength ? IntType::UINT16 : IntType::UINT8, null, null); + } + + // Private methods + + private function _readWithLength(IntType $length): BinaryString + { + $dataLength = $this->readInt($length); + + try { + return $this->readBytes($dataLength); + } catch (\RuntimeException $exception) { + $this->position -= $length->bytes(); + throw $exception; + } + } + + private function _readWithTerminator(Terminator|BinaryString $terminator, bool $terminatorIsOptional): BinaryString + { + $terminatorBytes = $terminator instanceof Terminator ? $terminator->toBytes() : $terminator; + $terminatorSize = $terminatorBytes->size(); + + if ($terminatorSize === 0) { + if ($terminatorIsOptional) { + return BinaryString::fromString(''); + } + + throw new \InvalidArgumentException('Terminator cannot be empty when required'); + } + + $remainingData = substr($this->_data, $this->position); + $terminatorPosition = strpos($remainingData, $terminatorBytes->value); + + if ($terminatorPosition === false) { + if ($terminatorIsOptional) { + $this->position = $this->length; + return BinaryString::fromString($remainingData); + } + + throw new RuntimeException('Terminator not found before end of data'); + } + + $result = substr($remainingData, 0, $terminatorPosition); + $this->position += $terminatorPosition + $terminatorSize; + + return BinaryString::fromString($result); } } diff --git a/src/BinaryString.php b/src/BinaryString.php index da928aa..6455bb6 100644 --- a/src/BinaryString.php +++ b/src/BinaryString.php @@ -70,4 +70,13 @@ public function equals(BinaryString $other): bool { return hash_equals($this->value, $other->value); } + + public function contains(BinaryString $needle): bool + { + if ($needle->size() === 0) { + return true; + } + + return str_contains($this->value, $needle->value); + } } diff --git a/src/BinaryWriter.php b/src/BinaryWriter.php index 4219752..8c883e8 100644 --- a/src/BinaryWriter.php +++ b/src/BinaryWriter.php @@ -40,19 +40,17 @@ public function writeBytes(BinaryString $bytes): self return $this; } - public function writeBytesWith(BinaryString $bytes, IntType $length): self + public function writeBytesWith(BinaryString $bytes, ?IntType $length = null, Terminator|BinaryString|null $terminator = null): self { - $dataLength = $bytes->size(); - $maxLength = $length->maxValue(); - - if ($dataLength > $maxLength) { - throw new \InvalidArgumentException("Data too long for {$length->name} length field (max: {$maxLength})"); + if (($length === null && $terminator === null) || ($length !== null && $terminator !== null)) { + throw new \InvalidArgumentException('Exactly one of length or terminator must be provided'); } - $this->writeInt($length, $dataLength); - $this->writeBytes($bytes); - - return $this; + if ($length !== null) { + return $this->_writeWithLength($bytes, $length); + } else { + return $this->_writeWithTerminator($bytes, $terminator); + } } public function writeInt(IntType $type, int $value): self @@ -103,15 +101,13 @@ public function writeString(BinaryString $string): self return $this; } - public function writeStringWith(BinaryString $string, IntType $length): self + public function writeStringWith(BinaryString $string, ?IntType $length = null, Terminator|BinaryString|null $terminator = null): self { if (!mb_check_encoding($string->value, 'UTF-8')) { throw new \InvalidArgumentException('String must be valid UTF-8'); } - $this->writeBytesWith($string, $length); - - return $this; + return $this->writeBytesWith($string, $length, $terminator); } // Deprecated methods @@ -125,12 +121,43 @@ public function writeUint16BE(int $value): self #[Deprecated('Use writeBytesWith($bytes, length: IntType::UINT8) or writeBytesWith($bytes, length: IntType::UINT16) instead')] public function writeBytesWithLength(BinaryString $bytes, bool $use16BitLength = false): self { - return $this->writeBytesWith($bytes, $use16BitLength ? IntType::UINT16 : IntType::UINT8); + return $this->writeBytesWith($bytes, $use16BitLength ? IntType::UINT16 : IntType::UINT8, null); } #[Deprecated('Use writeStringWith($string, length: IntType::UINT8) or writeStringWith($string, length: IntType::UINT16) instead')] public function writeStringWithLength(BinaryString $string, bool $use16BitLength = false): self { - return $this->writeStringWith($string, $use16BitLength ? IntType::UINT16 : IntType::UINT8); + return $this->writeStringWith($string, $use16BitLength ? IntType::UINT16 : IntType::UINT8, null); + } + + // Private methods + + private function _writeWithLength(BinaryString $data, IntType $length): self + { + $dataLength = $data->size(); + $maxLength = $length->maxValue(); + + if ($dataLength > $maxLength) { + throw new \InvalidArgumentException("Data too long for {$length->name} length field (max: {$maxLength})"); + } + + $this->writeInt($length, $dataLength); + $this->writeBytes($data); + + return $this; + } + + private function _writeWithTerminator(BinaryString $data, Terminator|BinaryString $terminator): self + { + $terminatorBytes = $terminator instanceof Terminator ? $terminator->toBytes() : $terminator; + + if ($data->contains($terminatorBytes)) { + throw new \InvalidArgumentException('Data contains terminator sequence'); + } + + $this->writeBytes($data); + $this->writeBytes($terminatorBytes); + + return $this; } } diff --git a/src/Terminator.php b/src/Terminator.php new file mode 100644 index 0000000..fb02a5c --- /dev/null +++ b/src/Terminator.php @@ -0,0 +1,84 @@ + BinaryString::fromString("\x00"), + self::SOH => BinaryString::fromString("\x01"), + self::STX => BinaryString::fromString("\x02"), + self::ETX => BinaryString::fromString("\x03"), + self::EOT => BinaryString::fromString("\x04"), + self::ENQ => BinaryString::fromString("\x05"), + self::ACK => BinaryString::fromString("\x06"), + self::BEL => BinaryString::fromString("\x07"), + self::BS => BinaryString::fromString("\x08"), + self::HT => BinaryString::fromString("\x09"), + self::LF => BinaryString::fromString("\x0A"), + self::VT => BinaryString::fromString("\x0B"), + self::FF => BinaryString::fromString("\x0C"), + self::CR => BinaryString::fromString("\x0D"), + self::SO => BinaryString::fromString("\x0E"), + self::SI => BinaryString::fromString("\x0F"), + self::DLE => BinaryString::fromString("\x10"), + self::DC1 => BinaryString::fromString("\x11"), + self::DC2 => BinaryString::fromString("\x12"), + self::DC3 => BinaryString::fromString("\x13"), + self::DC4 => BinaryString::fromString("\x14"), + self::NAK => BinaryString::fromString("\x15"), + self::SYN => BinaryString::fromString("\x16"), + self::ETB => BinaryString::fromString("\x17"), + self::CAN => BinaryString::fromString("\x18"), + self::EM => BinaryString::fromString("\x19"), + self::SUB => BinaryString::fromString("\x1A"), + self::ESC => BinaryString::fromString("\x1B"), + self::FS => BinaryString::fromString("\x1C"), + self::GS => BinaryString::fromString("\x1D"), + self::RS => BinaryString::fromString("\x1E"), + self::US => BinaryString::fromString("\x1F"), + self::SP => BinaryString::fromString("\x20"), + self::CRLF => BinaryString::fromString("\x0D\x0A"), + }; + } +} diff --git a/tests/BinaryReaderTest.php b/tests/BinaryReaderTest.php index 7756484..de180c8 100644 --- a/tests/BinaryReaderTest.php +++ b/tests/BinaryReaderTest.php @@ -5,6 +5,7 @@ use KDuma\BinaryTools\BinaryReader; use KDuma\BinaryTools\BinaryString; use KDuma\BinaryTools\IntType; +use KDuma\BinaryTools\Terminator; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -502,4 +503,244 @@ public function testReadIntUnsignedOverflow(): void $reader->readInt(IntType::UINT64); } + + public function testReadBytesWithTerminatorNUL(): void + { + $reader = new BinaryReader(BinaryString::fromString("Hello\x00World")); + $result = $reader->readBytesWith(terminator: Terminator::NUL); + $this->assertEquals("Hello", $result->toString()); + $this->assertEquals(6, $reader->position); // 5 bytes + 1 terminator consumed + } + + public function testReadBytesWithTerminatorGS(): void + { + $reader = new BinaryReader(BinaryString::fromString("Data\x1DMore")); + $result = $reader->readBytesWith(terminator: Terminator::GS); + $this->assertEquals("Data", $result->toString()); + $this->assertEquals(5, $reader->position); // 4 bytes + 1 terminator consumed + } + + public function testReadBytesWithCustomTerminator(): void + { + $reader = new BinaryReader(BinaryString::fromString("Line 1\r\nLine 2")); + $customTerminator = BinaryString::fromString("\r\n"); + $result = $reader->readBytesWith(terminator: $customTerminator); + $this->assertEquals("Line 1", $result->toString()); + $this->assertEquals(8, $reader->position); // 6 bytes + 2 terminator bytes consumed + } + + public function testReadStringWithTerminatorNUL(): void + { + $reader = new BinaryReader(BinaryString::fromString("Hello World\x00Rest")); + $result = $reader->readStringWith(terminator: Terminator::NUL); + $this->assertEquals("Hello World", $result->toString()); + $this->assertEquals(12, $reader->position); // 11 bytes + 1 terminator consumed + } + + public function testReadBytesWithTerminatorAtStart(): void + { + $reader = new BinaryReader(BinaryString::fromString("\x00Data")); + $result = $reader->readBytesWith(terminator: Terminator::NUL); + $this->assertEquals("", $result->toString()); + $this->assertEquals(1, $reader->position); // Just the terminator consumed + } + + public function testReadBytesWithMultipleTerminators(): void + { + $reader = new BinaryReader(BinaryString::fromString("First\x00\x00Second")); + + // First read should stop at first terminator + $result1 = $reader->readBytesWith(terminator: Terminator::NUL); + $this->assertEquals("First", $result1->toString()); + $this->assertEquals(6, $reader->position); + + // Second read should get empty string (next char is terminator) + $result2 = $reader->readBytesWith(terminator: Terminator::NUL); + $this->assertEquals("", $result2->toString()); + $this->assertEquals(7, $reader->position); + } + + public function testReadBytesWithNoTerminatorFound(): void + { + $reader = new BinaryReader(BinaryString::fromString("NoTerminator")); + $result = $reader->readBytesWith(optional_terminator: Terminator::NUL); + $this->assertEquals("NoTerminator", $result->toString()); + $this->assertEquals(12, $reader->position); // All data consumed, no terminator found + } + + public function testReadBytesWithMissingRequiredTerminatorThrows(): void + { + $reader = new BinaryReader(BinaryString::fromString("Incomplete")); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Terminator not found before end of data'); + + $reader->readBytesWith(terminator: Terminator::NUL); + } + + public function testReadStringWithMissingRequiredTerminatorThrows(): void + { + $reader = new BinaryReader(BinaryString::fromString("No terminator")); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Terminator not found before end of data'); + + $reader->readStringWith(terminator: Terminator::NUL); + } + + public function testReadStringWithTerminatorParameterValidation(): void + { + $reader = new BinaryReader(BinaryString::fromString("test")); + + // Test both parameters provided + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Exactly one of length terminator or optional_terminator must be provided'); + + $reader->readStringWith(IntType::UINT8, Terminator::NUL); + } + + public function testReadBytesWithNoParameters(): void + { + $reader = new BinaryReader(BinaryString::fromString("test")); + + // Test no parameters provided + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Exactly one of length terminator or optional_terminator must be provided'); + + $reader->readBytesWith(); + } + + public function testReadBytesWithBothTerminatorsProvided(): void + { + $reader = new BinaryReader(BinaryString::fromString("data")); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Exactly one of length terminator or optional_terminator must be provided'); + + $reader->readBytesWith(terminator: Terminator::NUL, optional_terminator: Terminator::NUL); + } + + public function testReadStringWithInvalidUTF8AndTerminator(): void + { + $reader = new BinaryReader(BinaryString::fromString("\xFF\xFE\x00")); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid UTF-8 string'); + + $reader->readStringWith(terminator: Terminator::NUL); + } + + public function testReadStringWithOptionalTerminatorUntilEnd(): void + { + $reader = new BinaryReader(BinaryString::fromString("Partial")); + $result = $reader->readStringWith(optional_terminator: Terminator::NUL); + + $this->assertEquals("Partial", $result->toString()); + $this->assertEquals(7, $reader->position); + } + + public function testRoundTripWithTerminators(): void + { + $writer = new \KDuma\BinaryTools\BinaryWriter(); + $testData = BinaryString::fromString("Hello, World!"); + + // Test NUL terminator round trip + $writer->writeBytesWith($testData, terminator: Terminator::NUL); + $reader = new BinaryReader($writer->getBuffer()); + $result = $reader->readBytesWith(terminator: Terminator::NUL); + $this->assertTrue($testData->equals($result)); + + // Test custom terminator round trip + $writer->reset(); + $customTerminator = BinaryString::fromString("||"); + $writer->writeStringWith($testData, terminator: $customTerminator); + $reader = new BinaryReader($writer->getBuffer()); + $result = $reader->readStringWith(terminator: $customTerminator); + $this->assertTrue($testData->equals($result)); + } + + public function testReadBytesWithCRLFTerminator(): void + { + $reader = new BinaryReader(BinaryString::fromString("HTTP/1.1 200 OK\x0D\x0AContent-Type: text/html")); + $result = $reader->readBytesWith(terminator: Terminator::CRLF); + $this->assertEquals("HTTP/1.1 200 OK", $result->toString()); + $this->assertEquals(17, $reader->position); // 15 chars + 2 CRLF bytes consumed + } + + public function testReadStringWithCommonControlCharacters(): void + { + // Test with Line Feed + $reader = new BinaryReader(BinaryString::fromString("Unix line\x0ANext line")); + $result = $reader->readStringWith(terminator: Terminator::LF); + $this->assertEquals("Unix line", $result->toString()); + $this->assertEquals(10, $reader->position); + + // Test with Tab separator + $reader = new BinaryReader(BinaryString::fromString("field1\x09field2\x09field3")); + $field1 = $reader->readBytesWith(terminator: Terminator::HT); + $field2 = $reader->readBytesWith(terminator: Terminator::HT); + $this->assertEquals("field1", $field1->toString()); + $this->assertEquals("field2", $field2->toString()); + + // Test with Record Separator + $reader = new BinaryReader(BinaryString::fromString("record1\x1Erecord2\x1E")); + $rec1 = $reader->readStringWith(terminator: Terminator::RS); + $rec2 = $reader->readStringWith(terminator: Terminator::RS); + $this->assertEquals("record1", $rec1->toString()); + $this->assertEquals("record2", $rec2->toString()); + } + + public function testReadBytesWithProtocolSpecificTerminators(): void + { + // Test STX/ETX boundaries + $reader = new BinaryReader(BinaryString::fromString("\x02Important Message\x03More data")); + + // Skip STX + $stx = $reader->readBytesWith(terminator: Terminator::STX); + $this->assertEquals("", $stx->toString()); + + // Read message until ETX + $message = $reader->readBytesWith(terminator: Terminator::ETX); + $this->assertEquals("Important Message", $message->toString()); + + // Test XON/XOFF flow control characters + $reader = new BinaryReader(BinaryString::fromString("data\x11moredata\x13")); + $beforeXon = $reader->readBytesWith(terminator: Terminator::DC1); // XON + $beforeXoff = $reader->readBytesWith(terminator: Terminator::DC3); // XOFF + $this->assertEquals("data", $beforeXon->toString()); + $this->assertEquals("moredata", $beforeXoff->toString()); + } + + public function testReadWithMultipleNewTerminators(): void + { + // Test multiple different terminators in sequence + $data = "file1\x1Cgroup1\x1Drecord1\x1Eunit1\x1Fend\x20"; + $reader = new BinaryReader(BinaryString::fromString($data)); + + $file = $reader->readStringWith(terminator: Terminator::FS); // File Separator + $group = $reader->readStringWith(terminator: Terminator::GS); // Group Separator + $record = $reader->readStringWith(terminator: Terminator::RS); // Record Separator + $unit = $reader->readStringWith(terminator: Terminator::US); // Unit Separator + $end = $reader->readStringWith(terminator: Terminator::SP); // Space + + $this->assertEquals("file1", $file->toString()); + $this->assertEquals("group1", $group->toString()); + $this->assertEquals("record1", $record->toString()); + $this->assertEquals("unit1", $unit->toString()); + $this->assertEquals("end", $end->toString()); + } + + public function testCrlfVsLfTerminators(): void + { + // Test that CRLF and LF are handled differently + $reader = new BinaryReader(BinaryString::fromString("line1\x0D\x0Aline2\x0Aline3")); + + // Read first line with CRLF + $line1 = $reader->readStringWith(terminator: Terminator::CRLF); + $this->assertEquals("line1", $line1->toString()); + + // Read second line with just LF + $line2 = $reader->readStringWith(terminator: Terminator::LF); + $this->assertEquals("line2", $line2->toString()); + } } diff --git a/tests/BinaryStringTest.php b/tests/BinaryStringTest.php index f18f4cb..f8d7d4e 100644 --- a/tests/BinaryStringTest.php +++ b/tests/BinaryStringTest.php @@ -65,6 +65,14 @@ public function testEquals() $this->assertFalse($this->binaryString->equals(BinaryString::fromString("\xFF\xFF\xFF\xFF"))); } + public function testContains(): void + { + $this->assertTrue($this->binaryString->contains(BinaryString::fromString("\x01"))); + $this->assertTrue($this->binaryString->contains(BinaryString::fromString("\x02\x03"))); + $this->assertFalse($this->binaryString->contains(BinaryString::fromString("\xFF"))); + $this->assertTrue($this->binaryString->contains(BinaryString::fromString(''))); + } + public function testToBase32() { $binaryString = BinaryString::fromString("foobar"); diff --git a/tests/BinaryWriterTest.php b/tests/BinaryWriterTest.php index 8da3bbb..28c04a0 100644 --- a/tests/BinaryWriterTest.php +++ b/tests/BinaryWriterTest.php @@ -5,6 +5,7 @@ use KDuma\BinaryTools\BinaryString; use KDuma\BinaryTools\BinaryWriter; use KDuma\BinaryTools\IntType; +use KDuma\BinaryTools\Terminator; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -362,4 +363,131 @@ public function testWriteStringWithLengthDeprecated(): void $this->writer->writeStringWithLength(BinaryString::fromString("abc"), true); $this->assertEquals("\x00\x03abc", $this->writer->getBuffer()->toString()); } + + public function testWriteBytesWithTerminatorNUL(): void + { + $this->writer->reset(); + $data = BinaryString::fromString("Hello"); + $this->writer->writeBytesWith($data, terminator: Terminator::NUL); + $this->assertEquals("Hello\x00", $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithTerminatorGS(): void + { + $this->writer->reset(); + $data = BinaryString::fromString("World"); + $this->writer->writeBytesWith($data, terminator: Terminator::GS); + $this->assertEquals("World\x1D", $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithCustomTerminator(): void + { + $this->writer->reset(); + $data = BinaryString::fromString("test"); + $customTerminator = BinaryString::fromString("\r\n"); + $this->writer->writeBytesWith($data, terminator: $customTerminator); + $this->assertEquals("test\r\n", $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithTerminatorRejectsEmbeddedTerminator(): void + { + $this->writer->reset(); + $data = BinaryString::fromString("Hello\x00World"); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Data contains terminator sequence'); + + $this->writer->writeBytesWith($data, terminator: Terminator::NUL); + } + + public function testWriteStringWithTerminatorNUL(): void + { + $this->writer->reset(); + $string = BinaryString::fromString("Hello World"); + $this->writer->writeStringWith($string, terminator: Terminator::NUL); + $this->assertEquals("Hello World\x00", $this->writer->getBuffer()->toString()); + } + + public function testWriteStringWithCustomTerminator(): void + { + $this->writer->reset(); + $string = BinaryString::fromString("Line 1"); + $newlineTerminator = BinaryString::fromString("\n"); + $this->writer->writeStringWith($string, terminator: $newlineTerminator); + $this->assertEquals("Line 1\n", $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithParameterValidation(): void + { + $this->writer->reset(); + $data = BinaryString::fromString("test"); + + // Test both parameters provided + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Exactly one of length or terminator must be provided'); + + $this->writer->writeBytesWith($data, IntType::UINT8, Terminator::NUL); + } + + public function testWriteBytesWithNoParameters(): void + { + $this->writer->reset(); + $data = BinaryString::fromString("test"); + + // Test no parameters provided + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Exactly one of length or terminator must be provided'); + + $this->writer->writeBytesWith($data); + } + + public function testWriteStringWithInvalidUTF8AndTerminator(): void + { + $this->writer->reset(); + $invalidUTF8 = BinaryString::fromString("\xFF\xFE"); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('String must be valid UTF-8'); + + $this->writer->writeStringWith($invalidUTF8, terminator: Terminator::NUL); + } + + public function testWriteBytesWithCRLFTerminator(): void + { + $this->writer->reset(); + $data = BinaryString::fromString("HTTP/1.1 200 OK"); + $this->writer->writeBytesWith($data, terminator: Terminator::CRLF); + $this->assertEquals("HTTP/1.1 200 OK\x0D\x0A", $this->writer->getBuffer()->toString()); + } + + public function testWriteStringWithCommonControlCharacters(): void + { + $this->writer->reset(); + + // Test with Line Feed + $this->writer->writeStringWith(BinaryString::fromString("Unix line"), terminator: Terminator::LF); + $this->assertEquals("Unix line\x0A", $this->writer->getBuffer()->toString()); + + // Test with Tab separator + $this->writer->reset(); + $this->writer->writeBytesWith(BinaryString::fromString("field1"), terminator: Terminator::HT); + $this->assertEquals("field1\x09", $this->writer->getBuffer()->toString()); + + // Test with Record Separator + $this->writer->reset(); + $this->writer->writeStringWith(BinaryString::fromString("record"), terminator: Terminator::RS); + $this->assertEquals("record\x1E", $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithProtocolSpecificTerminators(): void + { + $this->writer->reset(); + + // STX/ETX for text boundaries + $this->writer->writeBytesWith(BinaryString::fromString(""), terminator: Terminator::STX); + $this->writer->writeBytesWith(BinaryString::fromString("Important Message"), terminator: Terminator::ETX); + + $expected = "\x02Important Message\x03"; + $this->assertEquals($expected, $this->writer->getBuffer()->toString()); + } } diff --git a/tests/TerminatorTest.php b/tests/TerminatorTest.php new file mode 100644 index 0000000..124c596 --- /dev/null +++ b/tests/TerminatorTest.php @@ -0,0 +1,147 @@ +toBytes(); + + $this->assertEquals("\x00", $bytes->toString()); + $this->assertEquals(1, $bytes->size()); + } + + public function testGsTerminator(): void + { + $terminator = Terminator::GS; + $bytes = $terminator->toBytes(); + + $this->assertEquals("\x1D", $bytes->toString()); + $this->assertEquals(1, $bytes->size()); + } + + public function testTerminatorEquality(): void + { + $nul1 = Terminator::NUL; + $nul2 = Terminator::NUL; + $gs = Terminator::GS; + + $this->assertEquals($nul1, $nul2); + $this->assertNotEquals($nul1, $gs); + $this->assertNotEquals($nul2, $gs); + } + + public function testTerminatorBytes(): void + { + $nullBytes = BinaryString::fromString("\x00"); + $gsBytes = BinaryString::fromString("\x1D"); + + $this->assertTrue(Terminator::NUL->toBytes()->equals($nullBytes)); + $this->assertTrue(Terminator::GS->toBytes()->equals($gsBytes)); + $this->assertFalse(Terminator::NUL->toBytes()->equals($gsBytes)); + $this->assertFalse(Terminator::GS->toBytes()->equals($nullBytes)); + } + + public function testCrlfTerminator(): void + { + $terminator = Terminator::CRLF; + $bytes = $terminator->toBytes(); + + $this->assertEquals("\x0D\x0A", $bytes->toString()); + $this->assertEquals(2, $bytes->size()); + } + + public function testCommonControlCharacters(): void + { + // Test some commonly used control characters + $this->assertEquals("\x09", Terminator::HT->toBytes()->toString()); // Tab + $this->assertEquals("\x0A", Terminator::LF->toBytes()->toString()); // Line Feed + $this->assertEquals("\x0D", Terminator::CR->toBytes()->toString()); // Carriage Return + $this->assertEquals("\x20", Terminator::SP->toBytes()->toString()); // Space + $this->assertEquals("\x1C", Terminator::FS->toBytes()->toString()); // File Separator + $this->assertEquals("\x1E", Terminator::RS->toBytes()->toString()); // Record Separator + $this->assertEquals("\x1F", Terminator::US->toBytes()->toString()); // Unit Separator + } + + public function testAllASCIIControlCharacters(): void + { + // Test that all ASCII control characters 0x00-0x20 are covered + $testCases = [ + [Terminator::NUL, "\x00"], + [Terminator::SOH, "\x01"], + [Terminator::STX, "\x02"], + [Terminator::ETX, "\x03"], + [Terminator::EOT, "\x04"], + [Terminator::ENQ, "\x05"], + [Terminator::ACK, "\x06"], + [Terminator::BEL, "\x07"], + [Terminator::BS, "\x08"], + [Terminator::HT, "\x09"], + [Terminator::LF, "\x0A"], + [Terminator::VT, "\x0B"], + [Terminator::FF, "\x0C"], + [Terminator::CR, "\x0D"], + [Terminator::SO, "\x0E"], + [Terminator::SI, "\x0F"], + [Terminator::DLE, "\x10"], + [Terminator::DC1, "\x11"], + [Terminator::DC2, "\x12"], + [Terminator::DC3, "\x13"], + [Terminator::DC4, "\x14"], + [Terminator::NAK, "\x15"], + [Terminator::SYN, "\x16"], + [Terminator::ETB, "\x17"], + [Terminator::CAN, "\x18"], + [Terminator::EM, "\x19"], + [Terminator::SUB, "\x1A"], + [Terminator::ESC, "\x1B"], + [Terminator::FS, "\x1C"], + [Terminator::GS, "\x1D"], + [Terminator::RS, "\x1E"], + [Terminator::US, "\x1F"], + [Terminator::SP, "\x20"], + [Terminator::CRLF, "\x0D\x0A"], + ]; + + foreach ($testCases as [$terminator, $expectedBytes]) { + $actualBytes = $terminator->toBytes()->toString(); + $this->assertEquals( + $expectedBytes, + $actualBytes, + "Terminator {$terminator->name} should produce expected bytes" + ); + } + } + + public function testProtocolSpecificTerminators(): void + { + // Test terminators commonly used in protocols + + // XON/XOFF flow control + $this->assertEquals("\x11", Terminator::DC1->toBytes()->toString()); // XON + $this->assertEquals("\x13", Terminator::DC3->toBytes()->toString()); // XOFF + + // Text boundaries + $this->assertEquals("\x02", Terminator::STX->toBytes()->toString()); // Start of Text + $this->assertEquals("\x03", Terminator::ETX->toBytes()->toString()); // End of Text + + // Data structure separators + $this->assertEquals("\x1C", Terminator::FS->toBytes()->toString()); // File Separator + $this->assertEquals("\x1D", Terminator::GS->toBytes()->toString()); // Group Separator + $this->assertEquals("\x1E", Terminator::RS->toBytes()->toString()); // Record Separator + $this->assertEquals("\x1F", Terminator::US->toBytes()->toString()); // Unit Separator + + // Communication control + $this->assertEquals("\x04", Terminator::EOT->toBytes()->toString()); // End of Transmission + $this->assertEquals("\x06", Terminator::ACK->toBytes()->toString()); // Acknowledge + $this->assertEquals("\x15", Terminator::NAK->toBytes()->toString()); // Negative Acknowledge + } +} From cec87089133024ed80d8b94b278f1905a64725fb Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 18:59:38 +0200 Subject: [PATCH 08/15] Fix empty terminator --- src/BinaryReader.php | 6 +----- tests/BinaryReaderTest.php | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/BinaryReader.php b/src/BinaryReader.php index 5271a7e..3a3d9b6 100644 --- a/src/BinaryReader.php +++ b/src/BinaryReader.php @@ -244,11 +244,7 @@ private function _readWithTerminator(Terminator|BinaryString $terminator, bool $ $terminatorSize = $terminatorBytes->size(); if ($terminatorSize === 0) { - if ($terminatorIsOptional) { - return BinaryString::fromString(''); - } - - throw new \InvalidArgumentException('Terminator cannot be empty when required'); + throw new \InvalidArgumentException('Terminator cannot be empty'); } $remainingData = substr($this->_data, $this->position); diff --git a/tests/BinaryReaderTest.php b/tests/BinaryReaderTest.php index de180c8..295949d 100644 --- a/tests/BinaryReaderTest.php +++ b/tests/BinaryReaderTest.php @@ -588,6 +588,26 @@ public function testReadStringWithMissingRequiredTerminatorThrows(): void $reader->readStringWith(terminator: Terminator::NUL); } + public function testReadBytesWithEmptyOptionalTerminatorThrows(): void + { + $reader = new BinaryReader(BinaryString::fromString("Anything")); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Terminator cannot be empty'); + + $reader->readBytesWith(optional_terminator: BinaryString::fromString('')); + } + + public function testReadBytesWithEmptyRequiredTerminatorThrows(): void + { + $reader = new BinaryReader(BinaryString::fromString("Anything")); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Terminator cannot be empty'); + + $reader->readBytesWith(terminator: BinaryString::fromString('')); + } + public function testReadStringWithTerminatorParameterValidation(): void { $reader = new BinaryReader(BinaryString::fromString("test")); From 91329ecf3cf6674fbe9a481cd5ff99976484ee3d Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 19:02:42 +0200 Subject: [PATCH 09/15] Fix negative lengths --- src/BinaryReader.php | 4 ++++ tests/BinaryReaderTest.php | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/BinaryReader.php b/src/BinaryReader.php index 3a3d9b6..7bf05b7 100644 --- a/src/BinaryReader.php +++ b/src/BinaryReader.php @@ -230,6 +230,10 @@ private function _readWithLength(IntType $length): BinaryString { $dataLength = $this->readInt($length); + if ($dataLength < 0) { + throw new RuntimeException(sprintf('Negative length %d is invalid for %s', $dataLength, $length->name)); + } + try { return $this->readBytes($dataLength); } catch (\RuntimeException $exception) { diff --git a/tests/BinaryReaderTest.php b/tests/BinaryReaderTest.php index 295949d..36ed202 100644 --- a/tests/BinaryReaderTest.php +++ b/tests/BinaryReaderTest.php @@ -578,6 +578,19 @@ public function testReadBytesWithMissingRequiredTerminatorThrows(): void $reader->readBytesWith(terminator: Terminator::NUL); } + public function testReadBytesWithNegativeLengthThrows(): void + { + $writer = new \KDuma\BinaryTools\BinaryWriter(); + $writer->writeInt(IntType::INT8, -1); // length stored as signed byte -1 + + $reader = new BinaryReader($writer->getBuffer()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Negative length -1 is invalid for INT8'); + + $reader->readBytesWith(length: IntType::INT8); + } + public function testReadStringWithMissingRequiredTerminatorThrows(): void { $reader = new BinaryReader(BinaryString::fromString("No terminator")); From 8dd37b923d357393c11556b9922c49decdad1002 Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 19:03:24 +0200 Subject: [PATCH 10/15] Make Invalid seek position exception more verbose --- src/BinaryReader.php | 4 +++- tests/BinaryReaderTest.php | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/BinaryReader.php b/src/BinaryReader.php index 7bf05b7..b60c294 100644 --- a/src/BinaryReader.php +++ b/src/BinaryReader.php @@ -23,7 +23,9 @@ final class BinaryReader } set { if ($value < 0 || $value > $this->length) { - throw new RuntimeException('Invalid seek position: ' . $value); + throw new RuntimeException( + sprintf('Invalid seek position: %d (valid range: 0-%d)', $value, $this->length) + ); } $this->position = $value; diff --git a/tests/BinaryReaderTest.php b/tests/BinaryReaderTest.php index 36ed202..a2de7fd 100644 --- a/tests/BinaryReaderTest.php +++ b/tests/BinaryReaderTest.php @@ -342,7 +342,7 @@ public function testSeek() $this->reader->seek(5); $this->fail("Expected exception not thrown"); } catch (\RuntimeException $exception) { - $this->assertEquals('Invalid seek position: 5', $exception->getMessage()); + $this->assertEquals('Invalid seek position: 5 (valid range: 0-4)', $exception->getMessage()); $this->assertEquals(4, $this->reader->position); } @@ -350,7 +350,7 @@ public function testSeek() $this->reader->seek(-1); $this->fail("Expected exception not thrown"); } catch (\RuntimeException $exception) { - $this->assertEquals('Invalid seek position: -1', $exception->getMessage()); + $this->assertEquals('Invalid seek position: -1 (valid range: 0-4)', $exception->getMessage()); $this->assertEquals(4, $this->reader->position); } } @@ -373,14 +373,14 @@ public function testSetPosition() $this->reader->position = -1; $this->fail("Expected exception not thrown"); } catch (\RuntimeException $exception) { - $this->assertEquals('Invalid seek position: -1', $exception->getMessage()); + $this->assertEquals('Invalid seek position: -1 (valid range: 0-4)', $exception->getMessage()); } try { $this->reader->position = 5; $this->fail("Expected exception not thrown"); } catch (\RuntimeException $exception) { - $this->assertEquals('Invalid seek position: 5', $exception->getMessage()); + $this->assertEquals('Invalid seek position: 5 (valid range: 0-4)', $exception->getMessage()); } } From 80c769b4ef6fd2afd4df8504a5de0ce172b9836f Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 19:58:15 +0200 Subject: [PATCH 11/15] Implement fixed-size field padding --- src/BinaryReader.php | 66 ++++++++++++++-- src/BinaryWriter.php | 98 +++++++++++++++++++++--- tests/BinaryReaderTest.php | 105 +++++++++++++++++++++++++- tests/BinaryWriterTest.php | 149 ++++++++++++++++++++++++++++++++++++- 4 files changed, 396 insertions(+), 22 deletions(-) diff --git a/src/BinaryReader.php b/src/BinaryReader.php index b60c294..3992fec 100644 --- a/src/BinaryReader.php +++ b/src/BinaryReader.php @@ -81,25 +81,35 @@ public function readBytesWith( ?IntType $length = null, Terminator|BinaryString|null $terminator = null, Terminator|BinaryString|null $optional_terminator = null, + Terminator|BinaryString|null $padding = null, + ?int $padding_size = null, ): BinaryString { + if ($padding === null && $padding_size !== null) { + $padding = Terminator::NUL; + } + $modes = array_filter([ 'length' => $length, 'terminator' => $terminator, 'optional_terminator' => $optional_terminator, + 'padding' => $padding, ], static fn ($value) => $value !== null); if (count($modes) !== 1) { - throw new \InvalidArgumentException('Exactly one of length terminator or optional_terminator must be provided'); + throw new \InvalidArgumentException('Exactly one of length, terminator, optional_terminator, or padding must be provided'); } $selectedMode = array_key_first($modes); - if ($selectedMode === 'length') { return $this->_readWithLength($length); - } elseif ($selectedMode === 'terminator' || $selectedMode === 'optional_terminator') { - return $this->_readWithTerminator($modes[$selectedMode], $selectedMode === 'optional_terminator'); } + + if ($selectedMode === 'padding') { + return $this->_readWithPadding($padding, $padding_size); + } + + return $this->_readWithTerminator($modes[$selectedMode], $selectedMode === 'optional_terminator'); } public function readInt(IntType $type): int @@ -162,9 +172,11 @@ public function readStringWith( ?IntType $length = null, Terminator|BinaryString|null $terminator = null, Terminator|BinaryString|null $optional_terminator = null, + Terminator|BinaryString|null $padding = null, + ?int $padding_size = null, ): BinaryString { $startPosition = $this->position; - $string = $this->readBytesWith($length, $terminator, $optional_terminator); + $string = $this->readBytesWith($length, $terminator, $optional_terminator, $padding, $padding_size); if (!mb_check_encoding($string->value, 'UTF-8')) { $this->position = $startPosition; @@ -217,13 +229,13 @@ public function readUint16BE(): int #[Deprecated('Use readBytesWith(length: IntType::UINT8) or readBytesWith(length: IntType::UINT16) instead')] public function readBytesWithLength(bool $use16BitLength = false): BinaryString { - return $this->readBytesWith($use16BitLength ? IntType::UINT16 : IntType::UINT8, null, null); + return $this->readBytesWith($use16BitLength ? IntType::UINT16 : IntType::UINT8, null, null, null, null); } #[Deprecated('Use readStringWith(length: IntType::UINT8) or readStringWith(length: IntType::UINT16) instead')] public function readStringWithLength(bool $use16BitLength = false): BinaryString { - return $this->readStringWith($use16BitLength ? IntType::UINT16 : IntType::UINT8, null, null); + return $this->readStringWith($use16BitLength ? IntType::UINT16 : IntType::UINT8, null, null, null, null); } // Private methods @@ -270,4 +282,44 @@ private function _readWithTerminator(Terminator|BinaryString $terminator, bool $ return BinaryString::fromString($result); } + + private function _readWithPadding(BinaryString|Terminator $padding, ?int $paddingSize): BinaryString + { + if ($paddingSize === null) { + throw new \InvalidArgumentException('Padding size must be provided when padding is used'); + } + + $padding = $padding instanceof Terminator ? $padding->toBytes() : $padding; + + if ($paddingSize < 0) { + throw new RuntimeException('Padding size cannot be negative'); + } + + if ($padding->size() !== 1) { + throw new \InvalidArgumentException('Padding must be exactly one byte'); + } + + if ($paddingSize === 0) { + return BinaryString::fromString(''); + } + + $raw = $this->readBytes($paddingSize); + $dataString = $raw->value; + $padByte = $padding->value; + $padPosition = strpos($dataString, $padByte); + + if ($padPosition === false) { + return $raw; + } + + for ($i = $padPosition; $i < $paddingSize; $i++) { + if ($dataString[$i] !== $padByte) { + throw new RuntimeException('Invalid padding sequence encountered'); + } + } + + $data = substr($dataString, 0, $padPosition); + + return BinaryString::fromString($data); + } } diff --git a/src/BinaryWriter.php b/src/BinaryWriter.php index 8c883e8..ae9746d 100644 --- a/src/BinaryWriter.php +++ b/src/BinaryWriter.php @@ -40,17 +40,48 @@ public function writeBytes(BinaryString $bytes): self return $this; } - public function writeBytesWith(BinaryString $bytes, ?IntType $length = null, Terminator|BinaryString|null $terminator = null): self + public function writeBytesWith( + BinaryString $bytes, + ?IntType $length = null, + Terminator|BinaryString|null $terminator = null, + Terminator|BinaryString|null $optional_terminator = null, + Terminator|BinaryString|null $padding = null, + ?int $padding_size = null, + ): self { - if (($length === null && $terminator === null) || ($length !== null && $terminator !== null)) { - throw new \InvalidArgumentException('Exactly one of length or terminator must be provided'); + if ($padding === null && $padding_size !== null) { + $padding = Terminator::NUL; } - if ($length !== null) { + $modes = array_filter([ + 'length' => $length, + 'terminator' => $terminator, + 'optional_terminator' => $optional_terminator, + 'padding' => $padding, + ], static fn ($value) => $value !== null); + + if (count($modes) !== 1) { + throw new \InvalidArgumentException('Exactly one of length, terminator, optional_terminator, or padding must be provided'); + } + + $modeKey = array_key_first($modes); + + if ($modeKey === 'length') { return $this->_writeWithLength($bytes, $length); - } else { - return $this->_writeWithTerminator($bytes, $terminator); } + + if ($modeKey === 'padding') { + return $this->_writeWithPadding($bytes, $padding, $padding_size); + } + + if ($modeKey === 'optional_terminator') { + trigger_error( + 'UNSTABLE API: optional_terminator has no effect when writing IN THIS VERSION; data will always be terminated IN THIS VERSION; it will probably change in future', + E_USER_NOTICE + ); + } + + return $this->_writeWithTerminator($bytes, $modes[$modeKey]); } public function writeInt(IntType $type, int $value): self @@ -101,13 +132,20 @@ public function writeString(BinaryString $string): self return $this; } - public function writeStringWith(BinaryString $string, ?IntType $length = null, Terminator|BinaryString|null $terminator = null): self + public function writeStringWith( + BinaryString $string, + ?IntType $length = null, + Terminator|BinaryString|null $terminator = null, + Terminator|BinaryString|null $optional_terminator = null, + Terminator|BinaryString|null $padding = null, + ?int $padding_size = null, + ): self { if (!mb_check_encoding($string->value, 'UTF-8')) { throw new \InvalidArgumentException('String must be valid UTF-8'); } - return $this->writeBytesWith($string, $length, $terminator); + return $this->writeBytesWith($string, $length, $terminator, $optional_terminator, $padding, $padding_size); } // Deprecated methods @@ -121,13 +159,13 @@ public function writeUint16BE(int $value): self #[Deprecated('Use writeBytesWith($bytes, length: IntType::UINT8) or writeBytesWith($bytes, length: IntType::UINT16) instead')] public function writeBytesWithLength(BinaryString $bytes, bool $use16BitLength = false): self { - return $this->writeBytesWith($bytes, $use16BitLength ? IntType::UINT16 : IntType::UINT8, null); + return $this->writeBytesWith($bytes, $use16BitLength ? IntType::UINT16 : IntType::UINT8, null, null, null, null); } #[Deprecated('Use writeStringWith($string, length: IntType::UINT8) or writeStringWith($string, length: IntType::UINT16) instead')] public function writeStringWithLength(BinaryString $string, bool $use16BitLength = false): self { - return $this->writeStringWith($string, $use16BitLength ? IntType::UINT16 : IntType::UINT8, null); + return $this->writeStringWith($string, $use16BitLength ? IntType::UINT16 : IntType::UINT8, null, null, null, null); } // Private methods @@ -151,6 +189,10 @@ private function _writeWithTerminator(BinaryString $data, Terminator|BinaryStrin { $terminatorBytes = $terminator instanceof Terminator ? $terminator->toBytes() : $terminator; + if ($terminatorBytes->size() === 0) { + throw new \InvalidArgumentException('Terminator cannot be empty'); + } + if ($data->contains($terminatorBytes)) { throw new \InvalidArgumentException('Data contains terminator sequence'); } @@ -160,4 +202,40 @@ private function _writeWithTerminator(BinaryString $data, Terminator|BinaryStrin return $this; } + + private function _writeWithPadding(BinaryString $data, BinaryString|Terminator $padding, ?int $paddingSize): self + { + $padding = $padding instanceof Terminator ? $padding->toBytes() : $padding; + + if ($paddingSize === null) { + throw new \InvalidArgumentException('Padding size must be provided when padding is used'); + } + + if ($paddingSize < 0) { + throw new \InvalidArgumentException('Padding size cannot be negative'); + } + + if ($padding->size() !== 1) { + throw new \InvalidArgumentException('Padding must be exactly one byte'); + } + + if ($data->contains($padding)) { + throw new \InvalidArgumentException('Data contains padding byte'); + } + + $dataLength = $data->size(); + + if ($dataLength > $paddingSize) { + throw new \InvalidArgumentException('Data too long for padding size'); + } + + $this->writeBytes($data); + + if ($dataLength < $paddingSize) { + $padByte = $padding->value; + $this->buffer .= str_repeat($padByte, $paddingSize - $dataLength); + } + + return $this; + } } diff --git a/tests/BinaryReaderTest.php b/tests/BinaryReaderTest.php index a2de7fd..3d66615 100644 --- a/tests/BinaryReaderTest.php +++ b/tests/BinaryReaderTest.php @@ -591,6 +591,105 @@ public function testReadBytesWithNegativeLengthThrows(): void $reader->readBytesWith(length: IntType::INT8); } + public function testReadBytesWithPaddingReturnsDataUntilPad(): void + { + $data = BinaryString::fromString("Hi\x00\x00\x00"); + $reader = new BinaryReader($data); + + $result = $reader->readBytesWith(padding_size: 5, padding: BinaryString::fromString("\x00")); + + $this->assertEquals('Hi', $result->toString()); + $this->assertEquals(5, $reader->position); + } + + public function testReadBytesWithPaddingWithoutPadByte(): void + { + $data = BinaryString::fromString('Hello'); + $reader = new BinaryReader($data); + + $result = $reader->readBytesWith(padding_size: 5, padding: BinaryString::fromString("\x00")); + + $this->assertEquals('Hello', $result->toString()); + $this->assertEquals(5, $reader->position); + } + + public function testReadBytesWithPaddingInvalidSequenceThrows(): void + { + $data = BinaryString::fromString("Hi\x00A\x00"); + $reader = new BinaryReader($data); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid padding sequence encountered'); + + $reader->readBytesWith(padding_size: 5, padding: BinaryString::fromString("\x00")); + } + + public function testReadBytesWithPaddingZeroSizeReturnsEmpty(): void + { + $data = BinaryString::fromString('ABC'); + $reader = new BinaryReader($data); + + $result = $reader->readBytesWith(padding: BinaryString::fromString("\x00"), padding_size: 0); + + $this->assertEquals('', $result->toString()); + $this->assertEquals(0, $reader->position); + } + + public function testReadBytesWithPaddingCannotCombineWithLength(): void + { + $data = BinaryString::fromString('Data'); + $reader = new BinaryReader($data); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Exactly one of length, terminator, optional_terminator, or padding must be provided'); + + $reader->readBytesWith(length: IntType::UINT8, padding_size: 4); + } + + public function testReadBytesWithPaddingNegativeSizeThrows(): void + { + $data = BinaryString::fromString('Data'); + $reader = new BinaryReader($data); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Padding size cannot be negative'); + + $reader->readBytesWith(padding_size: -1, padding: BinaryString::fromString("\x00")); + } + + public function testReadBytesWithPaddingRequiresSize(): void + { + $data = BinaryString::fromString('Hello'); + $reader = new BinaryReader($data); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Padding size must be provided when padding is used'); + + $reader->readBytesWith(padding: BinaryString::fromString("\x00")); + } + + public function testReadBytesWithPaddingDefaultPadByte(): void + { + $data = BinaryString::fromString("Data\x00\x00"); + $reader = new BinaryReader($data); + + $result = $reader->readBytesWith(padding_size: 6); + + $this->assertEquals('Data', $result->toString()); + $this->assertEquals(6, $reader->position); + } + + public function testReadBytesWithPaddingRejectsMultiBytePad(): void + { + $data = BinaryString::fromString("Hi\r\n\r\n"); + $reader = new BinaryReader($data); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Padding must be exactly one byte'); + + $reader->readBytesWith(padding_size: 6, padding: Terminator::CRLF); + } + public function testReadStringWithMissingRequiredTerminatorThrows(): void { $reader = new BinaryReader(BinaryString::fromString("No terminator")); @@ -627,7 +726,7 @@ public function testReadStringWithTerminatorParameterValidation(): void // Test both parameters provided $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Exactly one of length terminator or optional_terminator must be provided'); + $this->expectExceptionMessage('Exactly one of length, terminator, optional_terminator, or padding must be provided'); $reader->readStringWith(IntType::UINT8, Terminator::NUL); } @@ -638,7 +737,7 @@ public function testReadBytesWithNoParameters(): void // Test no parameters provided $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Exactly one of length terminator or optional_terminator must be provided'); + $this->expectExceptionMessage('Exactly one of length, terminator, optional_terminator, or padding must be provided'); $reader->readBytesWith(); } @@ -648,7 +747,7 @@ public function testReadBytesWithBothTerminatorsProvided(): void $reader = new BinaryReader(BinaryString::fromString("data")); $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Exactly one of length terminator or optional_terminator must be provided'); + $this->expectExceptionMessage('Exactly one of length, terminator, optional_terminator, or padding must be provided'); $reader->readBytesWith(terminator: Terminator::NUL, optional_terminator: Terminator::NUL); } diff --git a/tests/BinaryWriterTest.php b/tests/BinaryWriterTest.php index 28c04a0..a42b959 100644 --- a/tests/BinaryWriterTest.php +++ b/tests/BinaryWriterTest.php @@ -400,6 +400,34 @@ public function testWriteBytesWithTerminatorRejectsEmbeddedTerminator(): void $this->writer->writeBytesWith($data, terminator: Terminator::NUL); } + public function testWriteBytesWithOptionalTerminatorTriggersNotice(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('Hello'); + + $notice = null; + set_error_handler(function (int $errno, string $errstr) use (&$notice) { + $notice = ['errno' => $errno, 'message' => $errstr]; + + return true; + }, E_USER_NOTICE); + + try { + $this->writer->writeBytesWith($data, optional_terminator: Terminator::NUL); + } finally { + restore_error_handler(); + } + + $this->assertNotNull($notice, 'Expected notice not triggered'); + $this->assertSame(E_USER_NOTICE, $notice['errno']); + $this->assertSame( + 'UNSTABLE API: optional_terminator has no effect when writing IN THIS VERSION; data will always be terminated IN THIS VERSION; it will probably change in future', + $notice['message'] + ); + + $this->assertEquals("Hello\x00", $this->writer->getBuffer()->toString()); + } + public function testWriteStringWithTerminatorNUL(): void { $this->writer->reset(); @@ -424,7 +452,7 @@ public function testWriteBytesWithParameterValidation(): void // Test both parameters provided $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Exactly one of length or terminator must be provided'); + $this->expectExceptionMessage('Exactly one of length, terminator, optional_terminator, or padding must be provided'); $this->writer->writeBytesWith($data, IntType::UINT8, Terminator::NUL); } @@ -436,7 +464,7 @@ public function testWriteBytesWithNoParameters(): void // Test no parameters provided $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Exactly one of length or terminator must be provided'); + $this->expectExceptionMessage('Exactly one of length, terminator, optional_terminator, or padding must be provided'); $this->writer->writeBytesWith($data); } @@ -460,6 +488,123 @@ public function testWriteBytesWithCRLFTerminator(): void $this->assertEquals("HTTP/1.1 200 OK\x0D\x0A", $this->writer->getBuffer()->toString()); } + public function testWriteBytesWithPaddingExactSize(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('ABCDE'); + + $this->writer->writeBytesWith($data, padding: BinaryString::fromString("\x20"), padding_size: 5); + + $this->assertEquals('ABCDE', $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithPaddingShorterThanSize(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('Hi'); + + $this->writer->writeBytesWith($data, padding: BinaryString::fromString("\x20"), padding_size: 5); + + $this->assertEquals("Hi ", $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithPaddingDefaultPadByte(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('AB'); + + $this->writer->writeBytesWith($data, padding_size: 4); + + $this->assertEquals("AB\x00\x00", $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithPaddingTooLongThrows(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('TooLong'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Data too long for padding size'); + + $this->writer->writeBytesWith($data, padding: BinaryString::fromString("\x20"), padding_size: 5); + } + + public function testWriteBytesWithPaddingRejectsDataContainingPadByte(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('Hello World'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Data contains padding byte'); + + $this->writer->writeBytesWith($data, padding: BinaryString::fromString("\x20"), padding_size: 20); + } + + public function testWriteBytesWithEmptyTerminatorThrows(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('Hello'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Terminator cannot be empty'); + + $this->writer->writeBytesWith($data, terminator: BinaryString::fromString('')); + } + + public function testWriteBytesWithPaddingRequiresSize(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('Data'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Padding size must be provided when padding is used'); + + $this->writer->writeBytesWith($data, padding: BinaryString::fromString("\x20")); + } + + public function testWriteBytesWithPaddingRejectsMultiBytePadding(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('Data'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Padding must be exactly one byte'); + + $this->writer->writeBytesWith($data, padding: Terminator::CRLF, padding_size: 10); + } + + public function testWriteBytesWithPaddingNegativeSizeThrows(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('AB'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Padding size cannot be negative'); + + $this->writer->writeBytesWith($data, padding: BinaryString::fromString("\x20"), padding_size: -1); + } + + public function testWriteBytesWithPaddingCannotCombineWithLength(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('AB'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Exactly one of length, terminator, optional_terminator, or padding must be provided'); + + $this->writer->writeBytesWith($data, length: IntType::UINT8, padding_size: 4); + } + + public function testWriteStringWithPadding(): void + { + $this->writer->reset(); + $string = BinaryString::fromString('Hi'); + + $this->writer->writeStringWith($string, padding: BinaryString::fromString("\x20"), padding_size: 4); + + $this->assertEquals("Hi ", $this->writer->getBuffer()->toString()); + } + public function testWriteStringWithCommonControlCharacters(): void { $this->writer->reset(); From 91558fb5f1a425d639f6636873201d1eff92db16 Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 20:27:37 +0200 Subject: [PATCH 12/15] Update documentation --- src/BinaryReader.php | 87 ++++++++++++++++++++++++++++++++++++++++++++ src/BinaryString.php | 47 +++++++++++++++++++++--- src/BinaryWriter.php | 75 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 6 deletions(-) diff --git a/src/BinaryReader.php b/src/BinaryReader.php index 3992fec..4b2b316 100644 --- a/src/BinaryReader.php +++ b/src/BinaryReader.php @@ -50,12 +50,22 @@ final class BinaryReader } } + /** + * Creates a new reader positioned at the start of the supplied buffer. + * + * @param BinaryString $data Buffer to read from. + */ public function __construct(BinaryString $data) { $this->_data = $data->toString(); $this->length = $data->size(); } + /** + * Reads the next byte from the stream. + * + * @throws RuntimeException When no more data is available. + */ public function readByte(): int { if ($this->position >= $this->length) { @@ -65,6 +75,12 @@ public function readByte(): int return ord($this->_data[$this->position++]); } + /** + * Reads exactly $count bytes from the current position. + * + * @param int $count Number of bytes to read. + * @throws RuntimeException When fewer than $count bytes remain. + */ public function readBytes(int $count): BinaryString { if ($this->position + $count > $this->length) { @@ -77,6 +93,17 @@ public function readBytes(int $count): BinaryString return BinaryString::fromString($result); } + /** + * Reads variable-length data using exactly one of the supplied strategies (length, terminator, optional terminator, or padding). + * + * @param IntType|null $length Integer type that stores the byte length when using length mode. + * @param Terminator|BinaryString|null $terminator Required terminator sequence when using terminator mode. + * @param Terminator|BinaryString|null $optional_terminator Terminator sequence that may be absent (fully consumes buffer when missing). + * @param Terminator|BinaryString|null $padding Single-byte padding value used for fixed-width fields. + * @param int|null $padding_size Total field width in bytes when padding is enabled. + * @throws \InvalidArgumentException When mutually exclusive modes are combined or configuration is invalid. + * @throws RuntimeException When the data violates the expectations of the chosen mode. + */ public function readBytesWith( ?IntType $length = null, Terminator|BinaryString|null $terminator = null, @@ -112,6 +139,12 @@ public function readBytesWith( return $this->_readWithTerminator($modes[$selectedMode], $selectedMode === 'optional_terminator'); } + /** + * Reads an integer using the provided {@see IntType} definition. + * + * @param IntType $type Integer description covering width, signedness, and byte order. + * @throws RuntimeException When the type is unsupported or the value cannot be represented. + */ public function readInt(IntType $type): int { if (!$type->isSupported()) { @@ -155,6 +188,12 @@ public function readInt(IntType $type): int } + /** + * Reads a fixed-length UTF-8 string. + * + * @param int $length Number of bytes to consume. + * @throws RuntimeException When insufficient data remains or decoding fails. + */ public function readString(int $length): BinaryString { $bytes = $this->readBytes($length); @@ -168,6 +207,17 @@ public function readString(int $length): BinaryString return $bytes; } + /** + * Reads a UTF-8 string using one of the variable-length strategies (length, terminator, optional terminator, or padding). + * + * @param IntType|null $length Integer type specifying the length field when using length mode. + * @param Terminator|BinaryString|null $terminator Required terminator. + * @param Terminator|BinaryString|null $optional_terminator Optional terminator. + * @param Terminator|BinaryString|null $padding Single-byte padding value for fixed-width fields. + * @param int|null $padding_size Total field width when padding is enabled. + * @throws \InvalidArgumentException When configuration is invalid. + * @throws RuntimeException When decoding fails or the data violates mode rules. + */ public function readStringWith( ?IntType $length = null, Terminator|BinaryString|null $terminator = null, @@ -186,6 +236,12 @@ public function readStringWith( return $string; } + /** + * Returns the next byte without advancing the read pointer. + * + * @return int Unsigned byte value. + * @throws RuntimeException When no more data remains. + */ public function peekByte(): int { if ($this->position >= $this->length) { @@ -195,6 +251,12 @@ public function peekByte(): int return ord($this->_data[$this->position]); } + /** + * Returns the next $count bytes without advancing the read pointer. + * + * @param int $count Number of bytes to inspect. + * @throws RuntimeException When fewer than $count bytes remain. + */ public function peekBytes(int $count): BinaryString { if ($this->position + $count > $this->length) { @@ -204,6 +266,12 @@ public function peekBytes(int $count): BinaryString return BinaryString::fromString(substr($this->_data, $this->position, $count)); } + /** + * Advances the read pointer by $count bytes. + * + * @param int $count Number of bytes to skip. + * @throws RuntimeException When insufficient data remains. + */ public function skip(int $count): void { if ($this->position + $count > $this->length) { @@ -213,6 +281,12 @@ public function skip(int $count): void $this->position += $count; } + /** + * Moves the read pointer to an absolute offset inside the buffer. + * + * @param int $position Zero-based offset to seek to. + * @throws RuntimeException When the target lies outside the buffer. + */ public function seek(int $position): void { $this->position = $position; @@ -221,18 +295,31 @@ public function seek(int $position): void // Deprecated methods #[Deprecated('Use readInt(IntType::UINT16) instead')] + /** + * @deprecated Use {@see readInt()} with {@see IntType::UINT16} instead. + */ public function readUint16BE(): int { return $this->readInt(IntType::UINT16); } #[Deprecated('Use readBytesWith(length: IntType::UINT8) or readBytesWith(length: IntType::UINT16) instead')] + /** + * @deprecated Use {@see readBytesWith()} with an explicit length type instead. + * + * @param bool $use16BitLength When true, reads a 16-bit length; otherwise an 8-bit length. + */ public function readBytesWithLength(bool $use16BitLength = false): BinaryString { return $this->readBytesWith($use16BitLength ? IntType::UINT16 : IntType::UINT8, null, null, null, null); } #[Deprecated('Use readStringWith(length: IntType::UINT8) or readStringWith(length: IntType::UINT16) instead')] + /** + * @deprecated Use {@see readStringWith()} with an explicit length type instead. + * + * @param bool $use16BitLength When true, reads a 16-bit length; otherwise an 8-bit length. + */ public function readStringWithLength(bool $use16BitLength = false): BinaryString { return $this->readStringWith($use16BitLength ? IntType::UINT16 : IntType::UINT8, null, null, null, null); diff --git a/src/BinaryString.php b/src/BinaryString.php index 6455bb6..ec872ee 100644 --- a/src/BinaryString.php +++ b/src/BinaryString.php @@ -8,16 +8,25 @@ protected function __construct(public string $value) { } + /** + * Returns the raw binary value as a PHP string. + */ public function toString(): string { return $this->value; } + /** + * Serialises the binary value into an ASCII hexadecimal string. + */ public function toHex(): string { return bin2hex($this->value); } + /** + * Serialises the binary value using Base64 encoding. + */ public function toBase64(): string { return base64_encode($this->value); @@ -26,51 +35,77 @@ public function toBase64(): string /** * Returns a Base32-encoded string representation of the binary value. * - * @param string $alphabet The alphabet to use for Base32 encoding. - * @return string The Base32-encoded string. + * @param string $alphabet Alphabet to use when encoding. */ public function toBase32(string $alphabet = Base32::DEFAULT_ALPHABET): string { return Base32::toBase32($this->value, $alphabet); } + /** + * Returns the number of bytes contained in the value. + */ public function size(): int { return strlen($this->value); } + /** + * Creates a BinaryString from an existing PHP string without validation. + * + * @param string $value Raw binary data. + */ public static function fromString(string $value): static { return new static($value); } + /** + * Creates a BinaryString from a hexadecimal dump. + * + * @param string $hex Hexadecimal representation of the data. + */ public static function fromHex(string $hex): static { return new static(hex2bin($hex)); } + /** + * Creates a BinaryString from a Base64-encoded payload. + * + * @param string $base64 Base64 representation of the data. + */ public static function fromBase64(string $base64): static { return new static(base64_decode($base64, true)); } /** - * Decodes a Base32-encoded string to a BinaryString instance. + * Decodes a Base32-encoded string to a BinaryString instance using the specified alphabet. * - * @param string $base32 The Base32-encoded string to decode. - * @param string $alphabet The alphabet used for Base32 encoding. Defaults to Base32::DEFAULT_ALPHABET. - * @return static A new BinaryString instance containing the decoded binary data. + * @param string $base32 Base32 payload to decode. + * @param string $alphabet Alphabet that was used during encoding. */ public static function fromBase32(string $base32, string $alphabet = Base32::DEFAULT_ALPHABET): static { return new static(Base32::fromBase32($base32, $alphabet)); } + /** + * Performs a timing-safe comparison with another BinaryString. + * + * @param BinaryString $other Value to compare against. + */ public function equals(BinaryString $other): bool { return hash_equals($this->value, $other->value); } + /** + * Determines whether the provided binary fragment appears in the value. + * + * @param BinaryString $needle Fragment to look for. + */ public function contains(BinaryString $needle): bool { if ($needle->size() === 0) { diff --git a/src/BinaryWriter.php b/src/BinaryWriter.php index ae9746d..3409197 100644 --- a/src/BinaryWriter.php +++ b/src/BinaryWriter.php @@ -8,21 +8,36 @@ final class BinaryWriter { private string $buffer = ''; + /** + * Returns the buffered bytes as a BinaryString without resetting the writer. + */ public function getBuffer(): BinaryString { return BinaryString::fromString($this->buffer); } + /** + * Returns the number of bytes written so far. + */ public function getLength(): int { return strlen($this->buffer); } + /** + * Clears the buffer so subsequent writes start from an empty state. + */ public function reset(): void { $this->buffer = ''; } + /** + * Appends a single byte value (0-255) to the buffer. + * + * @param int $byte Byte value to write. + * @throws \InvalidArgumentException When the value is outside the valid byte range. + */ public function writeByte(int $byte): self { if ($byte < 0 || $byte > 255) { @@ -33,6 +48,11 @@ public function writeByte(int $byte): self return $this; } + /** + * Appends raw bytes to the buffer. + * + * @param BinaryString $bytes Data to append. + */ public function writeBytes(BinaryString $bytes): self { $this->buffer .= $bytes->value; @@ -40,6 +60,19 @@ public function writeBytes(BinaryString $bytes): self return $this; } + /** + * Writes variable-length data using one of the available strategies: typed length, terminator or fixed padding. + * + * @note When {@code optional_terminator} is supplied it currently behaves the same as {@code terminator} but emits a notice. + * + * @param BinaryString $bytes Data to write. + * @param IntType|null $length Integer type describing the length field when using length mode. + * @param Terminator|BinaryString|null $terminator Mandatory terminator sequence. + * @param Terminator|BinaryString|null $optional_terminator Optional terminator sequence (currently emits a notice and behaves like $terminator). + * @param Terminator|BinaryString|null $padding Single-byte padding value for fixed-width fields. + * @param int|null $padding_size Total field width when padding is enabled. + * @throws \InvalidArgumentException When configuration is invalid or the data violates the chosen mode. + */ public function writeBytesWith( BinaryString $bytes, ?IntType $length = null, @@ -84,6 +117,14 @@ public function writeBytesWith( return $this->_writeWithTerminator($bytes, $modes[$modeKey]); } + /** + * Serialises an integer according to the provided {@see IntType} definition. + * + * @param IntType $type Integer description covering width, signedness, and byte order. + * @param int $value Value to serialise. + * @throws \RuntimeException When the type is unsupported on this platform. + * @throws \InvalidArgumentException When the value lies outside the type's range. + */ public function writeInt(IntType $type, int $value): self { if (!$type->isSupported()) { @@ -121,6 +162,12 @@ public function writeInt(IntType $type, int $value): self } + /** + * Writes a UTF-8 validated string without terminator or padding. + * + * @param BinaryString $string UTF-8 string data to emit. + * @throws \InvalidArgumentException When the data is not valid UTF-8. + */ public function writeString(BinaryString $string): self { if (!mb_check_encoding($string->value, 'UTF-8')) { @@ -132,6 +179,17 @@ public function writeString(BinaryString $string): self return $this; } + /** + * Writes a UTF-8 string using one of the variable-length strategies. + * + * @param BinaryString $string UTF-8 string data to emit. + * @param IntType|null $length Integer type describing the length field when using length mode. + * @param Terminator|BinaryString|null $terminator Mandatory terminator sequence. + * @param Terminator|BinaryString|null $optional_terminator Optional terminator sequence (currently emits a notice and behaves like $terminator). + * @param Terminator|BinaryString|null $padding Single-byte padding value for fixed-width fields. + * @param int|null $padding_size Total field width when padding is enabled. + * @throws \InvalidArgumentException When configuration is invalid or the string is not UTF-8. + */ public function writeStringWith( BinaryString $string, ?IntType $length = null, @@ -151,18 +209,35 @@ public function writeStringWith( // Deprecated methods #[Deprecated('Use writeInt(IntType::UINT16, $value) instead')] + /** + * @deprecated Use {@see writeInt()} with {@see IntType::UINT16} instead. + * + * @param int $value Unsigned 16-bit value. + */ public function writeUint16BE(int $value): self { return $this->writeInt(IntType::UINT16, $value); } #[Deprecated('Use writeBytesWith($bytes, length: IntType::UINT8) or writeBytesWith($bytes, length: IntType::UINT16) instead')] + /** + * @deprecated Use {@see writeBytesWith()} with an explicit length type instead. + * + * @param BinaryString $bytes Payload to write. + * @param bool $use16BitLength When true, emits a 16-bit length; otherwise an 8-bit length. + */ public function writeBytesWithLength(BinaryString $bytes, bool $use16BitLength = false): self { return $this->writeBytesWith($bytes, $use16BitLength ? IntType::UINT16 : IntType::UINT8, null, null, null, null); } #[Deprecated('Use writeStringWith($string, length: IntType::UINT8) or writeStringWith($string, length: IntType::UINT16) instead')] + /** + * @deprecated Use {@see writeStringWith()} with an explicit length type instead. + * + * @param BinaryString $string UTF-8 string to write. + * @param bool $use16BitLength When true, emits a 16-bit length; otherwise an 8-bit length. + */ public function writeStringWithLength(BinaryString $string, bool $use16BitLength = false): self { return $this->writeStringWith($string, $use16BitLength ? IntType::UINT16 : IntType::UINT8, null, null, null, null); From e48d7f40b098adf3e828175f1d7cfd139e506993 Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 21:09:40 +0200 Subject: [PATCH 13/15] Update docs --- src/IntType.php | 27 +++++++++ src/Terminator.php | 136 +++++++++++++++++++++++++++++++++------------ 2 files changed, 129 insertions(+), 34 deletions(-) diff --git a/src/IntType.php b/src/IntType.php index e917882..4746dd8 100644 --- a/src/IntType.php +++ b/src/IntType.php @@ -4,19 +4,46 @@ enum IntType { + /** Unsigned 8-bit integer (0-255) - Single byte */ case UINT8; + + /** Signed 8-bit integer (-128 to 127) - Single byte */ case INT8; + + /** Unsigned 16-bit integer (0-65535) - Big-endian byte order */ case UINT16; + + /** Signed 16-bit integer (-32768 to 32767) - Big-endian byte order */ case INT16; + + /** Unsigned 32-bit integer (0-4294967295) - Big-endian byte order */ case UINT32; + + /** Signed 32-bit integer (-2147483648 to 2147483647) - Big-endian byte order */ case INT32; + + /** Unsigned 16-bit integer (0-65535) - Little-endian byte order */ case UINT16_LE; + + /** Signed 16-bit integer (-32768 to 32767) - Little-endian byte order */ case INT16_LE; + + /** Unsigned 32-bit integer (0-4294967295) - Little-endian byte order */ case UINT32_LE; + + /** Signed 32-bit integer (-2147483648 to 2147483647) - Little-endian byte order */ case INT32_LE; + + /** Unsigned 64-bit integer - Big-endian byte order (platform dependent range) */ case UINT64; + + /** Signed 64-bit integer - Big-endian byte order (platform dependent range) */ case INT64; + + /** Unsigned 64-bit integer - Little-endian byte order (platform dependent range) */ case UINT64_LE; + + /** Signed 64-bit integer - Little-endian byte order (platform dependent range) */ case INT64_LE; public function bytes(): int diff --git a/src/Terminator.php b/src/Terminator.php index fb02a5c..bfb7965 100644 --- a/src/Terminator.php +++ b/src/Terminator.php @@ -5,42 +5,110 @@ enum Terminator { // ASCII control characters 0x00-0x1F - case NUL; // 0x00 - Null character - case SOH; // 0x01 - Start of Heading - case STX; // 0x02 - Start of Text - case ETX; // 0x03 - End of Text - case EOT; // 0x04 - End of Transmission - case ENQ; // 0x05 - Enquiry - case ACK; // 0x06 - Acknowledge - case BEL; // 0x07 - Bell - case BS; // 0x08 - Backspace - case HT; // 0x09 - Horizontal Tab - case LF; // 0x0A - Line Feed - case VT; // 0x0B - Vertical Tab - case FF; // 0x0C - Form Feed - case CR; // 0x0D - Carriage Return - case SO; // 0x0E - Shift Out - case SI; // 0x0F - Shift In - case DLE; // 0x10 - Data Link Escape - case DC1; // 0x11 - Device Control 1 (XON) - case DC2; // 0x12 - Device Control 2 - case DC3; // 0x13 - Device Control 3 (XOFF) - case DC4; // 0x14 - Device Control 4 - case NAK; // 0x15 - Negative Acknowledge - case SYN; // 0x16 - Synchronous Idle - case ETB; // 0x17 - End of Transmission Block - case CAN; // 0x18 - Cancel - case EM; // 0x19 - End of Medium - case SUB; // 0x1A - Substitute - case ESC; // 0x1B - Escape - case FS; // 0x1C - File Separator - case GS; // 0x1D - Group Separator - case RS; // 0x1E - Record Separator - case US; // 0x1F - Unit Separator - case SP; // 0x20 - Space + + /** Null character (0x00) - Commonly used for C-style string termination */ + case NUL; + + /** Start of Heading (0x01) - Indicates the start of a header block */ + case SOH; + + /** Start of Text (0x02) - Marks the beginning of text data */ + case STX; + + /** End of Text (0x03) - Marks the end of text data */ + case ETX; + + /** End of Transmission (0x04) - Indicates end of data transmission */ + case EOT; + + /** Enquiry (0x05) - Request for response or status */ + case ENQ; + + /** Acknowledge (0x06) - Positive acknowledgment signal */ + case ACK; + + /** Bell (0x07) - Audio alert or notification signal */ + case BEL; + + /** Backspace (0x08) - Move cursor back one position */ + case BS; + + /** Horizontal Tab (0x09) - Move to next tab stop */ + case HT; + + /** Line Feed (0x0A) - Move to next line (Unix line ending) */ + case LF; + + /** Vertical Tab (0x0B) - Move to next vertical tab position */ + case VT; + + /** Form Feed (0x0C) - Start new page or clear screen */ + case FF; + + /** Carriage Return (0x0D) - Return to start of line (classic Mac line ending) */ + case CR; + + /** Shift Out (0x0E) - Switch to alternate character set */ + case SO; + + /** Shift In (0x0F) - Switch back to standard character set */ + case SI; + + /** Data Link Escape (0x10) - Escape sequence for data link protocols */ + case DLE; + + /** Device Control 1 (0x11) - Also known as XON for flow control */ + case DC1; + + /** Device Control 2 (0x12) - General device control */ + case DC2; + + /** Device Control 3 (0x13) - Also known as XOFF for flow control */ + case DC3; + + /** Device Control 4 (0x14) - General device control */ + case DC4; + + /** Negative Acknowledge (0x15) - Error or rejection signal */ + case NAK; + + /** Synchronous Idle (0x16) - Synchronization in data streams */ + case SYN; + + /** End of Transmission Block (0x17) - End of data block marker */ + case ETB; + + /** Cancel (0x18) - Cancel current operation */ + case CAN; + + /** End of Medium (0x19) - End of storage medium */ + case EM; + + /** Substitute (0x1A) - Replacement for invalid character */ + case SUB; + + /** Escape (0x1B) - Start of escape sequence */ + case ESC; + + /** File Separator (0x1C) - Delimiter between files */ + case FS; + + /** Group Separator (0x1D) - Delimiter between groups of data */ + case GS; + + /** Record Separator (0x1E) - Delimiter between records */ + case RS; + + /** Unit Separator (0x1F) - Delimiter between units of data */ + case US; + + /** Space (0x20) - Standard whitespace character */ + case SP; // Common multi-character sequences - case CRLF; // 0x0D 0x0A - Carriage Return + Line Feed + + /** Carriage Return + Line Feed (0x0D 0x0A) - Windows line ending */ + case CRLF; public function toBytes(): BinaryString { From 146693b60014232c98b914dccd61c3ca4879148a Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 21:09:59 +0200 Subject: [PATCH 14/15] API docs generator --- composer.json | 3 +- generate-api-docs.php | 418 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 generate-api-docs.php diff --git a/composer.json b/composer.json index b6a0d16..d5411c7 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,8 @@ "test": "phpunit", "test-coverage": "phpunit --coverage-html coverage", "lint": "pint --test", - "fix": "pint" + "fix": "pint", + "docs": "php generate-api-docs.php > API.md" }, "config": { "optimize-autoloader": true, diff --git a/generate-api-docs.php b/generate-api-docs.php new file mode 100644 index 0000000..419a906 --- /dev/null +++ b/generate-api-docs.php @@ -0,0 +1,418 @@ +getShortName(); + + // Skip enums in main TOC - they'll be in the Enums section + if ($reflection->isEnum()) { + continue; + } + + $toc[] = "* [`\\$className`](#$shortName)"; + + // Add properties to TOC + $properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC); + foreach ($properties as $property) { + $propName = $property->getName(); + $toc[] = " * [`$shortName::\$$propName`](#$shortName$propName)"; + } + + // Add methods to TOC + $methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC); + $publicMethods = array_filter($methods, fn($method) => !$method->isConstructor() && !$method->isDestructor()); + foreach ($publicMethods as $method) { + $methodName = $method->getName(); + $paramCount = count($method->getParameters()); + $paramSuffix = $paramCount > 0 ? '(...)' : '()'; + $toc[] = " * [`$shortName::$methodName$paramSuffix`](#$shortName$methodName)"; + } +} + +// Add enum section if any +$hasEnums = false; +foreach ($classes as $className) { + $reflection = new \ReflectionClass($className); + if ($reflection->isEnum()) { + if (!$hasEnums) { + $toc[] = "* [Enums](#enums)"; + $hasEnums = true; + } + $shortName = $reflection->getShortName(); + $toc[] = " * [`\\$className`](#$shortName)"; + } +} + +$markdown .= implode("\n", $toc) . "\n\n"; + +foreach ($classes as $className) { + $reflection = new \ReflectionClass($className); + + // Skip enums in main section - they'll be handled in the Enums section + if ($reflection->isEnum()) { + continue; + } + + $markdown .= "### " . $reflection->getShortName() . "\n\n"; + + // Class description from docblock + $docComment = $reflection->getDocComment(); + if ($docComment) { + $lines = explode("\n", $docComment); + $description = ''; + foreach ($lines as $line) { + $line = trim($line); + if (str_starts_with($line, '/**') || str_starts_with($line, '*/') || str_starts_with($line, '*')) { + $line = ltrim($line, '/*'); + $line = trim($line); + if (!empty($line) && !str_starts_with($line, '@')) { + $description .= $line . " "; + } + } + } + if (!empty($description)) { + $markdown .= trim($description) . "\n\n"; + } + } + + // Namespace + $markdown .= "**Namespace:** `" . $reflection->getNamespaceName() . "`\n\n"; + + // Class type + if ($reflection->isEnum()) { + $markdown .= "**Type:** Enum\n\n"; + } elseif ($reflection->isFinal()) { + $markdown .= "**Type:** Final Class\n\n"; + } else { + $markdown .= "**Type:** Class\n\n"; + } + + // Public constants (for enums) + $constants = $reflection->getConstants(); + if (!empty($constants) && $reflection->isEnum()) { + $markdown .= "### Cases\n\n"; + foreach ($constants as $name => $value) { + $markdown .= "- `$name`\n"; + } + $markdown .= "\n"; + } + + // Public properties + $properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC); + if (!empty($properties)) { + $markdown .= "#### Properties\n\n"; + foreach ($properties as $property) { + $name = $property->getName(); + $type = $property->getType() ? $property->getType()->__toString() : 'mixed'; + $shortName = $reflection->getShortName(); + + $markdown .= "#### `\$$name`\n\n"; + $markdown .= "```php\n"; + $markdown .= "$type \$$name\n"; + $markdown .= "```\n\n"; + + // Property description + $propDoc = $property->getDocComment(); + if ($propDoc) { + $lines = explode("\n", $propDoc); + $description = ''; + foreach ($lines as $line) { + $line = trim($line); + if (str_starts_with($line, '/**') || str_starts_with($line, '*/') || str_starts_with($line, '*')) { + $line = ltrim($line, '/*'); + $line = trim($line); + if (!empty($line) && !str_starts_with($line, '@')) { + $description .= $line . " "; + } + } + } + if (!empty($description)) { + $markdown .= trim($description) . "\n\n"; + } + } + + $markdown .= "--------------------\n\n"; + } + } + + // Public methods + $methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC); + $publicMethods = array_filter($methods, fn($method) => !$method->isConstructor() && !$method->isDestructor()); + + if (!empty($publicMethods)) { + $markdown .= "#### Methods\n\n"; + + foreach ($publicMethods as $method) { + $name = $method->getName(); + $params = []; + $paramInfo = []; + + foreach ($method->getParameters() as $param) { + $paramType = $param->getType() ? $param->getType()->__toString() : 'mixed'; + $paramName = '$' . $param->getName(); + $paramStr = "$paramType $paramName"; + + if ($param->isDefaultValueAvailable()) { + $default = $param->getDefaultValue(); + if (is_null($default)) { + $paramStr .= ' = null'; + } elseif (is_bool($default)) { + $paramStr .= ' = ' . ($default ? 'true' : 'false'); + } elseif (is_string($default)) { + $paramStr .= ' = "' . $default . '"'; + } else { + $paramStr .= ' = ' . $default; + } + } + + $params[] = $paramStr; + $paramInfo[] = [ + 'name' => $param->getName(), + 'type' => $paramType, + 'optional' => $param->isDefaultValueAvailable() + ]; + } + + $returnType = $method->getReturnType() ? $method->getReturnType()->__toString() : 'mixed'; + + $paramSuffix = count($method->getParameters()) > 0 ? '(...)' : '()'; + $markdown .= "##### $name$paramSuffix\n\n"; + $markdown .= "```php\n"; + + // Format method signature with full namespace + $fullClassName = '\\' . $reflection->getName(); + $fullReturnType = $returnType; + if (str_contains($returnType, 'KDuma\\BinaryTools\\') && !str_starts_with($returnType, '\\')) { + $fullReturnType = '\\' . $returnType; + } + + if (count($method->getParameters()) > 0) { + // Multiline format for methods with parameters + $markdown .= "$fullClassName::$name(\n"; + $formattedParams = []; + foreach ($params as $param) { + // Add namespace prefix to types + $param = preg_replace('/\bKDuma\\\\BinaryTools\\\\([A-Za-z]+)/', '\\\\KDuma\\\\BinaryTools\\\\$1', $param); + $formattedParams[] = " $param"; + } + $markdown .= implode(",\n", $formattedParams) . "\n"; + $markdown .= "): $fullReturnType\n"; + } else { + // Single line for methods without parameters + $markdown .= "$fullClassName::$name(): $fullReturnType\n"; + } + + $markdown .= "```\n\n"; + + // Method description + $methodDoc = $method->getDocComment(); + $description = ''; + $docParams = []; + $returnDoc = ''; + + if ($methodDoc) { + $lines = explode("\n", $methodDoc); + + foreach ($lines as $line) { + $line = trim($line); + if (str_starts_with($line, '/**') || str_starts_with($line, '*/')) { + continue; + } + + $line = ltrim($line, '*'); + $line = trim($line); + + if (str_starts_with($line, '@param')) { + $parts = explode(' ', $line, 4); + if (count($parts) >= 3) { + $paramName = trim($parts[2], '$'); + $paramDesc = isset($parts[3]) ? $parts[3] : ''; + $docParams[$paramName] = $paramDesc; + } + } elseif (str_starts_with($line, '@return')) { + $returnDoc = str_replace('@return', '', $line); + $returnDoc = trim($returnDoc); + } elseif (str_starts_with($line, '@throws')) { + $description .= "\n\n**Throws:** " . str_replace('@throws', '', $line); + } elseif (!empty($line) && !str_starts_with($line, '@')) { + $description .= $line . " "; + } + } + } + + if (!empty($description)) { + $markdown .= trim($description) . "\n\n"; + } + + // Parameter table + if (!empty($paramInfo)) { + $markdown .= "| Param | Type | Description |\n"; + $markdown .= "| ----- | ---- | ----------- |\n"; + + foreach ($paramInfo as $info) { + $paramName = "**`{$info['name']}`**"; + + // Format type with namespace prefix and optional indicator + $paramType = $info['type']; + if (str_contains($paramType, 'KDuma\\BinaryTools\\') && !str_starts_with($paramType, '\\')) { + $paramType = '\\' . $paramType; + } + + // Escape pipe characters for markdown table + $escapedParamType = str_replace('|', '\\|', $paramType); + $typeColumn = "$escapedParamType"; + + if ($info['optional']) { + $typeColumn .= ' (optional)'; + } + + // Get description from docblock + $description = isset($docParams[$info['name']]) ? $docParams[$info['name']] : ''; + + $markdown .= "| $paramName | $typeColumn | $description |\n"; + } + $markdown .= "\n"; + } + + // Return type + $escapedReturnType = str_replace('|', '\\|', $fullReturnType); + if (!empty($returnDoc)) { + $markdown .= "**Returns:** $escapedReturnType - $returnDoc\n\n"; + } else { + $markdown .= "**Returns:** $escapedReturnType\n\n"; + } + + $markdown .= "--------------------\n\n"; + } + } + + $markdown .= "---\n\n"; +} + +// Add Enums section +$enumClasses = array_filter($classes, function($className) { + $reflection = new \ReflectionClass($className); + return $reflection->isEnum(); +}); + +if (!empty($enumClasses)) { + $markdown .= "### Enums\n\n"; + + foreach ($enumClasses as $enumClass) { + $reflection = new \ReflectionClass($enumClass); + $shortName = $reflection->getShortName(); + + $markdown .= "#### $shortName\n\n"; + + // Get enum description from docblock + $docComment = $reflection->getDocComment(); + if ($docComment) { + $lines = explode("\n", $docComment); + $description = ''; + foreach ($lines as $line) { + $line = trim($line); + if (str_starts_with($line, '/**') || str_starts_with($line, '*/') || str_starts_with($line, '*')) { + $line = ltrim($line, '/*'); + $line = trim($line); + if (!empty($line) && !str_starts_with($line, '@')) { + $description .= $line . " "; + } + } + } + if (!empty($description)) { + $markdown .= trim($description) . "\n\n"; + } + } + + // Namespace + $markdown .= "**Namespace:** `\\{$reflection->getName()}`\n\n"; + + // Enum cases table + $constants = $reflection->getConstants(); + if (!empty($constants)) { + $markdown .= "| Members | Value | Description |\n"; + $markdown .= "| ------- | ----- | ----------- |\n"; + + foreach ($constants as $name => $value) { + $memberName = "**`$name`**"; + + // For pure enums (not backed), just show the case name + // For backed enums, we'd need to use the enum's backing value + $caseValue = "'$name'"; + + // Try to get more specific value info if it's a backed enum + try { + if (method_exists($reflection, 'getBackingType')) { + $backingType = $reflection->getBackingType(); + if ($backingType) { + // This is a backed enum, but we can't easily get the backing value + // without instantiating the enum case + $caseValue = "'$name'"; + } + } + } catch (\Exception $e) { + // Fallback to case name + } + + // Try to get description from case docblock by reading the source file + $description = ''; + try { + $filename = $reflection->getFileName(); + if ($filename) { + $source = file_get_contents($filename); + $lines = explode("\n", $source); + + // Look for the case definition and preceding docblock + for ($i = 0; $i < count($lines); $i++) { + if (preg_match('/case\s+' . preg_quote($name) . '\s*;/', $lines[$i])) { + // Found the case, look back for docblock + for ($j = $i - 1; $j >= 0; $j--) { + $line = trim($lines[$j]); + if (str_starts_with($line, '/**')) { + // Found docblock start, extract description + $docLine = str_replace('/**', '', $line); + $docLine = str_replace('*/', '', $docLine); + $description = trim($docLine); + break; + } + if (!str_starts_with($line, '*') && !empty($line)) { + // Found non-docblock content, stop looking + break; + } + } + break; + } + } + } + } catch (\Exception $e) { + // Fallback to empty description + } + + $markdown .= "| $memberName | $caseValue | $description |\n"; + } + $markdown .= "\n"; + } + + $markdown .= "--------------------\n\n"; + } +} + +echo $markdown; \ No newline at end of file From 96ef3904c33329f8917e06527ea1915f9add1009 Mon Sep 17 00:00:00 2001 From: Krystian Duma Date: Thu, 18 Sep 2025 21:16:53 +0200 Subject: [PATCH 15/15] Style --- generate-api-docs.php | 8 ++++---- src/BinaryWriter.php | 6 ++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/generate-api-docs.php b/generate-api-docs.php index 419a906..1aeeea5 100644 --- a/generate-api-docs.php +++ b/generate-api-docs.php @@ -38,7 +38,7 @@ // Add methods to TOC $methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC); - $publicMethods = array_filter($methods, fn($method) => !$method->isConstructor() && !$method->isDestructor()); + $publicMethods = array_filter($methods, fn ($method) => !$method->isConstructor() && !$method->isDestructor()); foreach ($publicMethods as $method) { $methodName = $method->getName(); $paramCount = count($method->getParameters()); @@ -155,7 +155,7 @@ // Public methods $methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC); - $publicMethods = array_filter($methods, fn($method) => !$method->isConstructor() && !$method->isDestructor()); + $publicMethods = array_filter($methods, fn ($method) => !$method->isConstructor() && !$method->isDestructor()); if (!empty($publicMethods)) { $markdown .= "#### Methods\n\n"; @@ -308,7 +308,7 @@ } // Add Enums section -$enumClasses = array_filter($classes, function($className) { +$enumClasses = array_filter($classes, function ($className) { $reflection = new \ReflectionClass($className); return $reflection->isEnum(); }); @@ -415,4 +415,4 @@ } } -echo $markdown; \ No newline at end of file +echo $markdown; diff --git a/src/BinaryWriter.php b/src/BinaryWriter.php index 3409197..f41738c 100644 --- a/src/BinaryWriter.php +++ b/src/BinaryWriter.php @@ -80,8 +80,7 @@ public function writeBytesWith( Terminator|BinaryString|null $optional_terminator = null, Terminator|BinaryString|null $padding = null, ?int $padding_size = null, - ): self - { + ): self { if ($padding === null && $padding_size !== null) { $padding = Terminator::NUL; } @@ -197,8 +196,7 @@ public function writeStringWith( Terminator|BinaryString|null $optional_terminator = null, Terminator|BinaryString|null $padding = null, ?int $padding_size = null, - ): self - { + ): self { if (!mb_check_encoding($string->value, 'UTF-8')) { throw new \InvalidArgumentException('String must be valid UTF-8'); }