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..1aeeea5
--- /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;
diff --git a/src/BinaryReader.php b/src/BinaryReader.php
index 241eb33..4b2b316 100644
--- a/src/BinaryReader.php
+++ b/src/BinaryReader.php
@@ -2,6 +2,7 @@
namespace KDuma\BinaryTools;
+use Deprecated;
use RuntimeException;
final class BinaryReader
@@ -22,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;
@@ -47,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) {
@@ -62,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) {
@@ -74,28 +93,107 @@ public function readBytes(int $count): BinaryString
return BinaryString::fromString($result);
}
- public function readBytesWithLength(bool $use16BitLength = false): BinaryString
- {
- if ($use16BitLength) {
- $length = $this->readUint16BE();
- } else {
- $length = $this->readByte();
+ /**
+ * 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,
+ 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;
}
- try {
- return $this->readBytes($length);
- } catch (\RuntimeException $exception) {
- $this->position -= ($use16BitLength ? 2 : 1);
- throw $exception;
+ $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');
+ }
+
+ $selectedMode = array_key_first($modes);
+
+ if ($selectedMode === 'length') {
+ return $this->_readWithLength($length);
+ }
+
+ if ($selectedMode === 'padding') {
+ return $this->_readWithPadding($padding, $padding_size);
}
+
+ return $this->_readWithTerminator($modes[$selectedMode], $selectedMode === 'optional_terminator');
}
- public function readUint16BE(): int
+ /**
+ * 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
{
- $bytes = $this->readBytes(2)->value;
- return (ord($bytes[0]) << 8) | ord($bytes[1]);
+ if (!$type->isSupported()) {
+ // @codeCoverageIgnoreStart
+ throw new RuntimeException(
+ 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()) {
+ $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;
+ }
+ }
+ }
+
+ // 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;
}
+
+ /**
+ * 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);
@@ -109,18 +207,41 @@ public function readString(int $length): BinaryString
return $bytes;
}
- public function readStringWithLength(bool $use16BitLength = false): BinaryString
- {
- $string = $this->readBytesWithLength($use16BitLength);
+ /**
+ * 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,
+ 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, $padding, $padding_size);
if (!mb_check_encoding($string->value, 'UTF-8')) {
- $this->position -= ($use16BitLength ? 2 : 1) + $string->size();
+ $this->position = $startPosition;
throw new RuntimeException('Invalid UTF-8 string');
}
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) {
@@ -130,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) {
@@ -139,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) {
@@ -148,8 +281,132 @@ 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;
}
+
+ // 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);
+ }
+
+ // Private methods
+
+ 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) {
+ $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) {
+ throw new \InvalidArgumentException('Terminator cannot be empty');
+ }
+
+ $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);
+ }
+
+ 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/BinaryString.php b/src/BinaryString.php
index da928aa..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,48 +35,83 @@ 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) {
+ return true;
+ }
+
+ return str_contains($this->value, $needle->value);
+ }
}
diff --git a/src/BinaryWriter.php b/src/BinaryWriter.php
index 0b41a53..f41738c 100644
--- a/src/BinaryWriter.php
+++ b/src/BinaryWriter.php
@@ -2,25 +2,42 @@
namespace KDuma\BinaryTools;
+use Deprecated;
+
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) {
@@ -31,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;
@@ -38,37 +60,113 @@ public function writeBytes(BinaryString $bytes): self
return $this;
}
- public function writeBytesWithLength(BinaryString $bytes, bool $use16BitLength = false): 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);
+ /**
+ * 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,
+ Terminator|BinaryString|null $terminator = null,
+ Terminator|BinaryString|null $optional_terminator = null,
+ Terminator|BinaryString|null $padding = null,
+ ?int $padding_size = null,
+ ): self {
+ if ($padding === null && $padding_size !== null) {
+ $padding = Terminator::NUL;
}
- $this->writeBytes($bytes);
+ $modes = array_filter([
+ 'length' => $length,
+ 'terminator' => $terminator,
+ 'optional_terminator' => $optional_terminator,
+ 'padding' => $padding,
+ ], static fn ($value) => $value !== null);
- return $this;
+ 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);
+ }
+
+ 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 writeUint16BE(int $value): self
+ /**
+ * 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 ($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;
}
- $this->buffer .= chr(($value >> 8) & 0xFF);
- $this->buffer .= chr($value & 0xFF);
+ $bytes = '';
+ for ($i = $bytesCount - 1; $i >= 0; $i--) {
+ $bytes .= chr(($value >> ($i * 8)) & 0xFF);
+ }
+
+ if ($type->isLittleEndian()) {
+ $bytes = strrev($bytes);
+ }
+
+ $this->buffer .= $bytes;
return $this;
}
+
+ /**
+ * 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')) {
@@ -80,13 +178,136 @@ public function writeString(BinaryString $string): self
return $this;
}
- public function writeStringWithLength(BinaryString $string, bool $use16BitLength = false): self
- {
+ /**
+ * 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,
+ 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');
}
- $this->writeBytesWithLength($string, $use16BitLength);
+ return $this->writeBytesWith($string, $length, $terminator, $optional_terminator, $padding, $padding_size);
+ }
+
+ // 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);
+ }
+
+ // 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 ($terminatorBytes->size() === 0) {
+ throw new \InvalidArgumentException('Terminator cannot be empty');
+ }
+
+ if ($data->contains($terminatorBytes)) {
+ throw new \InvalidArgumentException('Data contains terminator sequence');
+ }
+
+ $this->writeBytes($data);
+ $this->writeBytes($terminatorBytes);
+
+ 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/src/IntType.php b/src/IntType.php
new file mode 100644
index 0000000..4746dd8
--- /dev/null
+++ b/src/IntType.php
@@ -0,0 +1,135 @@
+ 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,
+ };
+ }
+
+ 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/src/Terminator.php b/src/Terminator.php
new file mode 100644
index 0000000..bfb7965
--- /dev/null
+++ b/src/Terminator.php
@@ -0,0 +1,152 @@
+ 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 5422e35..3d66615 100644
--- a/tests/BinaryReaderTest.php
+++ b/tests/BinaryReaderTest.php
@@ -4,7 +4,10 @@
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;
#[CoversClass(BinaryReader::class)]
@@ -94,11 +97,63 @@ 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 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()
@@ -287,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);
}
@@ -295,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);
}
}
@@ -307,6 +362,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 (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 (valid range: 0-4)', $exception->getMessage());
+ }
+ }
+
public function testGetRemainingBytes()
{
$this->reader->seek(0);
@@ -318,4 +395,484 @@ 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));
+ }
+
+ 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);
+ }
+
+ 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 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 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"));
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('Terminator not found before end of data');
+
+ $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"));
+
+ // Test both parameters provided
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Exactly one of length, terminator, optional_terminator, or padding 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, optional_terminator, or padding 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, optional_terminator, or padding 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 16180d7..a42b959 100644
--- a/tests/BinaryWriterTest.php
+++ b/tests/BinaryWriterTest.php
@@ -4,7 +4,10 @@
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;
#[CoversClass(BinaryWriter::class)]
@@ -63,7 +66,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());
}
}
@@ -130,7 +133,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());
}
@@ -143,7 +146,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());
}
}
@@ -179,4 +182,457 @@ 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());
+ }
+
+ /**
+ * @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());
+ }
+
+ 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 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();
+ $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, terminator, optional_terminator, or padding 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, terminator, optional_terminator, or padding 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 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();
+
+ // 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/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));
+ }
+ }
+ }
+}
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
+ }
+}