diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 105bc90..96e4a08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,9 @@ on: permissions: contents: read +env: + PHP_VERSION: '8.3' + jobs: auto-review: name: Auto review @@ -14,21 +17,18 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Use PHP 8.2 + - name: Configure PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: ${{ env.PHP_VERSION }} - name: Install dependencies run: composer update --no-progress --optimize-autoloader - - name: Run phpcs - run: composer phpcs - - - name: Run phpmd - run: composer phpmd + - name: Run review + run: composer review tests: name: Tests @@ -36,20 +36,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Use PHP 8.2 + - name: Use PHP ${{ env.PHP_VERSION }} uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: ${{ env.PHP_VERSION }} - name: Install dependencies run: composer update --no-progress --optimize-autoloader - - name: Run unit tests - env: - XDEBUG_MODE: coverage - run: composer test - - - name: Run mutation tests - run: composer test-mutation + - name: Run tests + run: composer tests diff --git a/Makefile b/Makefile index 1b3026e..3cfd101 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -DOCKER_RUN = docker run --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.2 +DOCKER_RUN = docker run --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.3 .PHONY: configure test test-file test-no-coverage review show-reports clean diff --git a/composer.json b/composer.json index 4582ab4..6fc3550 100644 --- a/composer.json +++ b/composer.json @@ -40,18 +40,18 @@ } }, "require": { - "php": "^8.2", - "ext-gmp": "*" + "php": "^8.3", + "ext-bcmath": "*" }, "require-dev": { "phpmd/phpmd": "^2.15", "phpunit/phpunit": "^11", "phpstan/phpstan": "^1", "infection/infection": "^0.29", - "squizlabs/php_codesniffer": "^3.10" + "squizlabs/php_codesniffer": "^3.11" }, "suggest": { - "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP." + "ext-bcmath": "Enables the extension which is an interface to the GNU implementation as a Basic Calculator utility library." }, "scripts": { "phpcs": "phpcs --standard=PSR12 --extensions=php ./src", @@ -60,7 +60,6 @@ "test": "phpunit --log-junit=report/coverage/junit.xml --coverage-xml=report/coverage/coverage-xml --coverage-html=report/coverage/coverage-html tests", "test-mutation": "infection --only-covered --logger-html=report/coverage/mutation-report.html --coverage=report/coverage --min-msi=100 --min-covered-msi=100 --threads=4", "test-no-coverage": "phpunit --no-coverage", - "test-mutation-no-coverage": "infection --only-covered --min-msi=100 --threads=4", "review": [ "@phpcs", "@phpmd", @@ -71,8 +70,7 @@ "@test-mutation" ], "tests-no-coverage": [ - "@test-no-coverage", - "@test-mutation-no-coverage" + "@test-no-coverage" ] } } diff --git a/infection.json.dist b/infection.json.dist index 0bdd7f1..72bef1b 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -1,5 +1,5 @@ { - "timeout": 10, + "timeout": 30, "testFramework": "phpunit", "tmpDir": "report/infection/", "source": { @@ -13,7 +13,12 @@ }, "mutators": { "@default": true, + "BCMath": false, + "CastInt": false, + "Increment": false, + "GreaterThan": false, "UnwrapSubstr": false, + "UnwrapStrToLower": false, "LogicalAndNegation": false, "LogicalAndAllSubExprNegation": false }, diff --git a/src/Base62.php b/src/Base62.php index 2070ddd..1b24e24 100644 --- a/src/Base62.php +++ b/src/Base62.php @@ -4,15 +4,16 @@ namespace TinyBlocks\Encoder; +use TinyBlocks\Encoder\Internal\Decimal; use TinyBlocks\Encoder\Internal\Exceptions\InvalidDecoding; use TinyBlocks\Encoder\Internal\Hexadecimal; final readonly class Base62 implements Encoder { - private const BASE62_RADIX = 62; - private const BASE62_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; - private const BASE62_CHARACTER_LENGTH = 1; - private const BASE62_HEXADECIMAL_RADIX = 16; + public const int BASE62_RADIX = 62; + private const int BASE62_CHARACTER_LENGTH = 1; + + private const string BASE62_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; private function __construct(private string $value) { @@ -25,18 +26,18 @@ public static function from(string $value): Encoder public function encode(): string { - $hexadecimal = Hexadecimal::fromBinary(binary: $this->value); - $bytes = $hexadecimal->removeLeadingZeroBytes(); + $hexadecimal = Hexadecimal::fromBinary(binary: $this->value, alphabet: self::BASE62_ALPHABET); + $hexadecimal = $hexadecimal->removeLeadingZeroBytes(); - $base62 = str_repeat(self::BASE62_ALPHABET[0], $bytes); + $base62 = str_repeat(self::BASE62_ALPHABET[0], $hexadecimal->getBytes()); if ($hexadecimal->isEmpty()) { return $base62; } - $number = $hexadecimal->toGmpInit(base: self::BASE62_HEXADECIMAL_RADIX); + $base62Value = $hexadecimal->toBase(base: self::BASE62_RADIX); - return sprintf('%s%s', $base62, gmp_strval($number, self::BASE62_RADIX)); + return sprintf('%s%s', $base62, $base62Value); } public function decode(): string @@ -57,16 +58,13 @@ public function decode(): string return str_repeat("\x00", $bytes); } - $number = gmp_init($value, self::BASE62_RADIX); - $hexadecimal = Hexadecimal::fromGmp(number: $number, base: self::BASE62_HEXADECIMAL_RADIX); - $hexadecimal->padLeft(); - - $binary = hex2bin(sprintf('%s%s', str_repeat('00', $bytes), $hexadecimal->toString())); + $decimal = Decimal::fromBase62(number: $value, alphabet: self::BASE62_ALPHABET); + $hexadecimal = Hexadecimal::from(value: $decimal->toHexadecimal()) + ->fillWithZeroIfNecessary() + ->toString(); - if (!is_string($binary)) { - throw new InvalidDecoding(value: $this->value); - } + $binary = hex2bin($hexadecimal); - return $binary; + return sprintf('%s%s', str_repeat("\x00", $bytes), $binary); } } diff --git a/src/Internal/Decimal.php b/src/Internal/Decimal.php new file mode 100644 index 0000000..50dceee --- /dev/null +++ b/src/Internal/Decimal.php @@ -0,0 +1,42 @@ +value; + $hexadecimalValue = ''; + + while (bccomp($value, '0') > 0) { + $remainder = bcmod($value, Hexadecimal::HEXADECIMAL_RADIX); + $hexadecimalValue = sprintf('%s%s', Hexadecimal::HEXADECIMAL_ALPHABET[(int)$remainder], $hexadecimalValue); + $value = bcdiv($value, Hexadecimal::HEXADECIMAL_RADIX); + } + + return $hexadecimalValue; + } +} diff --git a/src/Internal/Exceptions/InvalidDecoding.php b/src/Internal/Exceptions/InvalidDecoding.php index d5e1b48..f53735e 100644 --- a/src/Internal/Exceptions/InvalidDecoding.php +++ b/src/Internal/Exceptions/InvalidDecoding.php @@ -11,6 +11,7 @@ final class InvalidDecoding extends RuntimeException public function __construct(private readonly string $value) { $template = 'The value <%s> could not be decoded.'; + parent::__construct(message: sprintf($template, $this->value)); } } diff --git a/src/Internal/Hexadecimal.php b/src/Internal/Hexadecimal.php index f06e424..1b126f4 100644 --- a/src/Internal/Hexadecimal.php +++ b/src/Internal/Hexadecimal.php @@ -4,60 +4,86 @@ namespace TinyBlocks\Encoder\Internal; -use GMP; - -final class Hexadecimal +final readonly class Hexadecimal { - private const HEXADECIMAL_BYTE_LENGTH = 2; + private const int DEFAULT_BYTE_COUNT = 0; + private const int HEXADECIMAL_BYTE_LENGTH = 2; - private string $value; + public const string HEXADECIMAL_RADIX = '16'; + public const string HEXADECIMAL_ALPHABET = '0123456789abcdef'; - private function __construct(string $value) - { - $this->value = $value; + private function __construct( + private string $value, + private string $alphabet, + private int $bytes = self::DEFAULT_BYTE_COUNT + ) { } - public static function fromGmp(GMP $number, int $base): Hexadecimal + public static function from(string $value): Hexadecimal { - return new Hexadecimal(value: gmp_strval($number, $base)); + return new Hexadecimal(value: $value, alphabet: self::HEXADECIMAL_ALPHABET); } - public static function fromBinary(string $binary): Hexadecimal + public static function fromBinary(string $binary, string $alphabet): Hexadecimal { - return new Hexadecimal(value: bin2hex($binary)); + return new Hexadecimal(value: bin2hex($binary), alphabet: $alphabet); } - public function isEmpty(): bool + public function removeLeadingZeroBytes(): Hexadecimal { - return empty($this->value); + $bytes = 0; + $newValue = $this->value; + + while (str_starts_with($newValue, '00')) { + $bytes++; + $newValue = substr($newValue, self::HEXADECIMAL_BYTE_LENGTH); + } + + return new Hexadecimal(value: $newValue, alphabet: $this->alphabet, bytes: $bytes); } - public function padLeft(): void + public function fillWithZeroIfNecessary(): Hexadecimal { - if (strlen($this->value) % 2 !== 0) { - $this->value = sprintf('0%s', $this->value); - } + $newValue = strlen($this->value) % 2 !== 0 ? sprintf('0%s', $this->value) : $this->value; + + return new Hexadecimal(value: $newValue, alphabet: $this->alphabet, bytes: $this->bytes); } - public function toString(): string + public function getBytes(): int { - return $this->value; + return $this->bytes; } - public function toGmpInit(int $base): GMP + public function isEmpty(): bool { - return gmp_init($this->value, $base); + return empty($this->value); } - public function removeLeadingZeroBytes(): int + public function toBase(int $base): string { - $bytes = 0; + $length = strlen($this->value); + $decimalValue = '0'; - while (str_starts_with($this->value, '00')) { - $bytes++; - $this->value = substr($this->value, self::HEXADECIMAL_BYTE_LENGTH); + for ($index = 0; $index < $length; $index++) { + $digit = strpos(self::HEXADECIMAL_ALPHABET, strtolower($this->value[$index])); + $decimalValue = bcmul($decimalValue, self::HEXADECIMAL_RADIX); + $decimalValue = bcadd($decimalValue, (string)$digit); + } + + $digits = $this->alphabet; + $result = ''; + + while (bccomp($decimalValue, '0') > 0) { + $remainder = bcmod($decimalValue, (string)$base); + $result = sprintf('%s%s', $digits[(int)$remainder], $result); + $decimalValue = bcdiv($decimalValue, (string)$base); } - return $bytes; + return $result ?: '0'; + } + + public function toString(): string + { + return $this->value; } } diff --git a/tests/Base62Test.php b/tests/Base62Test.php index 3d175be..4ead139 100644 --- a/tests/Base62Test.php +++ b/tests/Base62Test.php @@ -77,29 +77,32 @@ public function testEncodeAndDecodeWithLeadingZeroBytes(string $value): void public static function providerForTestEncode(): array { return [ - 'hello world' => ['value' => 'Hello world!', 'expected' => 'T8dgcjRGuYUueWht'], - 'empty string' => ['value' => '', 'expected' => ''], - 'numeric string' => ['value' => '1234567890', 'expected' => '1A0afZkibIAR2O'], - 'special characters' => ['value' => '@#$%^&*()', 'expected' => 'MjehbVgJedVR'] + 'Hello world' => ['value' => 'Hello world!', 'expected' => 'T8dgcjRGuYUueWht'], + 'Empty string' => ['value' => '', 'expected' => ''], + 'Numeric string' => ['value' => '1234567890', 'expected' => '1A0afZkibIAR2O'], + 'Special characters' => ['value' => '@#$%^&*()', 'expected' => 'MjehbVgJedVR'] ]; } public static function providerForTestDecode(): array { return [ - 'empty string' => ['value' => '', 'expected' => ''], - 'hello world' => ['value' => 'T8dgcjRGuYUueWht', 'expected' => 'Hello world!'], - 'numeric string' => ['value' => '1A0afZkibIAR2O', 'expected' => '1234567890'], - 'special characters' => ['value' => 'MjehbVgJedVR', 'expected' => '@#$%^&*()'] + 'Zero value' => ['value' => '0', 'expected' => ''], + 'Empty string' => ['value' => '', 'expected' => ''], + 'Hello world' => ['value' => 'T8dgcjRGuYUueWht', 'expected' => 'Hello world!'], + 'Leading zeros' => ['value' => '000001', 'expected' => hex2bin('000000000001')], + 'Numeric string' => ['value' => '1A0afZkibIAR2O', 'expected' => '1234567890'], + 'Single character' => ['value' => '1', 'expected' => "\001"], + 'Special characters' => ['value' => 'MjehbVgJedVR', 'expected' => '@#$%^&*()'] ]; } public static function providerForTestEncodeAndDecodeWithLeadingZeroBytes(): array { return [ - 'leading zero bytes 01' => ['value' => '001jlt60MnKnB9ECKRt4gl'], - 'leading zero bytes 02' => ['value' => hex2bin('07d8e31da269bf28')], - 'leading zero bytes 03' => ['value' => hex2bin('0000010203040506')] + 'Leading zero bytes 01' => ['value' => '001jlt60MnKnB9ECKRt4gl'], + 'Leading zero bytes 02' => ['value' => hex2bin('07d8e31da269bf28')], + 'Leading zero bytes 03' => ['value' => hex2bin('0000010203040506')] ]; } }