diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..22aac70 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +/tests export-ignore +/vendor export-ignore + +/LICENSE export-ignore +/Makefile export-ignore +/README.md export-ignore +/phpmd.xml export-ignore +/phpunit.xml export-ignore +/phpstan.neon.dist export-ignore +/infection.json.dist export-ignore + +/.github export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32850ac..105bc90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,11 @@ jobs: - name: Checkout uses: actions/checkout@v3 + - name: Use PHP 8.2 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + - name: Install dependencies run: composer update --no-progress --optimize-autoloader @@ -33,6 +38,11 @@ jobs: - name: Checkout uses: actions/checkout@v3 + - name: Use PHP 8.2 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + - name: Install dependencies run: composer update --no-progress --optimize-autoloader diff --git a/Makefile b/Makefile index 9bf35fe..1b3026e 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,17 @@ DOCKER_RUN = docker run --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.2 -.PHONY: configure test test-no-coverage review show-reports clean +.PHONY: configure test test-file test-no-coverage review show-reports clean configure: @${DOCKER_RUN} composer update --optimize-autoloader -test: review +test: @${DOCKER_RUN} composer tests -test-no-coverage: review +test-file: + @${DOCKER_RUN} composer tests-file-no-coverage ${FILE} + +test-no-coverage: @${DOCKER_RUN} composer tests-no-coverage review: @@ -19,4 +22,4 @@ show-reports: clean: @sudo chown -R ${USER}:${USER} ${PWD} - @rm -rf report vendor + @rm -rf report vendor .phpunit.cache diff --git a/README.md b/README.md index 935661a..cbd6635 100644 --- a/README.md +++ b/README.md @@ -26,14 +26,39 @@ composer require tiny-blocks/encoder ## How to use -The library exposes concrete implementations for encoding and decoding data. +The library provides concrete implementations of the `Encoder` interface, enabling encoding and decoding of data into +specific formats like Base62. ### Using Base62 +To encode a value into Base62 format: + +```php +$encoder = Base62::from(value: 'Hello world!'); +$encoded = $encoder->encode(); + +# Output: T8dgcjRGuYUueWht +``` + +To decode a Base62-encoded value back to its original form: + ```php -$encoded = Base62::encode(value: 'Hello world!') # T8dgcjRGuYUueWht +$encoder = Base62::from(value: 'T8dgcjRGuYUueWht'); +$decoded = $encoder->decode(); + +# Output: Hello world! +``` -Base62::decode(value: $encoded) # Hello world! +If you attempt to decode an invalid Base62 value, an `InvalidDecoding` exception will be thrown: + +```php +try { + $encoder = Base62::from(value: 'invalid_value'); + $decoded = $encoder->decode(); +} catch (InvalidDecoding $exception) { + echo $exception->getMessage(); + # Output: The value could not be decoded. +} ```
diff --git a/composer.json b/composer.json index 761cb91..4582ab4 100644 --- a/composer.json +++ b/composer.json @@ -8,8 +8,6 @@ "minimum-stability": "stable", "keywords": [ "psr", - "psr-4", - "psr-12", "base62", "decoder", "encoder", @@ -21,6 +19,10 @@ "homepage": "https://github.com/gustavofreze" } ], + "support": { + "issues": "https://github.com/tiny-blocks/encoder/issues", + "source": "https://github.com/tiny-blocks/encoder" + }, "config": { "sort-packages": true, "allow-plugins": { @@ -38,14 +40,15 @@ } }, "require": { - "php": "^8.1||^8.2", + "php": "^8.2", "ext-gmp": "*" }, "require-dev": { - "infection/infection": "^0.26", - "phpmd/phpmd": "^2.13", - "phpunit/phpunit": "^9.6", - "squizlabs/php_codesniffer": "^3.7" + "phpmd/phpmd": "^2.15", + "phpunit/phpunit": "^11", + "phpstan/phpstan": "^1", + "infection/infection": "^0.29", + "squizlabs/php_codesniffer": "^3.10" }, "suggest": { "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP." @@ -53,13 +56,15 @@ "scripts": { "phpcs": "phpcs --standard=PSR12 --extensions=php ./src", "phpmd": "phpmd ./src text phpmd.xml --suffixes php --ignore-violations-on-exit", + "phpstan": "phpstan analyse -c phpstan.neon.dist --quiet --no-progress", "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" + "@phpmd", + "@phpstan" ], "tests": [ "@test", diff --git a/infection.json.dist b/infection.json.dist index 3416675..0bdd7f1 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -1,26 +1,24 @@ { "timeout": 10, "testFramework": "phpunit", - "tmpDir": "report/", + "tmpDir": "report/infection/", "source": { "directories": [ "src" ] }, "logs": { - "text": "report/logs/infection-text.log", - "summary": "report/logs/infection-summary.log" + "text": "report/infection/logs/infection-text.log", + "summary": "report/infection/logs/infection-summary.log" }, "mutators": { "@default": true, - "Concat": false, - "FalseValue": false, - "IncrementInteger": false, - "DecrementInteger": false, - "ConcatOperandRemoval": false + "UnwrapSubstr": false, + "LogicalAndNegation": false, + "LogicalAndAllSubExprNegation": false }, "phpUnit": { "configDir": "", "customPath": "./vendor/bin/phpunit" } -} \ No newline at end of file +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..b99884f --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,7 @@ +parameters: + paths: + - src + level: 9 + tmpDir: report/phpstan + ignoreErrors: + reportUnmatchedIgnoredErrors: false diff --git a/phpunit.xml b/phpunit.xml index 3d05fc8..7f080dd 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,25 +1,35 @@ + bootstrap="vendor/autoload.php" + failOnRisky="true" + failOnWarning="true" + cacheDirectory=".phpunit.cache" + beStrictAboutOutputDuringTests="true"> + + + + src + + + - tests + tests - - src - + + + + + + + + + + diff --git a/src/Base62.php b/src/Base62.php index 8ae9fff..2070ddd 100644 --- a/src/Base62.php +++ b/src/Base62.php @@ -1,47 +1,56 @@ value); + $bytes = $hexadecimal->removeLeadingZeroBytes(); $base62 = str_repeat(self::BASE62_ALPHABET[0], $bytes); - if (empty($hexadecimal)) { + if ($hexadecimal->isEmpty()) { return $base62; } - $number = gmp_init($hexadecimal, self::BASE62_HEXADECIMAL_RADIX); + $number = $hexadecimal->toGmpInit(base: self::BASE62_HEXADECIMAL_RADIX); - return $base62 . gmp_strval($number, self::BASE62_RADIX); + return sprintf('%s%s', $base62, gmp_strval($number, self::BASE62_RADIX)); } - public static function decode(string $value): string + public function decode(): string { - if (strlen($value) !== strspn($value, self::BASE62_ALPHABET)) { - throw new InvalidBase62Encoding(value: $value); + if (strlen($this->value) !== strspn($this->value, self::BASE62_ALPHABET)) { + throw new InvalidDecoding(value: $this->value); } $bytes = 0; + $value = $this->value; while (!empty($value) && str_starts_with($value, self::BASE62_ALPHABET[0])) { $bytes++; - $value = substr($value, 1); + $value = substr($value, self::BASE62_CHARACTER_LENGTH); } if (empty($value)) { @@ -49,12 +58,15 @@ public static function decode(string $value): string } $number = gmp_init($value, self::BASE62_RADIX); - $hexadecimal = gmp_strval($number, self::BASE62_HEXADECIMAL_RADIX); + $hexadecimal = Hexadecimal::fromGmp(number: $number, base: self::BASE62_HEXADECIMAL_RADIX); + $hexadecimal->padLeft(); + + $binary = hex2bin(sprintf('%s%s', str_repeat('00', $bytes), $hexadecimal->toString())); - if (strlen($hexadecimal) % 2) { - $hexadecimal = '0' . $hexadecimal; + if (!is_string($binary)) { + throw new InvalidDecoding(value: $this->value); } - return hex2bin(str_repeat('00', $bytes) . $hexadecimal); + return $binary; } } diff --git a/src/Encoder.php b/src/Encoder.php new file mode 100644 index 0000000..a7700f2 --- /dev/null +++ b/src/Encoder.php @@ -0,0 +1,28 @@ + does not have a valid base62 encoding.'; + $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 new file mode 100644 index 0000000..f06e424 --- /dev/null +++ b/src/Internal/Hexadecimal.php @@ -0,0 +1,63 @@ +value = $value; + } + + public static function fromGmp(GMP $number, int $base): Hexadecimal + { + return new Hexadecimal(value: gmp_strval($number, $base)); + } + + public static function fromBinary(string $binary): Hexadecimal + { + return new Hexadecimal(value: bin2hex($binary)); + } + + public function isEmpty(): bool + { + return empty($this->value); + } + + public function padLeft(): void + { + if (strlen($this->value) % 2 !== 0) { + $this->value = sprintf('0%s', $this->value); + } + } + + public function toString(): string + { + return $this->value; + } + + public function toGmpInit(int $base): GMP + { + return gmp_init($this->value, $base); + } + + public function removeLeadingZeroBytes(): int + { + $bytes = 0; + + while (str_starts_with($this->value, '00')) { + $bytes++; + $this->value = substr($this->value, self::HEXADECIMAL_BYTE_LENGTH); + } + + return $bytes; + } +} diff --git a/tests/Base62Test.php b/tests/Base62Test.php index efb97e5..3d175be 100644 --- a/tests/Base62Test.php +++ b/tests/Base62Test.php @@ -1,80 +1,105 @@ encode(); + /** @Then the encoded value should match the expected result */ self::assertEquals($expected, $actual); } - /** - * @dataProvider providerForTestDecode - */ - public function testDecode(string $value, string $expected) + #[DataProvider('providerForTestDecode')] + public function testDecode(string $value, string $expected): void { - $actual = Base62::decode(value: $value); + /** @Given a Base62 encoded string */ + $encoder = Base62::from(value: $value); + /** @When decoding the value using Base62 */ + $actual = $encoder->decode(); + + /** @Then the decoded value should match the expected result */ self::assertEquals($expected, $actual); } - /** - * @dataProvider providerForTestEncodeAndDecodeWithLeadingZeroBytes - */ - public function testEncodeAndDecodeWithLeadingZeroBytes(string $value): void + public function testWhenInvalidDecodingBase62(): void { - $encoded = Base62::encode(value: $value); - $actual = Base62::decode(value: $encoded); + $value = hex2bin('9850EEEC191BF4FF26F99315CE43B0C8'); + $template = 'The value <%s> could not be decoded.'; + + $this->expectException(InvalidDecoding::class); + $this->expectExceptionMessage(sprintf($template, $value)); - self::assertEquals($value, $actual); + Base62::from(value: $value)->decode(); } - public function testWhenInvalidBase62Encoding(): void + public function testWhenInvalidDecodingBase62WhenHex2BinFails(): void { - $value = hex2bin('9850EEEC191BF4FF26F99315CE43B0C8'); - $template = 'The value <%s> does not have a valid base62 encoding.'; + $value = '\\A'; + $template = 'The value <%s> could not be decoded.'; - $this->expectException(InvalidBase62Encoding::class); + $this->expectException(InvalidDecoding::class); $this->expectExceptionMessage(sprintf($template, $value)); - Base62::decode(value: $value); + Base62::from(value: $value)->decode(); + } + + #[DataProvider('providerForTestEncodeAndDecodeWithLeadingZeroBytes')] + public function testEncodeAndDecodeWithLeadingZeroBytes(string $value): void + { + /** @Given a binary value with leading zero bytes */ + $encoder = Base62::from(value: $value); + + /** @When encoding the binary value */ + $encoded = $encoder->encode(); + + /** @When decoding the encoded value */ + $decoded = Base62::from(value: $encoded)->decode(); + + /** @Then the decoded value should match the original binary value */ + self::assertEquals($value, $decoded); } - public function providerForTestEncode(): array + public static function providerForTestEncode(): array { return [ - ['', ''], - ['@#$%^&*()', 'MjehbVgJedVR'], - ['1234567890', '1A0afZkibIAR2O'], - ['Hello world!', 'T8dgcjRGuYUueWht'] + 'hello world' => ['value' => 'Hello world!', 'expected' => 'T8dgcjRGuYUueWht'], + 'empty string' => ['value' => '', 'expected' => ''], + 'numeric string' => ['value' => '1234567890', 'expected' => '1A0afZkibIAR2O'], + 'special characters' => ['value' => '@#$%^&*()', 'expected' => 'MjehbVgJedVR'] ]; } - public function providerForTestDecode(): array + public static function providerForTestDecode(): array { return [ - ['', ''], - ['MjehbVgJedVR', '@#$%^&*()'], - ['1A0afZkibIAR2O', '1234567890'], - ['T8dgcjRGuYUueWht', 'Hello world!'] + 'empty string' => ['value' => '', 'expected' => ''], + 'hello world' => ['value' => 'T8dgcjRGuYUueWht', 'expected' => 'Hello world!'], + 'numeric string' => ['value' => '1A0afZkibIAR2O', 'expected' => '1234567890'], + 'special characters' => ['value' => 'MjehbVgJedVR', 'expected' => '@#$%^&*()'] ]; } - public function providerForTestEncodeAndDecodeWithLeadingZeroBytes(): array + public static function providerForTestEncodeAndDecodeWithLeadingZeroBytes(): array { return [ - ['001jlt60MnKnB9ECKRt4gl'], - [hex2bin('07d8e31da269bf28')], - [hex2bin('0000010203040506')] + 'leading zero bytes 01' => ['value' => '001jlt60MnKnB9ECKRt4gl'], + 'leading zero bytes 02' => ['value' => hex2bin('07d8e31da269bf28')], + 'leading zero bytes 03' => ['value' => hex2bin('0000010203040506')] ]; } }