From 60f071ba8c678c1cfdf2979cdfd4ed6c007fe476 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 25 Nov 2023 16:44:54 +0100 Subject: [PATCH 01/29] Usage info --- docs/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index a74f6ab..cd9a9f5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,8 @@ The value objects are immutable, always assign them when running modifications o You can create the value object from - string - integer -- float (careful) +- float +- object that implements Stringable (and numeric `toString()` method) ```php use PhpCollective\DecimalObject\Decimal; From ed16470fe289a896bbb426dbb7ac816165ee5782 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sat, 25 Nov 2023 16:55:37 +0100 Subject: [PATCH 02/29] Precision/Scale docs --- docs/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/README.md b/docs/README.md index cd9a9f5..c067bae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -80,6 +80,20 @@ $decimalAdded = $decimalOne->add($decimalTwo); // Now '3.3' Note that due to immutability `$decimalOne` is not modified here. The re-assignment is necessary for the operation to persist. +### Precision/Scale +Operations like `add()` use the higher of the scales of the operands. +If both are the same, they would also stay the same. + +With other operations like `multiply()`, they scale would be the addition of both operands' scale: +```php +$decimalOne = Decimal::create('1.55'); +$decimalTwo = Decimal::create('2.00'); + +echo $decimalOne->multiply($decimalTwo); // Prints '3.1000' + +// Keeping 2 digit scale requires a 2nd argument +echo $decimalOne->multiply($decimalTwo, 2); // Prints '3.10' +``` ## Contributing From 752ac99cc33d21e9ddaa0a8c0e5f444fd9ab65b4 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 26 Nov 2023 19:26:03 +0100 Subject: [PATCH 03/29] History docs. --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index afd26aa..f74f150 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,13 @@ composer require php-collective/decimal-object See [Documentation](/docs) for more details. -### Implementations +## Implementations The following libraries are using the `Decimal` value object: - [dereuromark/cakephp-decimal](https://github.com/dereuromark/cakephp-decimal) as decimal type replacement for CakePHP ORM. + +The original library here was written 2019 for [Spryker](https://spryker.com/) ecommerce framework +and since then battle-tested through millions of customers and orders as well as other complex calculations under the hood. +This here is the freshly upgraded and independently maintained version for the PHP ecosystem as a whole. + +Enjoy! From 1e9671c7134e01e4f9282cc8101d5cbf3c2a1fcb Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Wed, 29 Nov 2023 00:31:53 +0100 Subject: [PATCH 04/29] Note about (monetary) alternatives --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f74f150..e92aa38 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,11 @@ var_dump(0.1 + 0.2 - 0.3); // float(5.5511151231258E-17) Handling them as string is a workaround, but as value object you can more easily encapsulate some of the logic. ### Alternatives -Solutions like https://php-decimal.io require a PHP extension (would make it faster, but also more difficult for some +Solutions like [php-decimal](https://php-decimal.github.io/#introduction) require a PHP extension (would make it faster, but also more difficult for some servers to be available). For details see the [wiki](https://github.com/php-collective/decimal-object/wiki). +For monetary values itself one could look into [brick/money](https://github.com/brick/money) or [moneyphp/money](https://github.com/moneyphp/money). + Note: If you are looking for pre 8.1 versions, check out legacy library (https://github.com/spryker/decimal-object). ## Features From f5f86646d34e824a90552d49eb0132386017e53d Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Wed, 29 Nov 2023 00:34:46 +0100 Subject: [PATCH 05/29] Update README.md --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e92aa38..31e08aa 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,11 @@ var_dump(0.1 + 0.2 - 0.3); // float(5.5511151231258E-17) Handling them as string is a workaround, but as value object you can more easily encapsulate some of the logic. ### Alternatives -Solutions like [php-decimal](https://php-decimal.github.io/#introduction) require a PHP extension (would make it faster, but also more difficult for some -servers to be available). For details see the [wiki](https://github.com/php-collective/decimal-object/wiki). +Solutions like [php-decimal](https://php-decimal.github.io/#introduction) require a custom PHP extension (would make it faster, but also more difficult for some +servers to be available). +Monetary values themselves one could be handled also with [brick/money](https://github.com/brick/money) or [moneyphp/money](https://github.com/moneyphp/money). -For monetary values itself one could look into [brick/money](https://github.com/brick/money) or [moneyphp/money](https://github.com/moneyphp/money). +For details see the [wiki](https://github.com/php-collective/decimal-object/wiki). Note: If you are looking for pre 8.1 versions, check out legacy library (https://github.com/spryker/decimal-object). From cb2c24f2bb728e9326fa097c63bc3581fe582dfb Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Wed, 29 Nov 2023 00:35:58 +0100 Subject: [PATCH 06/29] Docs --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 31e08aa..4228f43 100644 --- a/README.md +++ b/README.md @@ -17,16 +17,16 @@ var_dump(0.1 + 0.2); // float(0.3) var_dump(0.1 + 0.2 - 0.3); // float(5.5511151231258E-17) ``` -Handling them as string is a workaround, but as value object you can more easily encapsulate some of the logic. +Handling them as strings is a workaround, but as value object you can more easily encapsulate some of the logic. ### Alternatives Solutions like [php-decimal](https://php-decimal.github.io/#introduction) require a custom PHP extension (would make it faster, but also more difficult for some servers to be available). -Monetary values themselves one could be handled also with [brick/money](https://github.com/brick/money) or [moneyphp/money](https://github.com/moneyphp/money). +Monetary values themselves could be handled also with [brick/money](https://github.com/brick/money) or [moneyphp/money](https://github.com/moneyphp/money). For details see the [wiki](https://github.com/php-collective/decimal-object/wiki). -Note: If you are looking for pre 8.1 versions, check out legacy library (https://github.com/spryker/decimal-object). +Note: If you are looking for pre PHP 8.1 versions, check out the [legacy library](https://github.com/spryker/decimal-object). ## Features From d8ba09cc068266444af991f21847ce8a7f8fe56a Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 4 Dec 2023 23:24:16 +0000 Subject: [PATCH 07/29] Run composer normalize over composer.json --- composer.json | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/composer.json b/composer.json index d0f9ef8..f81d273 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,8 @@ { "name": "php-collective/decimal-object", "description": "PHP decimal handling as value object", - "type": "library", "license": "MIT", + "type": "library", "authors": [ { "name": "The PHP Collective", @@ -19,10 +19,12 @@ "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "^10.3.0", "php-collective/code-sniffer": "^0.2.1", - "phpstan/phpstan": "^1.0.0" + "phpstan/phpstan": "^1.0.0", + "phpunit/phpunit": "^10.3.0" }, + "minimum-stability": "stable", + "prefer-stable": true, "autoload": { "psr-4": { "PhpCollective\\DecimalObject\\": "src/" @@ -33,25 +35,23 @@ "PhpCollective\\DecimalObject\\Test\\": "tests/" } }, - "minimum-stability": "stable", - "prefer-stable": true, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, "extra": { "branch-alias": { "dev-master": "1.x-dev" } }, "scripts": { - "test": "phpunit", - "test-coverage": "phpunit --log-junit tmp/coverage/unitreport.xml --coverage-html tmp/coverage --coverage-clover tmp/coverage/coverage.xml", - "stan": "phpstan analyse", + "cs-check": "vendor/bin/phpcs", + "cs-fix": "vendor/bin/phpcbf", "lowest": "validate-prefer-lowest", "lowest-setup": "composer update --prefer-lowest --prefer-stable --prefer-dist --no-interaction && cp composer.json composer.backup && composer require --dev dereuromark/composer-prefer-lowest && mv composer.backup composer.json", - "cs-check": "vendor/bin/phpcs", - "cs-fix": "vendor/bin/phpcbf" - }, - "config": { - "allow-plugins": { - "dealerdirect/phpcodesniffer-composer-installer": true - } + "stan": "phpstan analyse", + "test": "phpunit", + "test-coverage": "phpunit --log-junit tmp/coverage/unitreport.xml --coverage-html tmp/coverage --coverage-clover tmp/coverage/coverage.xml" } } From 45892c18fd8ef158741c8ac32ab4118a48dc8b5e Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Thu, 7 Dec 2023 14:56:17 +0100 Subject: [PATCH 08/29] Test stringable classes for create (#12) * Test stringable classes for create --- src/Decimal.php | 4 ++++ tests/DecimalTest.php | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/Decimal.php b/src/Decimal.php index c136a4a..cb607c9 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -94,6 +94,10 @@ protected function parseValue(object|string|float|int $value): string throw new InvalidArgumentException('Invalid value'); } + if ($value instanceof Stringable) { + $value = (string)$value; + } + if (is_string($value) && !is_numeric(trim($value))) { throw new InvalidArgumentException('Invalid non numeric value'); } diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index e8fd9ac..8da6e9f 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -13,6 +13,7 @@ use InvalidArgumentException; use PhpCollective\DecimalObject\Decimal; use PHPUnit\Framework\TestCase; +use Stringable; use TypeError; class DecimalTest extends TestCase @@ -58,6 +59,27 @@ public function testCreate(mixed $value, string $expected): void $this->assertSame($expected, (string)$decimal); } + /** + * @return void + */ + public function testCreateInvalidObject(): void + { + $objectStringable = new class implements Stringable + { + /** + * @return string + */ + public function __toString(): string + { + return 'foo'; + } + }; + + $this->expectException(InvalidArgumentException::class); + + Decimal::create($objectStringable); + } + /** * @return array */ @@ -74,6 +96,17 @@ public function __toString(): string } }; + $objectStringable = new class implements Stringable + { + /** + * @return string + */ + public function __toString(): string + { + return '12.12'; + } + }; + return [ [1.1, '1.1'], [-23, '-23'], @@ -102,6 +135,7 @@ public function __toString(): string ['622000000000000000000000', '622000000000000000000000'], ['3.11e2', '311'], [$objectWithToStringMethod, '12.12'], + [$objectStringable, '12.12'], ]; } From c983ea0b649610415f328690a175ae1d221122d4 Mon Sep 17 00:00:00 2001 From: mscherer Date: Thu, 7 Dec 2023 15:37:38 +0100 Subject: [PATCH 09/29] Note about scale auto. --- src/Decimal.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Decimal.php b/src/Decimal.php index cb607c9..ccd21dc 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -62,7 +62,7 @@ class Decimal implements JsonSerializable, Stringable /** * @param object|string|float|int $value - * @param int|null $scale + * @param int|null $scale Leave empty to auto-detect. */ public function __construct(object|string|float|int $value, ?int $scale = null) { @@ -143,7 +143,7 @@ protected function normalizeValue(string $value): string * it. * * @param object|string|float|int $value - * @param int|null $scale + * @param int|null $scale Leave empty to auto-detect. * * @return static */ @@ -247,7 +247,7 @@ public function compareTo(self|string|float|int $value): int * Add $value to this Decimal and return the sum as a new Decimal. * * @param static|string|float|int $value - * @param int|null $scale + * @param int|null $scale Leave empty to auto-detect. * * @return static */ @@ -281,7 +281,7 @@ protected function resultScale(self $a, self $b, ?int $scale = null): int * Decimal. * * @param static|string|float|int $value - * @param int|null $scale + * @param int|null $scale Leave empty to auto-detect. * * @return static */ @@ -389,7 +389,7 @@ public function isPositive(): bool * Multiply this Decimal by $value and return the product as a new Decimal. * * @param static|string|float|int $value - * @param int|null $scale + * @param int|null $scale Leave empty to auto-detect. * * @return static */ @@ -427,7 +427,7 @@ public function divide(self|string|int|float $value, int $scale): static * This method is equivalent to the ** operator. * * @param static|string|int $exponent - * @param int|null $scale + * @param int|null $scale Leave empty to use current. * * @return static */ @@ -443,7 +443,7 @@ public function pow(self|string|int $exponent, ?int $scale = null): static /** * Returns the square root of this decimal, with the same scale as this decimal. * - * @param int|null $scale + * @param int|null $scale Leave empty to use current. * * @return static */ @@ -460,7 +460,7 @@ public function sqrt(?int $scale = null): static * This method is equivalent to the % operator. * * @param static|string|int $value - * @param int|null $scale + * @param int|null $scale Leave empty to use current. * * @return static */ From 0a5dd25b2bea0a2f1ee904fd62dc13e2aa513979 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Thu, 7 Dec 2023 15:39:57 +0100 Subject: [PATCH 10/29] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4228f43..518f735 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Decimal value object for PHP. ## Background -When working with monetary values, normal data types like int or float are not suitable for exact arithmetic. +When working with decimal (e.g. monetary) values, normal data types like int or float are not suitable for exact arithmetic. Try out the following in PHP: ```php var_dump(0.1 + 0.2); // float(0.3) From d6dbabb3b218ec060f8be81cdbb6c1c510760f55 Mon Sep 17 00:00:00 2001 From: Jens Hatlak Date: Wed, 20 Dec 2023 12:06:13 +0100 Subject: [PATCH 11/29] Document meaning of scale --- docs/README.md | 14 ++++++++++---- src/Decimal.php | 24 +++++++++++++----------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/docs/README.md b/docs/README.md index c067bae..b586b0a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,7 +36,7 @@ Transformation: These return a new object: - `absolute()`: Returns the absolute (positive) value of this decimal. - `negate()`: Returns the negation (positive if negative and vice versa). -- `trim()`: Remove trailing zeros after the comma (same value, but different semantic meaning in term of precision/scale). +- `trim()`: Remove trailing zeros after the comma (same value, but different semantic meaning in terms of precision/scale). There is only one static method and acts as a convenience wrapper to create an object: - `create()`: Internally does `new Decimal($value)`, allows for easier chaining without need of `()` wrapping. @@ -81,20 +81,26 @@ $decimalAdded = $decimalOne->add($decimalTwo); // Now '3.3' Note that due to immutability `$decimalOne` is not modified here. The re-assignment is necessary for the operation to persist. ### Precision/Scale -Operations like `add()` use the higher of the scales of the operands. +Operations like `add()` use the higher one of the scales of the operands. If both are the same, they would also stay the same. -With other operations like `multiply()`, they scale would be the addition of both operands' scale: +With other operations like `multiply()`, the scale would be the addition of both operands' scale by default: ```php $decimalOne = Decimal::create('1.55'); $decimalTwo = Decimal::create('2.00'); echo $decimalOne->multiply($decimalTwo); // Prints '3.1000' +``` + +To produce a result with a different number of decimal places, provide a value for the `$scale` parameter: +```php +$decimalOne = Decimal::create('1.55'); +$decimalTwo = Decimal::create('2.00'); -// Keeping 2 digit scale requires a 2nd argument echo $decimalOne->multiply($decimalTwo, 2); // Prints '3.10' ``` + ## Contributing You can contribute as pull request directly: diff --git a/src/Decimal.php b/src/Decimal.php index ccd21dc..c70492d 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -56,13 +56,15 @@ class Decimal implements JsonSerializable, Stringable protected bool $negative = false; /** + * Decimal places to be applied to results + * * decimal(10,6) => 6 */ protected int $scale = 0; /** * @param object|string|float|int $value - * @param int|null $scale Leave empty to auto-detect. + * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. */ public function __construct(object|string|float|int $value, ?int $scale = null) { @@ -143,7 +145,7 @@ protected function normalizeValue(string $value): string * it. * * @param object|string|float|int $value - * @param int|null $scale Leave empty to auto-detect. + * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static */ @@ -247,7 +249,7 @@ public function compareTo(self|string|float|int $value): int * Add $value to this Decimal and return the sum as a new Decimal. * * @param static|string|float|int $value - * @param int|null $scale Leave empty to auto-detect. + * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static */ @@ -281,7 +283,7 @@ protected function resultScale(self $a, self $b, ?int $scale = null): int * Decimal. * * @param static|string|float|int $value - * @param int|null $scale Leave empty to auto-detect. + * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static */ @@ -389,7 +391,7 @@ public function isPositive(): bool * Multiply this Decimal by $value and return the product as a new Decimal. * * @param static|string|float|int $value - * @param int|null $scale Leave empty to auto-detect. + * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static */ @@ -407,7 +409,7 @@ public function multiply(self|string|int|float $value, ?int $scale = null): stat * Divide this Decimal by $value and return the quotient as a new Decimal. * * @param static|string|float|int $value - * @param int $scale + * @param int $scale Decimal places in the result * * @throws \DivisionByZeroError if $value is zero. * @@ -427,7 +429,7 @@ public function divide(self|string|int|float $value, int $scale): static * This method is equivalent to the ** operator. * * @param static|string|int $exponent - * @param int|null $scale Leave empty to use current. + * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static */ @@ -443,7 +445,7 @@ public function pow(self|string|int $exponent, ?int $scale = null): static /** * Returns the square root of this decimal, with the same scale as this decimal. * - * @param int|null $scale Leave empty to use current. + * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static */ @@ -460,7 +462,7 @@ public function sqrt(?int $scale = null): static * This method is equivalent to the % operator. * * @param static|string|int $value - * @param int|null $scale Leave empty to use current. + * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static */ @@ -474,7 +476,7 @@ public function mod(self|string|int $value, ?int $scale = null): static } /** - * @param int $scale + * @param int $scale Decimal places in the result (only used with mode "half up") * @param int $roundMode * * @return static @@ -524,7 +526,7 @@ public function ceil(): static /** * The result of discarding all digits behind the defined scale. * - * @param int $scale + * @param int $scale Decimal places in the result * * @throws \InvalidArgumentException * From e6205ca4385d59f01b336fd816d4ae9b9fe36cb7 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Thu, 8 Feb 2024 23:07:47 +0100 Subject: [PATCH 12/29] codecov-update --- .github/workflows/ci.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a8a4e9..0f5a488 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: php-version: ['8.1', '8.2', '8.3'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -42,14 +42,16 @@ jobs: - name: Code Coverage Report if: success() && matrix.php-version == '8.1' - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} validation: name: Coding Standard & Static Analysis runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 From 1502edf2f1d9eae6426426229099602f2518006f Mon Sep 17 00:00:00 2001 From: mscherer Date: Thu, 17 Oct 2024 00:05:57 +0200 Subject: [PATCH 13/29] Check PHP84 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f5a488..40d5ead 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['8.1', '8.2', '8.3'] + php-version: ['8.1', '8.4'] steps: - uses: actions/checkout@v4 From 539fd1d722a153be4a17bc209f90eab620548703 Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 10 Mar 2025 02:36:21 +0100 Subject: [PATCH 14/29] Fix up PHPStan --- composer.json | 4 ++-- phpstan.neon | 3 ++- src/Decimal.php | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index f81d273..2bfe7d0 100644 --- a/composer.json +++ b/composer.json @@ -20,8 +20,8 @@ }, "require-dev": { "php-collective/code-sniffer": "^0.2.1", - "phpstan/phpstan": "^1.0.0", - "phpunit/phpunit": "^10.3.0" + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^11.0.0" }, "minimum-stability": "stable", "prefer-stable": true, diff --git a/phpstan.neon b/phpstan.neon index cdcfb1f..e77e10e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,6 +2,7 @@ parameters: level: 8 paths: - src/ - checkMissingIterableValueType: false ignoreErrors: + - identifier: missingType.iterableValue - '#Unsafe usage of new static\(\).#' + - '#expects numeric-string, string given.#' diff --git a/src/Decimal.php b/src/Decimal.php index c70492d..00ad492 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -200,7 +200,7 @@ public function lessThan(self|string|float|int $value): bool */ public function greaterThanOrEquals(self|string|float|int $value): bool { - return ($this->compareTo($value) >= 0); + return $this->compareTo($value) >= 0; } /** @@ -222,7 +222,7 @@ public function greatherThanOrEquals(self|string|float|int $value): bool */ public function lessThanOrEquals(self|string|float|int $value): bool { - return ($this->compareTo($value) <= 0); + return $this->compareTo($value) <= 0; } /** From a9421cd48b1be82e1217a5bfc8f38b05a36b9eb4 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Mon, 10 Mar 2025 12:32:13 +0100 Subject: [PATCH 15/29] Fix up rounding for small values. (#18) * Fix up rounding for small values. * Fix up rounding for small values. --- composer.json | 2 +- phpstan.neon | 2 +- src/Decimal.php | 34 ++++++++++++++++++++++++++++------ tests/DecimalTest.php | 28 +++++++++++++++++++++++++--- 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/composer.json b/composer.json index 2bfe7d0..c168dc2 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "require-dev": { "php-collective/code-sniffer": "^0.2.1", "phpstan/phpstan": "^2.0.0", - "phpunit/phpunit": "^11.0.0" + "phpunit/phpunit": "^10.5.45 || ^11.0.0" }, "minimum-stability": "stable", "prefer-stable": true, diff --git a/phpstan.neon b/phpstan.neon index e77e10e..aa161e6 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -5,4 +5,4 @@ parameters: ignoreErrors: - identifier: missingType.iterableValue - '#Unsafe usage of new static\(\).#' - - '#expects numeric-string, string given.#' + - '#expects numeric-string, .*string given.#' diff --git a/src/Decimal.php b/src/Decimal.php index 00ad492..ac9b2d5 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -485,22 +485,44 @@ public function round(int $scale = 0, int $roundMode = self::ROUND_HALF_UP): sta { $exponent = $scale + 1; - $e = bcpow('10', (string)$exponent); switch ($roundMode) { case static::ROUND_FLOOR: - $v = bcdiv(bcadd(bcmul((string)$this, $e, 0), $this->isNegative() ? '-9' : '0'), $e, 0); + $stringValue = (string)$this; - break; + // If already an integer, return as is + if (!str_contains($stringValue, '.')) { + return new static($stringValue, $scale); + } + + // For positive values, truncate the decimal part (round down) + if (!$this->isNegative()) { + return new static(bcsub($stringValue, bcmod($stringValue, '1'), 0), $scale); + } + + // For negative values, round down away from zero + return new static(bcsub($stringValue, '1', 0), $scale); case static::ROUND_CEIL: - $v = bcdiv(bcadd(bcmul((string)$this, $e, 0), $this->isNegative() ? '0' : '9'), $e, 0); + $stringValue = (string)$this; + + // If already an integer, return as is + if (!str_contains($stringValue, '.')) { + return new static($stringValue, $scale); + } + + // If negative, truncate (remove decimals without adding 1) + if ($this->isNegative()) { + return new static(bcsub($stringValue, '0', 0), $scale); + } - break; + // Otherwise, round up for positive numbers + return new static(bcadd($stringValue, '1', 0), $scale); case static::ROUND_HALF_UP: default: + $e = bcpow('10', (string)$exponent); $v = bcdiv(bcadd(bcmul((string)$this, $e, 0), $this->isNegative() ? '-5' : '5'), $e, $scale); } - return new static($v); + return new static($v, $scale); } /** diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index 8da6e9f..106a109 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -636,7 +636,12 @@ public function testRound(mixed $value, int $scale, string $expected): void */ protected function assertNativeRound(string $expected, mixed $value, int $scale, int $roundMode): void { - $this->assertSame((new Decimal($expected))->trim()->toString(), (string)round((float)$value, $scale, $roundMode)); + $value = (string)round((float)$value, $scale, $roundMode); + if ($value === '-0') { + $value = '0'; + } + + $this->assertSame((new Decimal($expected))->trim()->toString(), $value); } /** @@ -659,6 +664,10 @@ public static function roundProvider(): array [13.4999, 0, '13'], [13.4999, 10, '13.4999000000'], [13.4999, 2, '13.50'], + [0.0001, 0, '0'], + [-0.0001, 0, '0'], + [0.9999, 0, '1'], + [-0.9999, 0, '-1'], ]; } @@ -685,7 +694,12 @@ public function testFloor(mixed $value, string $expected): void */ protected function assertNativeFloor(string $expected, mixed $value): void { - $this->assertSame($expected, (string)floor((float)$value)); + $value = (string)floor((float)$value); + if ($value === '-0') { + $value = '0'; + } + + $this->assertSame($expected, $value); } /** @@ -706,6 +720,8 @@ public static function floorProvider(): array ['13.6999', '13'], ['13.1', '13'], ['13.9', '13'], + [0.0001, '0'], + [-0.0001, '-1'], ]; } @@ -732,7 +748,11 @@ public function testCeil(mixed $value, string $expected): void */ protected function assertNativeCeil(string $expected, mixed $value): void { - $this->assertSame($expected, (string)ceil((float)$value)); + $value = (string)ceil((float)$value); + if ($value === '-0') { + $value = '0'; + } + $this->assertSame($expected, $value); } /** @@ -753,6 +773,8 @@ public static function ceilProvider(): array ['13.6999', '14'], ['13.1', '14'], ['13.9', '14'], + [0.0001, '1'], + [-0.0001, '0'], ]; } From fce3d887d6da43e15a83119ec566b13bac16e04f Mon Sep 17 00:00:00 2001 From: mscherer Date: Fri, 4 Apr 2025 00:00:36 +0200 Subject: [PATCH 16/29] Only require strict check as opt-in --- src/Decimal.php | 17 ++++++++++------- tests/DecimalTest.php | 10 +++++----- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/Decimal.php b/src/Decimal.php index ac9b2d5..2714d9a 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -65,14 +65,15 @@ class Decimal implements JsonSerializable, Stringable /** * @param object|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. + * @param bool $strict If scale should be strictly checked to avoid accidental precision loss. */ - public function __construct(object|string|float|int $value, ?int $scale = null) + public function __construct(object|string|float|int $value, ?int $scale = null, bool $strict = false) { $value = $this->parseValue($value); $value = $this->normalizeValue($value); $this->setValue($value, $scale); - $this->setScale($scale); + $this->setScale($scale, $strict); } /** @@ -146,16 +147,17 @@ protected function normalizeValue(string $value): string * * @param object|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. + * @param bool $strict If scale should be strictly checked to avoid accidental precision loss. * * @return static */ - public static function create(object|string|float|int $value, ?int $scale = null): static + public static function create(object|string|float|int $value, ?int $scale = null, bool $strict = false): static { if ($scale === null && $value instanceof static) { return clone $value; } - return new static($value, $scale); + return new static($value, $scale, $strict); } /** @@ -737,7 +739,7 @@ protected function copy(?string $integerPart = null, ?string $decimalPart = null } if ($decimalPart !== null) { $clone->fractionalPart = $decimalPart; - $clone->setScale(null); + $clone->setScale(null, false); } if ($negative !== null) { $clone->negative = $negative; @@ -860,15 +862,16 @@ protected function fromScientific(string $value, ?int $scale): void /** * @param int|null $scale + * @param bool $strict * * @throws \InvalidArgumentException * * @return void */ - protected function setScale(?int $scale): void + protected function setScale(?int $scale, bool $strict): void { $calculatedScale = strlen($this->fractionalPart); - if ($scale && $calculatedScale > $scale) { + if ($strict && $scale && $calculatedScale > $scale) { throw new InvalidArgumentException('Loss of precision detected. Detected scale `' . $calculatedScale . '` > `' . $scale . '` as defined.'); } diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index 106a109..59a7e7d 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -1036,16 +1036,16 @@ public function testDebugInfo(): void */ public function testPrecisionLossProtection(): void { - $a = Decimal::create('0.1', 50); + $a = Decimal::create('0.1', 50, true); $this->assertSame(50, $a->scale()); - $b = Decimal::create($a); + $b = Decimal::create($a, null, true); $this->assertSame(50, $b->scale()); - $c = Decimal::create($b, 6); // Not 50 if manually overwritten + $c = Decimal::create($b, 6, true); // Not 50 if manually overwritten $this->assertSame(6, $c->scale()); - $d = Decimal::create($c, 64); + $d = Decimal::create($c, 64, true); $this->assertSame(64, $d->scale()); } @@ -1057,6 +1057,6 @@ public function testPrecisionLossFail(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Loss of precision detected'); - Decimal::create('0.123', 2); + Decimal::create('0.123', 2, true); } } From 2b26490b7c047d136f302fee59d187e097a81ece Mon Sep 17 00:00:00 2001 From: mscherer Date: Fri, 4 Apr 2025 00:28:46 +0200 Subject: [PATCH 17/29] Fix up precision loss for defined smaller scale. --- src/Decimal.php | 8 ++++++-- tests/DecimalTest.php | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Decimal.php b/src/Decimal.php index 2714d9a..8f005ad 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -871,8 +871,12 @@ protected function fromScientific(string $value, ?int $scale): void protected function setScale(?int $scale, bool $strict): void { $calculatedScale = strlen($this->fractionalPart); - if ($strict && $scale && $calculatedScale > $scale) { - throw new InvalidArgumentException('Loss of precision detected. Detected scale `' . $calculatedScale . '` > `' . $scale . '` as defined.'); + if ($scale && $calculatedScale > $scale) { + if ($strict) { + throw new InvalidArgumentException('Loss of precision detected. Detected scale `' . $calculatedScale . '` > `' . $scale . '` as defined.'); + } + + $this->fractionalPart = substr($this->fractionalPart, 0, $scale); } $this->scale = $scale ?? $calculatedScale; diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index 59a7e7d..864678c 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -1031,6 +1031,18 @@ public function testDebugInfo(): void $this->assertEquals($expected, $result); } + /** + * @return void + */ + public function testPrecisionLoss(): void + { + $a = Decimal::create('0.123456789', 10); + $this->assertSame('0.123456789', (string)$a); + + $b = Decimal::create((string)$a, 8); + $this->assertSame('0.12345678', (string)$b); + } + /** * @return void */ From 0c5f020a376206b008ffaf8d243d95ed8cd56969 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 8 Mar 2026 18:10:19 +0100 Subject: [PATCH 18/29] Add Renovate configuration --- renovate.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..e69de29 From f264333248507495878e3c70631a0f0a4757ec47 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 8 Mar 2026 18:12:45 +0100 Subject: [PATCH 19/29] Fix Renovate configuration --- renovate.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/renovate.json b/renovate.json index e69de29..9a9e6f3 100644 --- a/renovate.json +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "github>php-collective/.github" + ] +} From 49612bb90d55bb2567ad5bbf167cb781dc634cd0 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Mon, 14 Sep 2026 13:48:46 +0200 Subject: [PATCH 20/29] Allow PHPUnit 12 and 13 (#19) --- .github/workflows/ci.yml | 4 +-- composer.json | 2 +- phpunit.xml.dist | 1 - tests/DecimalTest.php | 66 ++++++++++++++-------------------------- 4 files changed, 24 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40d5ead..191fdc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['8.1', '8.4'] + php-version: ['8.1', '8.3', '8.5'] steps: - uses: actions/checkout@v4 @@ -26,13 +26,11 @@ jobs: coverage: pcov - name: Composer install - continue-on-error: ${{ matrix.php-version == '8.3' }} run: | composer --version composer install - name: Run PHPUnit - continue-on-error: ${{ matrix.php-version == '8.3' }} run: | if [[ ${{ matrix.php-version }} == '8.1' ]]; then vendor/bin/phpunit --coverage-clover=coverage.xml diff --git a/composer.json b/composer.json index c168dc2..ae80adf 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "require-dev": { "php-collective/code-sniffer": "^0.2.1", "phpstan/phpstan": "^2.0.0", - "phpunit/phpunit": "^10.5.45 || ^11.0.0" + "phpunit/phpunit": "^10.5.45 || ^11.0.0 || ^12.0.0 || ^13.0.0" }, "minimum-stability": "stable", "prefer-stable": true, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 3542715..ef3d67a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -5,7 +5,6 @@ > - diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index 864678c..7ba553f 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -19,13 +19,12 @@ class DecimalTest extends TestCase { /** - * @dataProvider baseProvider - * * @param mixed $value * @param string $expected * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('baseProvider')] public function testNewObject(mixed $value, string $expected): void { $decimal = new Decimal($value); @@ -46,13 +45,12 @@ public function testNewObjectScientific(): void } /** - * @dataProvider baseProvider - * * @param mixed $value * @param string $expected * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('baseProvider')] public function testCreate(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -140,12 +138,11 @@ public function __toString(): string } /** - * @dataProvider invalidValuesProvider - * * @param mixed $value * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('invalidValuesProvider')] public function testNewObjectWithInvalidValueThrowsException(mixed $value): void { $this->expectException(InvalidArgumentException::class); @@ -166,14 +163,13 @@ public static function invalidValuesProvider(): array } /** - * @dataProvider truncateProvider - * * @param mixed $input * @param int $scale * @param string $expected * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('truncateProvider')] public function testTruncate(mixed $input, int $scale, string $expected): void { $decimal = Decimal::create($input); @@ -196,13 +192,12 @@ public static function truncateProvider(): array } /** - * @dataProvider integerProvider - * * @param mixed $value * @param bool $expected * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('integerProvider')] public function testIsInteger(mixed $value, bool $expected): void { $decimal = Decimal::create($value); @@ -229,13 +224,12 @@ public static function integerProvider(): array } /** - * @dataProvider zeroProvider - * * @param mixed $value * @param bool $expected * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('zeroProvider')] public function testIsZero(mixed $value, bool $expected): void { $decimal = Decimal::create($value); @@ -261,13 +255,12 @@ public static function zeroProvider(): array } /** - * @dataProvider compareZeroProvider - * * @param mixed $input * @param int $expected * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('compareZeroProvider')] public function testIsPositive(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -275,13 +268,12 @@ public function testIsPositive(mixed $input, int $expected): void } /** - * @dataProvider compareZeroProvider - * * @param mixed $input * @param int $expected * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('compareZeroProvider')] public function testIsNegative(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -310,13 +302,12 @@ public static function compareZeroProvider(): array } /** - * @dataProvider scaleProvider - * * @param mixed $input * @param int $expected * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('scaleProvider')] public function testScale(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -342,13 +333,12 @@ public static function scaleProvider(): array } /** - * @dataProvider scientificProvider - * * @param mixed $value * @param string $expected * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('scientificProvider')] public function testToScientific(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -417,12 +407,11 @@ public function testToFloat(): void } /** - * @dataProvider bigFloatDataProvider - * * @param string $value * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('bigFloatDataProvider')] public function testToFloatForBigDecimalThrowsAnException(string $value): void { $decimal = Decimal::create($value); @@ -457,12 +446,11 @@ public function testToInt(): void } /** - * @dataProvider bigIntDataProvider - * * @param string $value * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('bigIntDataProvider')] public function testToIntForBigIntThrowsAnException(string $value): void { $decimal = Decimal::create($value); @@ -611,14 +599,13 @@ public function testEquals(): void } /** - * @dataProvider roundProvider - * * @param mixed $value * @param int $scale * @param string $expected * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('roundProvider')] public function testRound(mixed $value, int $scale, string $expected): void { $decimal = Decimal::create($value); @@ -672,13 +659,12 @@ public static function roundProvider(): array } /** - * @dataProvider floorProvider - * * @param mixed $value * @param string $expected * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('floorProvider')] public function testFloor(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -726,13 +712,12 @@ public static function floorProvider(): array } /** - * @dataProvider ceilProvider - * * @param mixed $value * @param string $expected * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('ceilProvider')] public function testCeil(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -779,14 +764,13 @@ public static function ceilProvider(): array } /** - * @dataProvider compareProvider - * * @param mixed $a * @param mixed $b * @param int $expected * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] public function testGreaterThan(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -794,14 +778,13 @@ public function testGreaterThan(mixed $a, mixed $b, int $expected): void } /** - * @dataProvider compareProvider - * * @param mixed $a * @param mixed $b * @param int $expected * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] public function testLessThan(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -809,14 +792,13 @@ public function testLessThan(mixed $a, mixed $b, int $expected): void } /** - * @dataProvider compareProvider - * * @param mixed $a * @param mixed $b * @param int $expected * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] public function testGreaterEquals(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -824,14 +806,13 @@ public function testGreaterEquals(mixed $a, mixed $b, int $expected): void } /** - * @dataProvider compareProvider - * * @param mixed $a * @param mixed $b * @param int $expected * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] public function testLessEquals(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -888,8 +869,6 @@ public function testSubtract(): void } /** - * @dataProvider multiplicationProvider - * * @param mixed $a * @param mixed $b * @param int|null $scale @@ -897,6 +876,7 @@ public function testSubtract(): void * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('multiplicationProvider')] public function testMultiply(mixed $a, mixed $b, ?int $scale, string $expected): void { $decimal = Decimal::create($a); @@ -928,8 +908,6 @@ public static function multiplicationProvider(): array } /** - * @dataProvider multiplicationLegacyProvider - * * @param mixed $a * @param mixed $b * @param int|null $scale @@ -937,6 +915,7 @@ public static function multiplicationProvider(): array * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('multiplicationLegacyProvider')] public function testMultiplyLegacy(mixed $a, mixed $b, ?int $scale, string $expected): void { $decimal = Decimal::create($a); @@ -954,8 +933,6 @@ public static function multiplicationLegacyProvider(): array } /** - * @dataProvider divisionProvider - * * @param mixed $a * @param mixed $b * @param int $scale @@ -963,6 +940,7 @@ public static function multiplicationLegacyProvider(): array * * @return void */ + #[\PHPUnit\Framework\Attributes\DataProvider('divisionProvider')] public function testDivide(mixed $a, mixed $b, int $scale, string $expected): void { $decimal = Decimal::create($a); From d8fc592e2c29dd50bafac79f9a6f9b9deca8243d Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 16:26:54 +0200 Subject: [PATCH 21/29] Fix parsing, negative zero, rounding and scientific notation edge cases Parsing - Leading zeros survived in several forms: "0010", "-007", "+007" and "-00" kept them, and "+.5" lost its leading zero. - Scientific notation was wrong for any mantissa with more than one integer digit and a negative exponent ("12.5e-1" became 0.125, "100e-2" became 0.0100) and for mantissas below one with a positive exponent (".5e1" became 5.5, "0.00012e3" stayed 0.00012). - An explicit plus in the exponent ("1e+5") was rejected. PHP renders large floats that way, so new Decimal(1e25) threw. - "1.0e0" dropped the written zero. A trailing ".0" is now only removed from the string PHP produces for a float. - INF and NAN were accepted and stored as the strings "INF" and "NAN". - A scale of 0 was treated as "no scale": new Decimal('1.5', 0) kept the fraction and strict mode did not throw. Negative scales were accepted. Negative zero - "-0.0", negate() on zero, truncate() and trim() of small negative values produced "-0" and reported isNegative() as true while sign() returned 0. Zero now never carries a sign. Rounding - floor() and ceil() moved whole numbers with a fraction of zeros: floor('-1.0') returned -2 and ceil('1.0') returned 2. - ROUND_FLOOR and ROUND_CEIL ignored the requested scale. - An unknown round mode silently fell back to half up. It now throws, as does a negative scale. Conversion - toScientific() returned ".e-1" for zero, went through float division and so lost digits beyond 15 for large integers, and returned "1.250e2" for 120.50. - toInt() refused PHP_INT_MIN. Operands - mod() and pow() passed the operand to bcmath unparsed, so values in scientific notation failed. mod() now defaults to the larger scale of both operands, like add(); 10 mod 0.3 returned 0 before. --- src/Decimal.php | 202 +++++++++++++++---------------- tests/DecimalTest.php | 276 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 372 insertions(+), 106 deletions(-) diff --git a/src/Decimal.php b/src/Decimal.php index 8f005ad..ec10482 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -74,6 +74,7 @@ public function __construct(object|string|float|int $value, ?int $scale = null, $this->setValue($value, $scale); $this->setScale($scale, $strict); + $this->normalizeZero(); } /** @@ -105,11 +106,16 @@ protected function parseValue(object|string|float|int $value): string throw new InvalidArgumentException('Invalid non numeric value'); } - if (!is_string($value)) { - $value = (string)$value; + if (is_float($value)) { + if (!is_finite($value)) { + throw new InvalidArgumentException('Invalid non finite value'); + } + + // PHP renders large and small floats as `1.0E+25`, that zero is not a significant digit. + return str_replace('.0E', 'E', (string)$value); } - return $value; + return (string)$value; } /** @@ -120,22 +126,13 @@ protected function parseValue(object|string|float|int $value): string protected function normalizeValue(string $value): string { $value = trim($value); - /** @var string $value */ - $value = preg_replace( - [ - '/^^(-?)(\.)(.*)$/', // omitted leading zero - '/^0+(.)(\..*)?$/', // multiple leading zeros - '/^(\+(.*)|(-)(0))$/', // leading positive sign, tolerate minus zero too - ], - [ - '${1}0.${3}', - '${1}${2}', - '${4}${2}', - ], - $value, - ); + $sign = str_starts_with($value, '-') ? '-' : ''; + $value = ltrim(ltrim($value, '+-'), '0'); + if ($value === '' || !ctype_digit($value[0])) { + $value = '0' . $value; + } - return $value; + return $sign . $value; } /** @@ -441,7 +438,7 @@ public function pow(self|string|int $exponent, ?int $scale = null): static $scale = $this->scale(); } - return new static(bcpow((string)$this, (string)$exponent, $scale)); + return new static(bcpow((string)$this, (string)static::create($exponent), $scale)); } /** @@ -470,61 +467,52 @@ public function sqrt(?int $scale = null): static */ public function mod(self|string|int $value, ?int $scale = null): static { - if ($scale === null) { - $scale = $this->scale(); - } + $decimal = static::create($value); + $scale = $this->resultScale($this, $decimal, $scale); - return new static(bcmod((string)$this, (string)$value, $scale)); + return new static(bcmod((string)$this, (string)$decimal, $scale)); } /** - * @param int $scale Decimal places in the result (only used with mode "half up") - * @param int $roundMode + * @param int $scale Decimal places in the result + * @param int $roundMode One of the ROUND_* constants of this class + * + * @throws \InvalidArgumentException * * @return static */ public function round(int $scale = 0, int $roundMode = self::ROUND_HALF_UP): static { - $exponent = $scale + 1; + if ($scale < 0) { + throw new InvalidArgumentException('Scale must be >= 0.'); + } + if (!in_array($roundMode, [static::ROUND_HALF_UP, static::ROUND_CEIL, static::ROUND_FLOOR], true)) { + throw new InvalidArgumentException('Unsupported round mode `' . $roundMode . '`.'); + } - switch ($roundMode) { - case static::ROUND_FLOOR: - $stringValue = (string)$this; + $value = (string)$this; + $truncated = bcadd($value, '0', $scale); + if (bccomp($value, $truncated, $this->scale()) === 0) { + return new static($truncated, $scale); + } - // If already an integer, return as is - if (!str_contains($stringValue, '.')) { - return new static($stringValue, $scale); - } + $unit = $scale > 0 ? '0.' . str_repeat('0', $scale - 1) . '1' : '1'; - // For positive values, truncate the decimal part (round down) - if (!$this->isNegative()) { - return new static(bcsub($stringValue, bcmod($stringValue, '1'), 0), $scale); - } + switch ($roundMode) { + case static::ROUND_FLOOR: + $result = $this->isNegative() ? bcsub($truncated, $unit, $scale) : $truncated; - // For negative values, round down away from zero - return new static(bcsub($stringValue, '1', 0), $scale); + break; case static::ROUND_CEIL: - $stringValue = (string)$this; - - // If already an integer, return as is - if (!str_contains($stringValue, '.')) { - return new static($stringValue, $scale); - } - - // If negative, truncate (remove decimals without adding 1) - if ($this->isNegative()) { - return new static(bcsub($stringValue, '0', 0), $scale); - } + $result = $this->isNegative() ? $truncated : bcadd($truncated, $unit, $scale); - // Otherwise, round up for positive numbers - return new static(bcadd($stringValue, '1', 0), $scale); - case static::ROUND_HALF_UP: + break; default: - $e = bcpow('10', (string)$exponent); - $v = bcdiv(bcadd(bcmul((string)$this, $e, 0), $this->isNegative() ? '-5' : '5'), $e, $scale); + $e = bcpow('10', (string)($scale + 1)); + $result = bcdiv(bcadd(bcmul($value, $e, 0), $this->isNegative() ? '-5' : '5'), $e, $scale); } - return new static($v, $scale); + return new static($result, $scale); } /** @@ -610,7 +598,9 @@ public function toInt(): int */ public function isBigInteger(): bool { - return bccomp($this->integralPart, (string)PHP_INT_MAX) === 1 || bccomp($this->integralPart, (string)PHP_INT_MIN) === -1; + $value = ($this->negative ? '-' : '') . $this->integralPart; + + return bccomp($value, (string)PHP_INT_MAX) === 1 || bccomp($value, (string)PHP_INT_MIN) === -1; } /** @@ -618,8 +608,7 @@ public function isBigInteger(): bool */ public function isBigDecimal(): bool { - return $this->isBigInteger() || - bccomp($this->fractionalPart, (string)PHP_INT_MAX) === 1 || bccomp($this->fractionalPart, (string)PHP_INT_MIN) === -1; + return $this->isBigInteger() || bccomp($this->fractionalPart ?: '0', (string)PHP_INT_MAX) === 1; } /** @@ -635,38 +624,31 @@ public function isBigDecimal(): bool */ public function toScientific(): string { - if ($this->integralPart) { - $exponent = 0; - $integralPart = $this->integralPart; - while ($integralPart >= 10) { - $integralPart /= 10; - $exponent++; - } + if ($this->isZero()) { + return $this->toString() . static::EXP_MARK . '0'; + } - $value = (string)$integralPart; - if (!str_contains($value, '.')) { - $value .= '.'; + if ($this->integralPart !== '0') { + $exponent = strlen($this->integralPart) - 1; + $digits = $this->integralPart . $this->fractionalPart; + if ($this->fractionalPart === '') { + $digits = rtrim($digits, '0'); } - $value .= $this->fractionalPart; } else { - $exponent = -1; - // 00002 - // 20000 - $fractionalPart = $this->fractionalPart; - while (str_starts_with($fractionalPart, '0')) { - $fractionalPart = substr($fractionalPart, 1); - $exponent--; - } + $digits = ltrim($this->fractionalPart, '0'); + $exponent = strlen($digits) - strlen($this->fractionalPart) - 1; + } - $pos = abs($exponent) - 1; - $value = substr($this->fractionalPart, $pos, 1) . '.' . substr($this->fractionalPart, $pos + 1); + $value = $digits[0]; + if (strlen($digits) > 1) { + $value .= static::RADIX_MARK . substr($digits, 1); } if ($this->negative) { $value = '-' . $value; } - return $value . 'e' . $exponent; + return $value . static::EXP_MARK . $exponent; } /** @@ -744,6 +726,7 @@ protected function copy(?string $integerPart = null, ?string $decimalPart = null if ($negative !== null) { $clone->negative = $negative; } + $clone->normalizeZero(); return $clone; } @@ -824,39 +807,30 @@ protected function fromFloat(string $value): void */ protected function fromScientific(string $value, ?int $scale): void { - $pattern = '/^(-?)(\d+(?:' . static::RADIX_MARK . '\d*)?|' . - '[' . static::RADIX_MARK . ']' . '\d+)' . static::EXP_MARK . '(-?\d*)?$/i'; - preg_match($pattern, $value, $matches); - if (!$matches) { + $pattern = '/^(-?)(\d+)(?:' . preg_quote(static::RADIX_MARK, '/') . '(\d*))?' . static::EXP_MARK . '([+-]?\d+)$/i'; + if (preg_match($pattern, $value, $matches) !== 1) { throw new InvalidArgumentException('Invalid scientific value/notation: ' . $value); } $this->negative = $matches[1] === '-'; - /** @var string $value */ - $value = preg_replace('/\b\.0$/', '', $matches[2]); - $exp = (int)$matches[3]; + $integralPart = $matches[2]; + $fractionalPart = $matches[3]; + $exp = (int)$matches[4]; if ($exp < 0) { - $this->integralPart = '0'; - /** @var string $value */ - $value = preg_replace('/^(\d+)(\.)?(\d+)$/', '${1}${3}', $value, 1); - $this->fractionalPart = str_repeat('0', -$exp - 1) . $value; - - if ($scale !== null) { - $this->fractionalPart = str_pad($this->fractionalPart, $scale, '0'); - } + $integralPart = str_pad($integralPart, -$exp + 1, '0', STR_PAD_LEFT); + $fractionalPart = substr($integralPart, $exp) . $fractionalPart; + $integralPart = substr($integralPart, 0, $exp); } else { - $this->integralPart = bcmul($matches[2], bcpow('10', (string)$exp)); + $integralPart .= substr(str_pad($fractionalPart, $exp, '0'), 0, $exp); + $fractionalPart = substr($fractionalPart, $exp); + } - $pos = strlen($this->integralPart); - if (str_contains($value, '.')) { - $pos++; - } - $this->fractionalPart = rtrim(substr($value, $pos), '.'); + $this->integralPart = ltrim($integralPart, '0') ?: '0'; + $this->fractionalPart = $fractionalPart; - if ($scale !== null) { - $this->fractionalPart = str_pad($this->fractionalPart, $scale - strlen($this->integralPart), '0'); - } + if ($scale !== null) { + $this->fractionalPart = str_pad($this->fractionalPart, $scale, '0'); } } @@ -870,8 +844,12 @@ protected function fromScientific(string $value, ?int $scale): void */ protected function setScale(?int $scale, bool $strict): void { + if ($scale !== null && $scale < 0) { + throw new InvalidArgumentException('Scale must be >= 0.'); + } + $calculatedScale = strlen($this->fractionalPart); - if ($scale && $calculatedScale > $scale) { + if ($scale !== null && $calculatedScale > $scale) { if ($strict) { throw new InvalidArgumentException('Loss of precision detected. Detected scale `' . $calculatedScale . '` > `' . $scale . '` as defined.'); } @@ -881,4 +859,16 @@ protected function setScale(?int $scale, bool $strict): void $this->scale = $scale ?? $calculatedScale; } + + /** + * Zero has no sign, `-0.00` would otherwise report as negative. + * + * @return void + */ + protected function normalizeZero(): void + { + if ($this->negative && $this->isZero()) { + $this->negative = false; + } + } } diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index 7ba553f..e235cde 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -1049,4 +1049,280 @@ public function testPrecisionLossFail(): void Decimal::create('0.123', 2, true); } + + /** + * @param mixed $value + * @param string $expected + * + * @return void + */ + #[\PHPUnit\Framework\Attributes\DataProvider('normalizationProvider')] + public function testNewObjectNormalization(mixed $value, string $expected): void + { + $this->assertSame($expected, (string)new Decimal($value)); + } + + /** + * @return array + */ + public static function normalizationProvider(): array + { + return [ + 'leading zeros' => ['0010', '10'], + 'negative leading zeros' => ['-007', '-7'], + 'positive sign leading zeros' => ['+007', '7'], + 'positive sign omitted zero' => ['+.5', '0.5'], + 'negative zero with decimals' => ['-0.0', '0.0'], + 'negative double zero' => ['-00', '0'], + 'trailing radix' => ['5.', '5'], + 'explicit positive exponent' => ['1e+5', '100000'], + 'uppercase exponent' => ['1E5', '100000'], + 'multi digit mantissa' => ['12.5e-1', '1.25'], + 'integer mantissa' => ['100e-2', '1.00'], + 'integer mantissa trailing zero' => ['120e-1', '12.0'], + 'long mantissa' => ['123.456e-2', '1.23456'], + 'negative mantissa' => ['-1.5e-2', '-0.015'], + 'omitted zero mantissa' => ['.5e1', '5'], + 'small mantissa' => ['0.00012e3', '0.12'], + 'significant zero in mantissa' => ['1.0e0', '1.0'], + 'zero exponent' => ['1.5e0', '1.5'], + 'negative zero exponent' => ['-0e5', '0'], + 'zero mantissa' => ['0.0e-3', '0.0000'], + 'large float' => [1e25, '10000000000000000000000000'], + 'small float' => [1.5e-7, '0.00000015'], + 'negative zero float' => [-0.0, '0'], + ]; + } + + /** + * @param float $value + * + * @return void + */ + #[\PHPUnit\Framework\Attributes\DataProvider('nonFiniteProvider')] + public function testNewObjectNonFiniteFloat(float $value): void + { + $this->expectException(InvalidArgumentException::class); + + new Decimal($value); + } + + /** + * @return array + */ + public static function nonFiniteProvider(): array + { + return [ + 'inf' => [INF], + 'negative inf' => [-INF], + 'nan' => [NAN], + ]; + } + + /** + * @return void + */ + public function testNewObjectZeroScale(): void + { + $decimal = new Decimal('1.56', 0); + $this->assertSame('1', (string)$decimal); + $this->assertSame(0, $decimal->scale()); + } + + /** + * @return void + */ + public function testNewObjectZeroScaleStrict(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Loss of precision detected'); + + new Decimal('1.56', 0, true); + } + + /** + * @return void + */ + public function testNewObjectNegativeScale(): void + { + $this->expectException(InvalidArgumentException::class); + + new Decimal('1.56', -1); + } + + /** + * @return void + */ + public function testNewObjectScientificWithScale(): void + { + $this->assertSame('1.500', (string)new Decimal('1.5e0', 3)); + $this->assertSame('0.001200', (string)new Decimal('1.2e-3', 6)); + $this->assertSame('0.0012', (string)new Decimal('1.23456e-3', 4)); + } + + /** + * @return void + */ + public function testNoNegativeZero(): void + { + $decimal = new Decimal('-0.0'); + $this->assertFalse($decimal->isNegative()); + $this->assertSame(0, $decimal->sign()); + + $this->assertSame('0', (string)(new Decimal('0'))->negate()); + $this->assertSame('0.00', (string)(new Decimal('0.00'))->negate()); + $this->assertSame('0', (string)(new Decimal('-0.5'))->truncate()); + $this->assertSame('0.0', (string)(new Decimal('-0.05'))->truncate(1)); + $this->assertSame('0.00', (string)new Decimal('-0.001', 2)); + $this->assertFalse((new Decimal('-0.5'))->truncate()->isNegative()); + } + + /** + * @param string $value + * @param int $scale + * @param int $mode + * @param string $expected + * + * @return void + */ + #[\PHPUnit\Framework\Attributes\DataProvider('roundModeProvider')] + public function testRoundMode(string $value, int $scale, int $mode, string $expected): void + { + $this->assertSame($expected, (string)(new Decimal($value))->round($scale, $mode)); + } + + /** + * @return array + */ + public static function roundModeProvider(): array + { + return [ + 'floor negative whole' => ['-1.0', 0, Decimal::ROUND_FLOOR, '-1'], + 'floor positive whole' => ['1.00', 0, Decimal::ROUND_FLOOR, '1'], + 'floor negative' => ['-1.5', 0, Decimal::ROUND_FLOOR, '-2'], + 'floor scale' => ['1.25', 1, Decimal::ROUND_FLOOR, '1.2'], + 'floor negative scale' => ['-1.21', 1, Decimal::ROUND_FLOOR, '-1.3'], + 'floor exact scale' => ['-1.20', 1, Decimal::ROUND_FLOOR, '-1.2'], + 'floor higher scale' => ['1.5', 3, Decimal::ROUND_FLOOR, '1.500'], + 'floor small negative' => ['-0.001', 2, Decimal::ROUND_FLOOR, '-0.01'], + 'ceil positive whole' => ['1.0', 0, Decimal::ROUND_CEIL, '1'], + 'ceil negative whole' => ['-1.00', 0, Decimal::ROUND_CEIL, '-1'], + 'ceil positive' => ['1.5', 0, Decimal::ROUND_CEIL, '2'], + 'ceil scale' => ['1.21', 1, Decimal::ROUND_CEIL, '1.3'], + 'ceil negative scale' => ['-1.29', 1, Decimal::ROUND_CEIL, '-1.2'], + 'ceil exact scale' => ['1.20', 1, Decimal::ROUND_CEIL, '1.2'], + 'ceil small negative' => ['-0.001', 2, Decimal::ROUND_CEIL, '0.00'], + 'ceil small positive' => ['0.001', 2, Decimal::ROUND_CEIL, '0.01'], + 'half up negative' => ['-1.005', 2, Decimal::ROUND_HALF_UP, '-1.01'], + 'half up to zero' => ['-0.004', 2, Decimal::ROUND_HALF_UP, '0.00'], + ]; + } + + /** + * @return void + */ + public function testRoundNegativeScale(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('15'))->round(-1); + } + + /** + * @param string $value + * + * @return void + */ + #[\PHPUnit\Framework\Attributes\DataProvider('roundInvalidModeProvider')] + public function testRoundInvalidMode(string $value): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal($value))->round(0, PHP_ROUND_HALF_EVEN); + } + + /** + * @return array + */ + public static function roundInvalidModeProvider(): array + { + return [ + 'needs rounding' => ['1.5'], + 'exact' => ['1.0'], + ]; + } + + /** + * @param string $value + * @param string $expected + * + * @return void + */ + #[\PHPUnit\Framework\Attributes\DataProvider('scientificEdgeProvider')] + public function testToScientificEdgeCases(string $value, string $expected): void + { + $decimal = new Decimal($value); + $this->assertSame($expected, $decimal->toScientific()); + $this->assertSame($value, (string)new Decimal($decimal->toScientific())); + } + + /** + * @return array + */ + public static function scientificEdgeProvider(): array + { + return [ + 'zero' => ['0', '0e0'], + 'zero with scale' => ['0.00', '0.00e0'], + 'single digit' => ['5', '5e0'], + 'power of ten' => ['10', '1e1'], + 'large power of ten' => ['100000', '1e5'], + 'trailing zero integer' => ['120', '1.2e2'], + 'trailing zero integer with fraction' => ['120.50', '1.2050e2'], + 'beyond float precision' => ['1234567890123456789012.5', '1.2345678901234567890125e21'], + 'beyond int precision' => ['12345678901234567', '1.2345678901234567e16'], + 'single fraction digit' => ['0.5', '5e-1'], + 'small fraction' => ['0.00012', '1.2e-4'], + 'negative with scale' => ['-12.50', '-1.250e1'], + ]; + } + + /** + * @return void + */ + public function testToIntBoundaries(): void + { + $this->assertSame(PHP_INT_MIN, (new Decimal((string)PHP_INT_MIN))->toInt()); + $this->assertSame(PHP_INT_MAX, (new Decimal((string)PHP_INT_MAX))->toInt()); + $this->assertFalse((new Decimal((string)PHP_INT_MIN))->isBigInteger()); + $this->assertTrue((new Decimal('-9223372036854775809'))->isBigInteger()); + $this->assertTrue((new Decimal('9223372036854775808'))->isBigInteger()); + } + + /** + * @return void + */ + public function testModOperands(): void + { + $decimal = new Decimal('10'); + + $this->assertSame('0', (string)$decimal->mod('1e1')); + $this->assertSame('1', (string)$decimal->mod(new Decimal('3'))); + $this->assertSame('0.1', (string)$decimal->mod('0.3')); + $this->assertSame('1.5', (string)(new Decimal('10.5'))->mod('3')); + $this->assertSame('0', (string)$decimal->mod('0.3', 0)); + } + + /** + * @return void + */ + public function testPowOperands(): void + { + $decimal = new Decimal('2'); + + $this->assertSame('100', (string)(new Decimal('10'))->pow('2e0')); + $this->assertSame('4', (string)$decimal->pow(new Decimal('2'))); + $this->assertSame('0.50', (string)$decimal->pow(-1, 2)); + $this->assertSame('2.25', (string)(new Decimal('1.50'))->pow(2)); + } } From a02decde41ff0471a659b25e3c602cbe5aff18af Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 16:31:53 +0200 Subject: [PATCH 22/29] Require PHP 8.2 and update the dev tooling (#21) PHP 8.1 has been out of security support since the end of 2025. The floor stops at 8.2 because dereuromark/cakephp-decimal supports 8.2 and should keep receiving new releases of this library. - PHPUnit ^11.5 || ^12 || ^13, the 10.x line was only needed for 8.1. - php-collective/code-sniffer 0.2 to 0.6. The new rules want attributes imported and docblock types that agree with the native hint, so the operand docblocks now say self like the signatures do. - CI runs 8.2, 8.3, 8.4 and 8.5 on ubuntu-latest. - The Docker image no longer downloads PHPUnit 10.4 and PHPStan 1.10 phars. The Makefile runs the Composer scripts and never used them. - composer.json gains keywords and support links. --- .docker/Dockerfile | 9 +------ .env | 4 +-- .github/workflows/ci.yml | 18 ++++++------- README.md | 5 ++-- compose.yaml | 4 +-- composer.json | 18 ++++++++++--- phpunit.xml.dist | 2 ++ src/Decimal.php | 30 +++++++++++----------- tests/DecimalTest.php | 55 ++++++++++++++++++++-------------------- 9 files changed, 75 insertions(+), 70 deletions(-) diff --git a/.docker/Dockerfile b/.docker/Dockerfile index dd46470..d7bc22c 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -1,14 +1,7 @@ -ARG PHP_VERSION=8.2 -ARG PHPUNIT_VERSION=10.4.0 -ARG PHPSTAN_VERSION=1.10.38 +ARG PHP_VERSION=8.5 FROM php:${PHP_VERSION}-cli-alpine COPY --from=composer /usr/bin/composer /usr/bin/composer -ARG PHPUNIT_VERSION -ARG PHPSTAN_VERSION - RUN docker-php-ext-install bcmath && docker-php-ext-enable bcmath -RUN wget -O phpunit https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar && chmod +x phpunit -RUN wget -O phpstan https://github.com/phpstan/phpstan/releases/download/${PHPSTAN_VERSION}/phpstan.phar && chmod +x phpstan diff --git a/.env b/.env index af97ce2..93ca64f 100644 --- a/.env +++ b/.env @@ -1,3 +1 @@ -PHP_VERSION=8.2 -PHPUNIT_VERSION=10.4.0 -PHPSTAN_VERSION=1.10.38 +PHP_VERSION=8.5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 191fdc4..e40c7f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,14 +9,14 @@ on: jobs: testsuite: - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - php-version: ['8.1', '8.3', '8.5'] + php-version: ['8.2', '8.3', '8.4', '8.5'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -32,29 +32,29 @@ jobs: - name: Run PHPUnit run: | - if [[ ${{ matrix.php-version }} == '8.1' ]]; then + if [[ ${{ matrix.php-version }} == '8.2' ]]; then vendor/bin/phpunit --coverage-clover=coverage.xml else vendor/bin/phpunit fi - name: Code Coverage Report - if: success() && matrix.php-version == '8.1' - uses: codecov/codecov-action@v4 + if: success() && matrix.php-version == '8.2' + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} validation: name: Coding Standard & Static Analysis - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.2' extensions: mbstring, intl, bcmath coverage: none diff --git a/README.md b/README.md index 518f735..9193433 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/php-collective/decimal-object/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/php-collective/decimal-object/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/php-collective/decimal-object/branch/master/graph/badge.svg?token=L1thFB9nOG)](https://codecov.io/gh/php-collective/decimal-object) [![Latest Stable Version](https://poser.pugx.org/php-collective/decimal-object/v/stable.svg)](https://packagist.org/packages/php-collective/decimal-object) -[![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.1-8892BF.svg)](https://php.net/) +[![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.2-8892BF.svg)](https://php.net/) [![PHPStan](https://img.shields.io/badge/PHPStan-level%208-brightgreen.svg?style=flat)](https://phpstan.org/) [![License](https://poser.pugx.org/php-collective/decimal-object/license)](https://packagist.org/packages/php-collective/decimal-object) @@ -26,7 +26,7 @@ Monetary values themselves could be handled also with [brick/money](https://gith For details see the [wiki](https://github.com/php-collective/decimal-object/wiki). -Note: If you are looking for pre PHP 8.1 versions, check out the [legacy library](https://github.com/spryker/decimal-object). +Note: PHP 8.1 is covered by the 1.3 releases. For anything older, check out the [legacy library](https://github.com/spryker/decimal-object). ## Features @@ -40,6 +40,7 @@ Note: If you are looking for pre PHP 8.1 versions, check out the [legacy library ### Requirements +- PHP 8.2+ - `bcmath` PHP extension enabled ### Composer (preferred) diff --git a/compose.yaml b/compose.yaml index 84edd30..7d9c008 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,9 +6,7 @@ services: context: . dockerfile: .docker/Dockerfile args: - PHP_VERSION: "${PHP_VERSION:-8.2}" - PHPUNIT_VERSION: "${PHPUNIT_VERSION:-10.4.0}" - PHPSTAN_VERSION: "${PHPSTAN_VERSION:-1.10.38}" + PHP_VERSION: "${PHP_VERSION:-8.5}" volumes: - ./:/code working_dir: /code diff --git a/composer.json b/composer.json index ae80adf..c37719e 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,14 @@ "description": "PHP decimal handling as value object", "license": "MIT", "type": "library", + "keywords": [ + "decimal", + "value-object", + "bcmath", + "precision", + "arbitrary-precision", + "math" + ], "authors": [ { "name": "The PHP Collective", @@ -13,15 +21,19 @@ "homepage": "https://spryker.com" } ], + "support": { + "issues": "https://github.com/php-collective/decimal-object/issues", + "source": "https://github.com/php-collective/decimal-object" + }, "require": { - "php": ">=8.1", + "php": ">=8.2", "ext-bcmath": "*", "ext-json": "*" }, "require-dev": { - "php-collective/code-sniffer": "^0.2.1", + "php-collective/code-sniffer": "^0.6.8", "phpstan/phpstan": "^2.0.0", - "phpunit/phpunit": "^10.5.45 || ^11.0.0 || ^12.0.0 || ^13.0.0" + "phpunit/phpunit": "^11.5.0 || ^12.0.0 || ^13.0.0" }, "minimum-stability": "stable", "prefer-stable": true, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index ef3d67a..eca8d3f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,7 @@ diff --git a/src/Decimal.php b/src/Decimal.php index ec10482..e3cba1a 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -162,7 +162,7 @@ public static function create(object|string|float|int $value, ?int $scale = null * * This method is equivalent to the `==` operator. * - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool TRUE if this decimal is considered equal to the given value. * Equal decimal values tie-break on precision. @@ -173,7 +173,7 @@ public function equals(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -183,7 +183,7 @@ public function greaterThan(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -193,7 +193,7 @@ public function lessThan(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -205,7 +205,7 @@ public function greaterThanOrEquals(self|string|float|int $value): bool /** * @deprecated Use {@link greaterThanOrEquals()} instead. * - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -215,7 +215,7 @@ public function greatherThanOrEquals(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -232,7 +232,7 @@ public function lessThanOrEquals(self|string|float|int $value): bool * - `0` if the instance is equal to $value, or * - `1` if the instance is greater than $value. * - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return int */ @@ -247,7 +247,7 @@ public function compareTo(self|string|float|int $value): int /** * Add $value to this Decimal and return the sum as a new Decimal. * - * @param static|string|float|int $value + * @param self|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static @@ -281,7 +281,7 @@ protected function resultScale(self $a, self $b, ?int $scale = null): int * Subtract $value from this Decimal and return the difference as a new * Decimal. * - * @param static|string|float|int $value + * @param self|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static @@ -389,12 +389,12 @@ public function isPositive(): bool /** * Multiply this Decimal by $value and return the product as a new Decimal. * - * @param static|string|float|int $value + * @param self|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static */ - public function multiply(self|string|int|float $value, ?int $scale = null): static + public function multiply(self|string|float|int $value, ?int $scale = null): static { $decimal = static::create($value); if ($scale === null) { @@ -407,14 +407,14 @@ public function multiply(self|string|int|float $value, ?int $scale = null): stat /** * Divide this Decimal by $value and return the quotient as a new Decimal. * - * @param static|string|float|int $value + * @param self|string|float|int $value * @param int $scale Decimal places in the result * * @throws \DivisionByZeroError if $value is zero. * * @return static */ - public function divide(self|string|int|float $value, int $scale): static + public function divide(self|string|float|int $value, int $scale): static { $decimal = static::create($value); if ($decimal->isZero()) { @@ -427,7 +427,7 @@ public function divide(self|string|int|float $value, int $scale): static /** * This method is equivalent to the ** operator. * - * @param static|string|int $exponent + * @param self|string|int $exponent * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static @@ -460,7 +460,7 @@ public function sqrt(?int $scale = null): static /** * This method is equivalent to the % operator. * - * @param static|string|int $value + * @param self|string|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index e235cde..bf863c8 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -12,6 +12,7 @@ use DivisionByZeroError; use InvalidArgumentException; use PhpCollective\DecimalObject\Decimal; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Stringable; use TypeError; @@ -24,7 +25,7 @@ class DecimalTest extends TestCase * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('baseProvider')] + #[DataProvider('baseProvider')] public function testNewObject(mixed $value, string $expected): void { $decimal = new Decimal($value); @@ -50,7 +51,7 @@ public function testNewObjectScientific(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('baseProvider')] + #[DataProvider('baseProvider')] public function testCreate(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -142,7 +143,7 @@ public function __toString(): string * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('invalidValuesProvider')] + #[DataProvider('invalidValuesProvider')] public function testNewObjectWithInvalidValueThrowsException(mixed $value): void { $this->expectException(InvalidArgumentException::class); @@ -169,7 +170,7 @@ public static function invalidValuesProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('truncateProvider')] + #[DataProvider('truncateProvider')] public function testTruncate(mixed $input, int $scale, string $expected): void { $decimal = Decimal::create($input); @@ -197,7 +198,7 @@ public static function truncateProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('integerProvider')] + #[DataProvider('integerProvider')] public function testIsInteger(mixed $value, bool $expected): void { $decimal = Decimal::create($value); @@ -229,7 +230,7 @@ public static function integerProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('zeroProvider')] + #[DataProvider('zeroProvider')] public function testIsZero(mixed $value, bool $expected): void { $decimal = Decimal::create($value); @@ -260,7 +261,7 @@ public static function zeroProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareZeroProvider')] + #[DataProvider('compareZeroProvider')] public function testIsPositive(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -273,7 +274,7 @@ public function testIsPositive(mixed $input, int $expected): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareZeroProvider')] + #[DataProvider('compareZeroProvider')] public function testIsNegative(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -307,7 +308,7 @@ public static function compareZeroProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('scaleProvider')] + #[DataProvider('scaleProvider')] public function testScale(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -338,7 +339,7 @@ public static function scaleProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('scientificProvider')] + #[DataProvider('scientificProvider')] public function testToScientific(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -411,7 +412,7 @@ public function testToFloat(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('bigFloatDataProvider')] + #[DataProvider('bigFloatDataProvider')] public function testToFloatForBigDecimalThrowsAnException(string $value): void { $decimal = Decimal::create($value); @@ -450,7 +451,7 @@ public function testToInt(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('bigIntDataProvider')] + #[DataProvider('bigIntDataProvider')] public function testToIntForBigIntThrowsAnException(string $value): void { $decimal = Decimal::create($value); @@ -605,7 +606,7 @@ public function testEquals(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('roundProvider')] + #[DataProvider('roundProvider')] public function testRound(mixed $value, int $scale, string $expected): void { $decimal = Decimal::create($value); @@ -664,7 +665,7 @@ public static function roundProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('floorProvider')] + #[DataProvider('floorProvider')] public function testFloor(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -717,7 +718,7 @@ public static function floorProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('ceilProvider')] + #[DataProvider('ceilProvider')] public function testCeil(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -770,7 +771,7 @@ public static function ceilProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] + #[DataProvider('compareProvider')] public function testGreaterThan(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -784,7 +785,7 @@ public function testGreaterThan(mixed $a, mixed $b, int $expected): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] + #[DataProvider('compareProvider')] public function testLessThan(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -798,7 +799,7 @@ public function testLessThan(mixed $a, mixed $b, int $expected): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] + #[DataProvider('compareProvider')] public function testGreaterEquals(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -812,7 +813,7 @@ public function testGreaterEquals(mixed $a, mixed $b, int $expected): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] + #[DataProvider('compareProvider')] public function testLessEquals(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -876,7 +877,7 @@ public function testSubtract(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('multiplicationProvider')] + #[DataProvider('multiplicationProvider')] public function testMultiply(mixed $a, mixed $b, ?int $scale, string $expected): void { $decimal = Decimal::create($a); @@ -915,7 +916,7 @@ public static function multiplicationProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('multiplicationLegacyProvider')] + #[DataProvider('multiplicationLegacyProvider')] public function testMultiplyLegacy(mixed $a, mixed $b, ?int $scale, string $expected): void { $decimal = Decimal::create($a); @@ -940,7 +941,7 @@ public static function multiplicationLegacyProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('divisionProvider')] + #[DataProvider('divisionProvider')] public function testDivide(mixed $a, mixed $b, int $scale, string $expected): void { $decimal = Decimal::create($a); @@ -1056,7 +1057,7 @@ public function testPrecisionLossFail(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('normalizationProvider')] + #[DataProvider('normalizationProvider')] public function testNewObjectNormalization(mixed $value, string $expected): void { $this->assertSame($expected, (string)new Decimal($value)); @@ -1099,7 +1100,7 @@ public static function normalizationProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('nonFiniteProvider')] + #[DataProvider('nonFiniteProvider')] public function testNewObjectNonFiniteFloat(float $value): void { $this->expectException(InvalidArgumentException::class); @@ -1185,7 +1186,7 @@ public function testNoNegativeZero(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('roundModeProvider')] + #[DataProvider('roundModeProvider')] public function testRoundMode(string $value, int $scale, int $mode, string $expected): void { $this->assertSame($expected, (string)(new Decimal($value))->round($scale, $mode)); @@ -1233,7 +1234,7 @@ public function testRoundNegativeScale(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('roundInvalidModeProvider')] + #[DataProvider('roundInvalidModeProvider')] public function testRoundInvalidMode(string $value): void { $this->expectException(InvalidArgumentException::class); @@ -1258,7 +1259,7 @@ public static function roundInvalidModeProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('scientificEdgeProvider')] + #[DataProvider('scientificEdgeProvider')] public function testToScientificEdgeCases(string $value, string $expected): void { $decimal = new Decimal($value); From ede83d6883a8b11e40a7332a8c8a3590c6dd5702 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 16:40:01 +0200 Subject: [PATCH 23/29] Keep four behavior changes out of 1.x A run of the old and new class over the same 10,000 calls showed four of the earlier changes altering results for ordinary input, with no error to point at the cause. They move to the next major and 1.x keeps its behavior: - round() with an unsupported mode rounds half up again, where the fix made every such call throw. - ROUND_FLOOR and ROUND_CEIL round to a whole number whatever scale is passed, as the docblock has always said. - A scale of 0 in the constructor is read as no scale and does not cut off decimals. - mod() defaults to the scale of the value it is called on. The fixes for wrong results stay: floor() and ceil() on whole numbers with decimals, operand parsing in mod() and pow(), the negative scale check. --- src/Decimal.php | 29 ++++++++++---------- tests/DecimalTest.php | 62 ++++--------------------------------------- 2 files changed, 20 insertions(+), 71 deletions(-) diff --git a/src/Decimal.php b/src/Decimal.php index e3cba1a..e133d78 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -467,15 +467,16 @@ public function sqrt(?int $scale = null): static */ public function mod(self|string|int $value, ?int $scale = null): static { - $decimal = static::create($value); - $scale = $this->resultScale($this, $decimal, $scale); + if ($scale === null) { + $scale = $this->scale(); + } - return new static(bcmod((string)$this, (string)$decimal, $scale)); + return new static(bcmod((string)$this, (string)static::create($value), $scale)); } /** - * @param int $scale Decimal places in the result - * @param int $roundMode One of the ROUND_* constants of this class + * @param int $scale Decimal places in the result (only used with mode "half up") + * @param int $roundMode ROUND_HALF_UP, ROUND_CEIL or ROUND_FLOOR. Anything else rounds half up. * * @throws \InvalidArgumentException * @@ -486,25 +487,24 @@ public function round(int $scale = 0, int $roundMode = self::ROUND_HALF_UP): sta if ($scale < 0) { throw new InvalidArgumentException('Scale must be >= 0.'); } - if (!in_array($roundMode, [static::ROUND_HALF_UP, static::ROUND_CEIL, static::ROUND_FLOOR], true)) { - throw new InvalidArgumentException('Unsupported round mode `' . $roundMode . '`.'); - } + + // Floor and ceil always round to a whole number in 1.x, the scale only labels the result. + $directed = in_array($roundMode, [static::ROUND_FLOOR, static::ROUND_CEIL], true); + $places = $directed ? 0 : $scale; $value = (string)$this; - $truncated = bcadd($value, '0', $scale); + $truncated = bcadd($value, '0', $places); if (bccomp($value, $truncated, $this->scale()) === 0) { return new static($truncated, $scale); } - $unit = $scale > 0 ? '0.' . str_repeat('0', $scale - 1) . '1' : '1'; - switch ($roundMode) { case static::ROUND_FLOOR: - $result = $this->isNegative() ? bcsub($truncated, $unit, $scale) : $truncated; + $result = $this->isNegative() ? bcsub($truncated, '1', 0) : $truncated; break; case static::ROUND_CEIL: - $result = $this->isNegative() ? $truncated : bcadd($truncated, $unit, $scale); + $result = $this->isNegative() ? $truncated : bcadd($truncated, '1', 0); break; default: @@ -848,8 +848,9 @@ protected function setScale(?int $scale, bool $strict): void throw new InvalidArgumentException('Scale must be >= 0.'); } + // A scale of 0 is treated like no scale in 1.x and does not cut off decimals. $calculatedScale = strlen($this->fractionalPart); - if ($scale !== null && $calculatedScale > $scale) { + if ($scale && $calculatedScale > $scale) { if ($strict) { throw new InvalidArgumentException('Loss of precision detected. Detected scale `' . $calculatedScale . '` > `' . $scale . '` as defined.'); } diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index bf863c8..8971d59 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -1120,27 +1120,6 @@ public static function nonFiniteProvider(): array ]; } - /** - * @return void - */ - public function testNewObjectZeroScale(): void - { - $decimal = new Decimal('1.56', 0); - $this->assertSame('1', (string)$decimal); - $this->assertSame(0, $decimal->scale()); - } - - /** - * @return void - */ - public function testNewObjectZeroScaleStrict(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Loss of precision detected'); - - new Decimal('1.56', 0, true); - } - /** * @return void */ @@ -1201,19 +1180,13 @@ public static function roundModeProvider(): array 'floor negative whole' => ['-1.0', 0, Decimal::ROUND_FLOOR, '-1'], 'floor positive whole' => ['1.00', 0, Decimal::ROUND_FLOOR, '1'], 'floor negative' => ['-1.5', 0, Decimal::ROUND_FLOOR, '-2'], - 'floor scale' => ['1.25', 1, Decimal::ROUND_FLOOR, '1.2'], - 'floor negative scale' => ['-1.21', 1, Decimal::ROUND_FLOOR, '-1.3'], - 'floor exact scale' => ['-1.20', 1, Decimal::ROUND_FLOOR, '-1.2'], - 'floor higher scale' => ['1.5', 3, Decimal::ROUND_FLOOR, '1.500'], - 'floor small negative' => ['-0.001', 2, Decimal::ROUND_FLOOR, '-0.01'], 'ceil positive whole' => ['1.0', 0, Decimal::ROUND_CEIL, '1'], 'ceil negative whole' => ['-1.00', 0, Decimal::ROUND_CEIL, '-1'], 'ceil positive' => ['1.5', 0, Decimal::ROUND_CEIL, '2'], - 'ceil scale' => ['1.21', 1, Decimal::ROUND_CEIL, '1.3'], - 'ceil negative scale' => ['-1.29', 1, Decimal::ROUND_CEIL, '-1.2'], - 'ceil exact scale' => ['1.20', 1, Decimal::ROUND_CEIL, '1.2'], - 'ceil small negative' => ['-0.001', 2, Decimal::ROUND_CEIL, '0.00'], - 'ceil small positive' => ['0.001', 2, Decimal::ROUND_CEIL, '0.01'], + 'floor small negative' => ['-0.001', 0, Decimal::ROUND_FLOOR, '-1'], + 'floor zero with decimals' => ['0.00', 0, Decimal::ROUND_FLOOR, '0'], + 'ceil small positive' => ['0.001', 0, Decimal::ROUND_CEIL, '1'], + 'ceil zero with decimals' => ['0.00', 0, Decimal::ROUND_CEIL, '0'], 'half up negative' => ['-1.005', 2, Decimal::ROUND_HALF_UP, '-1.01'], 'half up to zero' => ['-0.004', 2, Decimal::ROUND_HALF_UP, '0.00'], ]; @@ -1229,30 +1202,6 @@ public function testRoundNegativeScale(): void (new Decimal('15'))->round(-1); } - /** - * @param string $value - * - * @return void - */ - #[DataProvider('roundInvalidModeProvider')] - public function testRoundInvalidMode(string $value): void - { - $this->expectException(InvalidArgumentException::class); - - (new Decimal($value))->round(0, PHP_ROUND_HALF_EVEN); - } - - /** - * @return array - */ - public static function roundInvalidModeProvider(): array - { - return [ - 'needs rounding' => ['1.5'], - 'exact' => ['1.0'], - ]; - } - /** * @param string $value * @param string $expected @@ -1309,9 +1258,8 @@ public function testModOperands(): void $this->assertSame('0', (string)$decimal->mod('1e1')); $this->assertSame('1', (string)$decimal->mod(new Decimal('3'))); - $this->assertSame('0.1', (string)$decimal->mod('0.3')); + $this->assertSame('0.1', (string)$decimal->mod('0.3', 1)); $this->assertSame('1.5', (string)(new Decimal('10.5'))->mod('3')); - $this->assertSame('0', (string)$decimal->mod('0.3', 0)); } /** From 763e5bc633cb523e193a358910ff9d3be6bc8d4a Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 16:41:43 +0200 Subject: [PATCH 24/29] Keep three more output changes out of 1.x Same reasoning as the previous commit, applied to the changes that only alter the string a caller gets back: - A string mantissa of "1.0" is read as "1" again, so "1.0e-5" keeps a scale of 5. - Scientific input with a positive exponent and a scale pads the way it did before. - toScientific() keeps the radix mark on a mantissa without decimals ("5.e0"). The wrong results stay fixed: zero, lost digits on large integers, and values such as 120.50. All three move to the next major. --- src/Decimal.php | 13 +++++++------ tests/DecimalTest.php | 12 +++++------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/Decimal.php b/src/Decimal.php index e133d78..f92677b 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -639,10 +639,8 @@ public function toScientific(): string $exponent = strlen($digits) - strlen($this->fractionalPart) - 1; } - $value = $digits[0]; - if (strlen($digits) > 1) { - $value .= static::RADIX_MARK . substr($digits, 1); - } + // The radix mark stays even without decimals (`5.e0`) in 1.x. + $value = $digits[0] . static::RADIX_MARK . substr($digits, 1); if ($this->negative) { $value = '-' . $value; @@ -814,7 +812,8 @@ protected function fromScientific(string $value, ?int $scale): void $this->negative = $matches[1] === '-'; $integralPart = $matches[2]; - $fractionalPart = $matches[3]; + // A mantissa of `1.0` is read as `1` in 1.x. + $fractionalPart = $matches[3] === '0' ? '' : $matches[3]; $exp = (int)$matches[4]; if ($exp < 0) { @@ -830,7 +829,9 @@ protected function fromScientific(string $value, ?int $scale): void $this->fractionalPart = $fractionalPart; if ($scale !== null) { - $this->fractionalPart = str_pad($this->fractionalPart, $scale, '0'); + // 1.x pads positive exponents to the scale minus the integral digits. + $length = $exp < 0 ? $scale : $scale - strlen($this->integralPart); + $this->fractionalPart = str_pad($this->fractionalPart, $length, '0'); } } diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index 8971d59..aa5ad49 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -1085,10 +1085,9 @@ public static function normalizationProvider(): array 'negative mantissa' => ['-1.5e-2', '-0.015'], 'omitted zero mantissa' => ['.5e1', '5'], 'small mantissa' => ['0.00012e3', '0.12'], - 'significant zero in mantissa' => ['1.0e0', '1.0'], 'zero exponent' => ['1.5e0', '1.5'], 'negative zero exponent' => ['-0e5', '0'], - 'zero mantissa' => ['0.0e-3', '0.0000'], + 'zero mantissa' => ['0.0e-3', '0.000'], 'large float' => [1e25, '10000000000000000000000000'], 'small float' => [1.5e-7, '0.00000015'], 'negative zero float' => [-0.0, '0'], @@ -1135,7 +1134,6 @@ public function testNewObjectNegativeScale(): void */ public function testNewObjectScientificWithScale(): void { - $this->assertSame('1.500', (string)new Decimal('1.5e0', 3)); $this->assertSame('0.001200', (string)new Decimal('1.2e-3', 6)); $this->assertSame('0.0012', (string)new Decimal('1.23456e-3', 4)); } @@ -1224,14 +1222,14 @@ public static function scientificEdgeProvider(): array return [ 'zero' => ['0', '0e0'], 'zero with scale' => ['0.00', '0.00e0'], - 'single digit' => ['5', '5e0'], - 'power of ten' => ['10', '1e1'], - 'large power of ten' => ['100000', '1e5'], + 'single digit' => ['5', '5.e0'], + 'power of ten' => ['10', '1.e1'], + 'large power of ten' => ['100000', '1.e5'], 'trailing zero integer' => ['120', '1.2e2'], 'trailing zero integer with fraction' => ['120.50', '1.2050e2'], 'beyond float precision' => ['1234567890123456789012.5', '1.2345678901234567890125e21'], 'beyond int precision' => ['12345678901234567', '1.2345678901234567e16'], - 'single fraction digit' => ['0.5', '5e-1'], + 'single fraction digit' => ['0.5', '5.e-1'], 'small fraction' => ['0.00012', '1.2e-4'], 'negative with scale' => ['-12.50', '-1.250e1'], ]; From ce9ea39bea12abfc66785303952d6bb941b471ed Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 16:42:21 +0200 Subject: [PATCH 25/29] Apply the behavior changes held back from 1.x These correct real defects but change results or output for ordinary input without raising an error, so they wait for the major release: - A scale of 0 in the constructor cuts off decimals like any other scale, and throws in strict mode. 1.x reads 0 as "no scale". - round() throws on an unsupported mode. 1.x silently rounds half up. - ROUND_FLOOR and ROUND_CEIL round at the requested scale. 1.x always rounds to a whole number. - mod() defaults to the larger scale of both operands, like add(). 1.x uses the scale of the left operand, so 10 mod 0.3 is 0. - A string mantissa keeps a written zero: "1.0e-5" has a scale of 6. - Scientific input with a scale is padded to that scale for positive exponents too. - toScientific() drops the radix mark when the mantissa has no decimals: "5e0" where 1.x prints "5.e0". --- src/Decimal.php | 42 ++++++++++++------------ tests/DecimalTest.php | 74 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 84 insertions(+), 32 deletions(-) diff --git a/src/Decimal.php b/src/Decimal.php index 1547d58..928899e 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -455,16 +455,15 @@ public function sqrt(?int $scale = null): static */ public function mod(self|string|int $value, ?int $scale = null): static { - if ($scale === null) { - $scale = $this->scale(); - } + $decimal = static::create($value); + $scale = $this->resultScale($this, $decimal, $scale); - return new static(bcmod((string)$this, (string)static::create($value), $scale)); + return new static(bcmod((string)$this, (string)$decimal, $scale)); } /** - * @param int $scale Decimal places in the result (only used with mode "half up") - * @param int $roundMode ROUND_HALF_UP, ROUND_CEIL or ROUND_FLOOR. Anything else rounds half up. + * @param int $scale Decimal places in the result + * @param int $roundMode One of the ROUND_* constants of this class * * @throws \InvalidArgumentException * @@ -475,24 +474,25 @@ public function round(int $scale = 0, int $roundMode = self::ROUND_HALF_UP): sta if ($scale < 0) { throw new InvalidArgumentException('Scale must be >= 0.'); } - - // Floor and ceil always round to a whole number in 1.x, the scale only labels the result. - $directed = in_array($roundMode, [static::ROUND_FLOOR, static::ROUND_CEIL], true); - $places = $directed ? 0 : $scale; + if (!in_array($roundMode, [static::ROUND_HALF_UP, static::ROUND_CEIL, static::ROUND_FLOOR], true)) { + throw new InvalidArgumentException('Unsupported round mode `' . $roundMode . '`.'); + } $value = (string)$this; - $truncated = bcadd($value, '0', $places); + $truncated = bcadd($value, '0', $scale); if (bccomp($value, $truncated, $this->scale()) === 0) { return new static($truncated, $scale); } + $unit = $scale > 0 ? '0.' . str_repeat('0', $scale - 1) . '1' : '1'; + switch ($roundMode) { case static::ROUND_FLOOR: - $result = $this->isNegative() ? bcsub($truncated, '1', 0) : $truncated; + $result = $this->isNegative() ? bcsub($truncated, $unit, $scale) : $truncated; break; case static::ROUND_CEIL: - $result = $this->isNegative() ? $truncated : bcadd($truncated, '1', 0); + $result = $this->isNegative() ? $truncated : bcadd($truncated, $unit, $scale); break; default: @@ -627,8 +627,10 @@ public function toScientific(): string $exponent = strlen($digits) - strlen($this->fractionalPart) - 1; } - // The radix mark stays even without decimals (`5.e0`) in 1.x. - $value = $digits[0] . static::RADIX_MARK . substr($digits, 1); + $value = $digits[0]; + if (strlen($digits) > 1) { + $value .= static::RADIX_MARK . substr($digits, 1); + } if ($this->negative) { $value = '-' . $value; @@ -800,8 +802,7 @@ protected function fromScientific(string $value, ?int $scale): void $this->negative = $matches[1] === '-'; $integralPart = $matches[2]; - // A mantissa of `1.0` is read as `1` in 1.x. - $fractionalPart = $matches[3] === '0' ? '' : $matches[3]; + $fractionalPart = $matches[3]; $exp = (int)$matches[4]; if ($exp < 0) { @@ -817,9 +818,7 @@ protected function fromScientific(string $value, ?int $scale): void $this->fractionalPart = $fractionalPart; if ($scale !== null) { - // 1.x pads positive exponents to the scale minus the integral digits. - $length = $exp < 0 ? $scale : $scale - strlen($this->integralPart); - $this->fractionalPart = str_pad($this->fractionalPart, $length, '0'); + $this->fractionalPart = str_pad($this->fractionalPart, $scale, '0'); } } @@ -837,9 +836,8 @@ protected function setScale(?int $scale, bool $strict): void throw new InvalidArgumentException('Scale must be >= 0.'); } - // A scale of 0 is treated like no scale in 1.x and does not cut off decimals. $calculatedScale = strlen($this->fractionalPart); - if ($scale && $calculatedScale > $scale) { + if ($scale !== null && $calculatedScale > $scale) { if ($strict) { throw new InvalidArgumentException('Loss of precision detected. Detected scale `' . $calculatedScale . '` > `' . $scale . '` as defined.'); } diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index aa5ad49..bf863c8 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -1085,9 +1085,10 @@ public static function normalizationProvider(): array 'negative mantissa' => ['-1.5e-2', '-0.015'], 'omitted zero mantissa' => ['.5e1', '5'], 'small mantissa' => ['0.00012e3', '0.12'], + 'significant zero in mantissa' => ['1.0e0', '1.0'], 'zero exponent' => ['1.5e0', '1.5'], 'negative zero exponent' => ['-0e5', '0'], - 'zero mantissa' => ['0.0e-3', '0.000'], + 'zero mantissa' => ['0.0e-3', '0.0000'], 'large float' => [1e25, '10000000000000000000000000'], 'small float' => [1.5e-7, '0.00000015'], 'negative zero float' => [-0.0, '0'], @@ -1119,6 +1120,27 @@ public static function nonFiniteProvider(): array ]; } + /** + * @return void + */ + public function testNewObjectZeroScale(): void + { + $decimal = new Decimal('1.56', 0); + $this->assertSame('1', (string)$decimal); + $this->assertSame(0, $decimal->scale()); + } + + /** + * @return void + */ + public function testNewObjectZeroScaleStrict(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Loss of precision detected'); + + new Decimal('1.56', 0, true); + } + /** * @return void */ @@ -1134,6 +1156,7 @@ public function testNewObjectNegativeScale(): void */ public function testNewObjectScientificWithScale(): void { + $this->assertSame('1.500', (string)new Decimal('1.5e0', 3)); $this->assertSame('0.001200', (string)new Decimal('1.2e-3', 6)); $this->assertSame('0.0012', (string)new Decimal('1.23456e-3', 4)); } @@ -1178,13 +1201,19 @@ public static function roundModeProvider(): array 'floor negative whole' => ['-1.0', 0, Decimal::ROUND_FLOOR, '-1'], 'floor positive whole' => ['1.00', 0, Decimal::ROUND_FLOOR, '1'], 'floor negative' => ['-1.5', 0, Decimal::ROUND_FLOOR, '-2'], + 'floor scale' => ['1.25', 1, Decimal::ROUND_FLOOR, '1.2'], + 'floor negative scale' => ['-1.21', 1, Decimal::ROUND_FLOOR, '-1.3'], + 'floor exact scale' => ['-1.20', 1, Decimal::ROUND_FLOOR, '-1.2'], + 'floor higher scale' => ['1.5', 3, Decimal::ROUND_FLOOR, '1.500'], + 'floor small negative' => ['-0.001', 2, Decimal::ROUND_FLOOR, '-0.01'], 'ceil positive whole' => ['1.0', 0, Decimal::ROUND_CEIL, '1'], 'ceil negative whole' => ['-1.00', 0, Decimal::ROUND_CEIL, '-1'], 'ceil positive' => ['1.5', 0, Decimal::ROUND_CEIL, '2'], - 'floor small negative' => ['-0.001', 0, Decimal::ROUND_FLOOR, '-1'], - 'floor zero with decimals' => ['0.00', 0, Decimal::ROUND_FLOOR, '0'], - 'ceil small positive' => ['0.001', 0, Decimal::ROUND_CEIL, '1'], - 'ceil zero with decimals' => ['0.00', 0, Decimal::ROUND_CEIL, '0'], + 'ceil scale' => ['1.21', 1, Decimal::ROUND_CEIL, '1.3'], + 'ceil negative scale' => ['-1.29', 1, Decimal::ROUND_CEIL, '-1.2'], + 'ceil exact scale' => ['1.20', 1, Decimal::ROUND_CEIL, '1.2'], + 'ceil small negative' => ['-0.001', 2, Decimal::ROUND_CEIL, '0.00'], + 'ceil small positive' => ['0.001', 2, Decimal::ROUND_CEIL, '0.01'], 'half up negative' => ['-1.005', 2, Decimal::ROUND_HALF_UP, '-1.01'], 'half up to zero' => ['-0.004', 2, Decimal::ROUND_HALF_UP, '0.00'], ]; @@ -1200,6 +1229,30 @@ public function testRoundNegativeScale(): void (new Decimal('15'))->round(-1); } + /** + * @param string $value + * + * @return void + */ + #[DataProvider('roundInvalidModeProvider')] + public function testRoundInvalidMode(string $value): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal($value))->round(0, PHP_ROUND_HALF_EVEN); + } + + /** + * @return array + */ + public static function roundInvalidModeProvider(): array + { + return [ + 'needs rounding' => ['1.5'], + 'exact' => ['1.0'], + ]; + } + /** * @param string $value * @param string $expected @@ -1222,14 +1275,14 @@ public static function scientificEdgeProvider(): array return [ 'zero' => ['0', '0e0'], 'zero with scale' => ['0.00', '0.00e0'], - 'single digit' => ['5', '5.e0'], - 'power of ten' => ['10', '1.e1'], - 'large power of ten' => ['100000', '1.e5'], + 'single digit' => ['5', '5e0'], + 'power of ten' => ['10', '1e1'], + 'large power of ten' => ['100000', '1e5'], 'trailing zero integer' => ['120', '1.2e2'], 'trailing zero integer with fraction' => ['120.50', '1.2050e2'], 'beyond float precision' => ['1234567890123456789012.5', '1.2345678901234567890125e21'], 'beyond int precision' => ['12345678901234567', '1.2345678901234567e16'], - 'single fraction digit' => ['0.5', '5.e-1'], + 'single fraction digit' => ['0.5', '5e-1'], 'small fraction' => ['0.00012', '1.2e-4'], 'negative with scale' => ['-12.50', '-1.250e1'], ]; @@ -1256,8 +1309,9 @@ public function testModOperands(): void $this->assertSame('0', (string)$decimal->mod('1e1')); $this->assertSame('1', (string)$decimal->mod(new Decimal('3'))); - $this->assertSame('0.1', (string)$decimal->mod('0.3', 1)); + $this->assertSame('0.1', (string)$decimal->mod('0.3')); $this->assertSame('1.5', (string)(new Decimal('10.5'))->mod('3')); + $this->assertSame('0', (string)$decimal->mod('0.3', 0)); } /** From 5fd1e0acf0e3abd097c1f64366b30208b533224e Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 16:44:55 +0200 Subject: [PATCH 26/29] Fix parsing, negative zero, rounding and scientific notation edge cases (#20) * Fix parsing, negative zero, rounding and scientific notation edge cases Parsing - Leading zeros survived in several forms: "0010", "-007", "+007" and "-00" kept them, and "+.5" lost its leading zero. - Scientific notation was wrong for any mantissa with more than one integer digit and a negative exponent ("12.5e-1" became 0.125, "100e-2" became 0.0100) and for mantissas below one with a positive exponent (".5e1" became 5.5, "0.00012e3" stayed 0.00012). - An explicit plus in the exponent ("1e+5") was rejected. PHP renders large floats that way, so new Decimal(1e25) threw. - "1.0e0" dropped the written zero. A trailing ".0" is now only removed from the string PHP produces for a float. - INF and NAN were accepted and stored as the strings "INF" and "NAN". - A scale of 0 was treated as "no scale": new Decimal('1.5', 0) kept the fraction and strict mode did not throw. Negative scales were accepted. Negative zero - "-0.0", negate() on zero, truncate() and trim() of small negative values produced "-0" and reported isNegative() as true while sign() returned 0. Zero now never carries a sign. Rounding - floor() and ceil() moved whole numbers with a fraction of zeros: floor('-1.0') returned -2 and ceil('1.0') returned 2. - ROUND_FLOOR and ROUND_CEIL ignored the requested scale. - An unknown round mode silently fell back to half up. It now throws, as does a negative scale. Conversion - toScientific() returned ".e-1" for zero, went through float division and so lost digits beyond 15 for large integers, and returned "1.250e2" for 120.50. - toInt() refused PHP_INT_MIN. Operands - mod() and pow() passed the operand to bcmath unparsed, so values in scientific notation failed. mod() now defaults to the larger scale of both operands, like add(); 10 mod 0.3 returned 0 before. * Require PHP 8.2 and update the dev tooling (#21) PHP 8.1 has been out of security support since the end of 2025. The floor stops at 8.2 because dereuromark/cakephp-decimal supports 8.2 and should keep receiving new releases of this library. - PHPUnit ^11.5 || ^12 || ^13, the 10.x line was only needed for 8.1. - php-collective/code-sniffer 0.2 to 0.6. The new rules want attributes imported and docblock types that agree with the native hint, so the operand docblocks now say self like the signatures do. - CI runs 8.2, 8.3, 8.4 and 8.5 on ubuntu-latest. - The Docker image no longer downloads PHPUnit 10.4 and PHPStan 1.10 phars. The Makefile runs the Composer scripts and never used them. - composer.json gains keywords and support links. * Keep four behavior changes out of 1.x A run of the old and new class over the same 10,000 calls showed four of the earlier changes altering results for ordinary input, with no error to point at the cause. They move to the next major and 1.x keeps its behavior: - round() with an unsupported mode rounds half up again, where the fix made every such call throw. - ROUND_FLOOR and ROUND_CEIL round to a whole number whatever scale is passed, as the docblock has always said. - A scale of 0 in the constructor is read as no scale and does not cut off decimals. - mod() defaults to the scale of the value it is called on. The fixes for wrong results stay: floor() and ceil() on whole numbers with decimals, operand parsing in mod() and pow(), the negative scale check. * Keep three more output changes out of 1.x Same reasoning as the previous commit, applied to the changes that only alter the string a caller gets back: - A string mantissa of "1.0" is read as "1" again, so "1.0e-5" keeps a scale of 5. - Scientific input with a positive exponent and a scale pads the way it did before. - toScientific() keeps the radix mark on a mantissa without decimals ("5.e0"). The wrong results stay fixed: zero, lost digits on large integers, and values such as 120.50. All three move to the next major. --- .docker/Dockerfile | 9 +- .env | 4 +- .github/workflows/ci.yml | 18 +-- README.md | 5 +- compose.yaml | 4 +- composer.json | 18 ++- phpunit.xml.dist | 2 + src/Decimal.php | 226 ++++++++++++++++----------------- tests/DecimalTest.php | 267 +++++++++++++++++++++++++++++++++++---- 9 files changed, 386 insertions(+), 167 deletions(-) diff --git a/.docker/Dockerfile b/.docker/Dockerfile index dd46470..d7bc22c 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -1,14 +1,7 @@ -ARG PHP_VERSION=8.2 -ARG PHPUNIT_VERSION=10.4.0 -ARG PHPSTAN_VERSION=1.10.38 +ARG PHP_VERSION=8.5 FROM php:${PHP_VERSION}-cli-alpine COPY --from=composer /usr/bin/composer /usr/bin/composer -ARG PHPUNIT_VERSION -ARG PHPSTAN_VERSION - RUN docker-php-ext-install bcmath && docker-php-ext-enable bcmath -RUN wget -O phpunit https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar && chmod +x phpunit -RUN wget -O phpstan https://github.com/phpstan/phpstan/releases/download/${PHPSTAN_VERSION}/phpstan.phar && chmod +x phpstan diff --git a/.env b/.env index af97ce2..93ca64f 100644 --- a/.env +++ b/.env @@ -1,3 +1 @@ -PHP_VERSION=8.2 -PHPUNIT_VERSION=10.4.0 -PHPSTAN_VERSION=1.10.38 +PHP_VERSION=8.5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 191fdc4..e40c7f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,14 +9,14 @@ on: jobs: testsuite: - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - php-version: ['8.1', '8.3', '8.5'] + php-version: ['8.2', '8.3', '8.4', '8.5'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -32,29 +32,29 @@ jobs: - name: Run PHPUnit run: | - if [[ ${{ matrix.php-version }} == '8.1' ]]; then + if [[ ${{ matrix.php-version }} == '8.2' ]]; then vendor/bin/phpunit --coverage-clover=coverage.xml else vendor/bin/phpunit fi - name: Code Coverage Report - if: success() && matrix.php-version == '8.1' - uses: codecov/codecov-action@v4 + if: success() && matrix.php-version == '8.2' + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} validation: name: Coding Standard & Static Analysis - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.2' extensions: mbstring, intl, bcmath coverage: none diff --git a/README.md b/README.md index 518f735..9193433 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/php-collective/decimal-object/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/php-collective/decimal-object/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/php-collective/decimal-object/branch/master/graph/badge.svg?token=L1thFB9nOG)](https://codecov.io/gh/php-collective/decimal-object) [![Latest Stable Version](https://poser.pugx.org/php-collective/decimal-object/v/stable.svg)](https://packagist.org/packages/php-collective/decimal-object) -[![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.1-8892BF.svg)](https://php.net/) +[![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.2-8892BF.svg)](https://php.net/) [![PHPStan](https://img.shields.io/badge/PHPStan-level%208-brightgreen.svg?style=flat)](https://phpstan.org/) [![License](https://poser.pugx.org/php-collective/decimal-object/license)](https://packagist.org/packages/php-collective/decimal-object) @@ -26,7 +26,7 @@ Monetary values themselves could be handled also with [brick/money](https://gith For details see the [wiki](https://github.com/php-collective/decimal-object/wiki). -Note: If you are looking for pre PHP 8.1 versions, check out the [legacy library](https://github.com/spryker/decimal-object). +Note: PHP 8.1 is covered by the 1.3 releases. For anything older, check out the [legacy library](https://github.com/spryker/decimal-object). ## Features @@ -40,6 +40,7 @@ Note: If you are looking for pre PHP 8.1 versions, check out the [legacy library ### Requirements +- PHP 8.2+ - `bcmath` PHP extension enabled ### Composer (preferred) diff --git a/compose.yaml b/compose.yaml index 84edd30..7d9c008 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,9 +6,7 @@ services: context: . dockerfile: .docker/Dockerfile args: - PHP_VERSION: "${PHP_VERSION:-8.2}" - PHPUNIT_VERSION: "${PHPUNIT_VERSION:-10.4.0}" - PHPSTAN_VERSION: "${PHPSTAN_VERSION:-1.10.38}" + PHP_VERSION: "${PHP_VERSION:-8.5}" volumes: - ./:/code working_dir: /code diff --git a/composer.json b/composer.json index ae80adf..c37719e 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,14 @@ "description": "PHP decimal handling as value object", "license": "MIT", "type": "library", + "keywords": [ + "decimal", + "value-object", + "bcmath", + "precision", + "arbitrary-precision", + "math" + ], "authors": [ { "name": "The PHP Collective", @@ -13,15 +21,19 @@ "homepage": "https://spryker.com" } ], + "support": { + "issues": "https://github.com/php-collective/decimal-object/issues", + "source": "https://github.com/php-collective/decimal-object" + }, "require": { - "php": ">=8.1", + "php": ">=8.2", "ext-bcmath": "*", "ext-json": "*" }, "require-dev": { - "php-collective/code-sniffer": "^0.2.1", + "php-collective/code-sniffer": "^0.6.8", "phpstan/phpstan": "^2.0.0", - "phpunit/phpunit": "^10.5.45 || ^11.0.0 || ^12.0.0 || ^13.0.0" + "phpunit/phpunit": "^11.5.0 || ^12.0.0 || ^13.0.0" }, "minimum-stability": "stable", "prefer-stable": true, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index ef3d67a..eca8d3f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,7 @@ diff --git a/src/Decimal.php b/src/Decimal.php index 8f005ad..f92677b 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -74,6 +74,7 @@ public function __construct(object|string|float|int $value, ?int $scale = null, $this->setValue($value, $scale); $this->setScale($scale, $strict); + $this->normalizeZero(); } /** @@ -105,11 +106,16 @@ protected function parseValue(object|string|float|int $value): string throw new InvalidArgumentException('Invalid non numeric value'); } - if (!is_string($value)) { - $value = (string)$value; + if (is_float($value)) { + if (!is_finite($value)) { + throw new InvalidArgumentException('Invalid non finite value'); + } + + // PHP renders large and small floats as `1.0E+25`, that zero is not a significant digit. + return str_replace('.0E', 'E', (string)$value); } - return $value; + return (string)$value; } /** @@ -120,22 +126,13 @@ protected function parseValue(object|string|float|int $value): string protected function normalizeValue(string $value): string { $value = trim($value); - /** @var string $value */ - $value = preg_replace( - [ - '/^^(-?)(\.)(.*)$/', // omitted leading zero - '/^0+(.)(\..*)?$/', // multiple leading zeros - '/^(\+(.*)|(-)(0))$/', // leading positive sign, tolerate minus zero too - ], - [ - '${1}0.${3}', - '${1}${2}', - '${4}${2}', - ], - $value, - ); + $sign = str_starts_with($value, '-') ? '-' : ''; + $value = ltrim(ltrim($value, '+-'), '0'); + if ($value === '' || !ctype_digit($value[0])) { + $value = '0' . $value; + } - return $value; + return $sign . $value; } /** @@ -165,7 +162,7 @@ public static function create(object|string|float|int $value, ?int $scale = null * * This method is equivalent to the `==` operator. * - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool TRUE if this decimal is considered equal to the given value. * Equal decimal values tie-break on precision. @@ -176,7 +173,7 @@ public function equals(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -186,7 +183,7 @@ public function greaterThan(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -196,7 +193,7 @@ public function lessThan(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -208,7 +205,7 @@ public function greaterThanOrEquals(self|string|float|int $value): bool /** * @deprecated Use {@link greaterThanOrEquals()} instead. * - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -218,7 +215,7 @@ public function greatherThanOrEquals(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -235,7 +232,7 @@ public function lessThanOrEquals(self|string|float|int $value): bool * - `0` if the instance is equal to $value, or * - `1` if the instance is greater than $value. * - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return int */ @@ -250,7 +247,7 @@ public function compareTo(self|string|float|int $value): int /** * Add $value to this Decimal and return the sum as a new Decimal. * - * @param static|string|float|int $value + * @param self|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static @@ -284,7 +281,7 @@ protected function resultScale(self $a, self $b, ?int $scale = null): int * Subtract $value from this Decimal and return the difference as a new * Decimal. * - * @param static|string|float|int $value + * @param self|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static @@ -392,12 +389,12 @@ public function isPositive(): bool /** * Multiply this Decimal by $value and return the product as a new Decimal. * - * @param static|string|float|int $value + * @param self|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static */ - public function multiply(self|string|int|float $value, ?int $scale = null): static + public function multiply(self|string|float|int $value, ?int $scale = null): static { $decimal = static::create($value); if ($scale === null) { @@ -410,14 +407,14 @@ public function multiply(self|string|int|float $value, ?int $scale = null): stat /** * Divide this Decimal by $value and return the quotient as a new Decimal. * - * @param static|string|float|int $value + * @param self|string|float|int $value * @param int $scale Decimal places in the result * * @throws \DivisionByZeroError if $value is zero. * * @return static */ - public function divide(self|string|int|float $value, int $scale): static + public function divide(self|string|float|int $value, int $scale): static { $decimal = static::create($value); if ($decimal->isZero()) { @@ -430,7 +427,7 @@ public function divide(self|string|int|float $value, int $scale): static /** * This method is equivalent to the ** operator. * - * @param static|string|int $exponent + * @param self|string|int $exponent * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static @@ -441,7 +438,7 @@ public function pow(self|string|int $exponent, ?int $scale = null): static $scale = $this->scale(); } - return new static(bcpow((string)$this, (string)$exponent, $scale)); + return new static(bcpow((string)$this, (string)static::create($exponent), $scale)); } /** @@ -463,7 +460,7 @@ public function sqrt(?int $scale = null): static /** * This method is equivalent to the % operator. * - * @param static|string|int $value + * @param self|string|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static @@ -474,57 +471,48 @@ public function mod(self|string|int $value, ?int $scale = null): static $scale = $this->scale(); } - return new static(bcmod((string)$this, (string)$value, $scale)); + return new static(bcmod((string)$this, (string)static::create($value), $scale)); } /** * @param int $scale Decimal places in the result (only used with mode "half up") - * @param int $roundMode + * @param int $roundMode ROUND_HALF_UP, ROUND_CEIL or ROUND_FLOOR. Anything else rounds half up. + * + * @throws \InvalidArgumentException * * @return static */ public function round(int $scale = 0, int $roundMode = self::ROUND_HALF_UP): static { - $exponent = $scale + 1; + if ($scale < 0) { + throw new InvalidArgumentException('Scale must be >= 0.'); + } - switch ($roundMode) { - case static::ROUND_FLOOR: - $stringValue = (string)$this; + // Floor and ceil always round to a whole number in 1.x, the scale only labels the result. + $directed = in_array($roundMode, [static::ROUND_FLOOR, static::ROUND_CEIL], true); + $places = $directed ? 0 : $scale; - // If already an integer, return as is - if (!str_contains($stringValue, '.')) { - return new static($stringValue, $scale); - } + $value = (string)$this; + $truncated = bcadd($value, '0', $places); + if (bccomp($value, $truncated, $this->scale()) === 0) { + return new static($truncated, $scale); + } - // For positive values, truncate the decimal part (round down) - if (!$this->isNegative()) { - return new static(bcsub($stringValue, bcmod($stringValue, '1'), 0), $scale); - } + switch ($roundMode) { + case static::ROUND_FLOOR: + $result = $this->isNegative() ? bcsub($truncated, '1', 0) : $truncated; - // For negative values, round down away from zero - return new static(bcsub($stringValue, '1', 0), $scale); + break; case static::ROUND_CEIL: - $stringValue = (string)$this; - - // If already an integer, return as is - if (!str_contains($stringValue, '.')) { - return new static($stringValue, $scale); - } + $result = $this->isNegative() ? $truncated : bcadd($truncated, '1', 0); - // If negative, truncate (remove decimals without adding 1) - if ($this->isNegative()) { - return new static(bcsub($stringValue, '0', 0), $scale); - } - - // Otherwise, round up for positive numbers - return new static(bcadd($stringValue, '1', 0), $scale); - case static::ROUND_HALF_UP: + break; default: - $e = bcpow('10', (string)$exponent); - $v = bcdiv(bcadd(bcmul((string)$this, $e, 0), $this->isNegative() ? '-5' : '5'), $e, $scale); + $e = bcpow('10', (string)($scale + 1)); + $result = bcdiv(bcadd(bcmul($value, $e, 0), $this->isNegative() ? '-5' : '5'), $e, $scale); } - return new static($v, $scale); + return new static($result, $scale); } /** @@ -610,7 +598,9 @@ public function toInt(): int */ public function isBigInteger(): bool { - return bccomp($this->integralPart, (string)PHP_INT_MAX) === 1 || bccomp($this->integralPart, (string)PHP_INT_MIN) === -1; + $value = ($this->negative ? '-' : '') . $this->integralPart; + + return bccomp($value, (string)PHP_INT_MAX) === 1 || bccomp($value, (string)PHP_INT_MIN) === -1; } /** @@ -618,8 +608,7 @@ public function isBigInteger(): bool */ public function isBigDecimal(): bool { - return $this->isBigInteger() || - bccomp($this->fractionalPart, (string)PHP_INT_MAX) === 1 || bccomp($this->fractionalPart, (string)PHP_INT_MIN) === -1; + return $this->isBigInteger() || bccomp($this->fractionalPart ?: '0', (string)PHP_INT_MAX) === 1; } /** @@ -635,38 +624,29 @@ public function isBigDecimal(): bool */ public function toScientific(): string { - if ($this->integralPart) { - $exponent = 0; - $integralPart = $this->integralPart; - while ($integralPart >= 10) { - $integralPart /= 10; - $exponent++; - } + if ($this->isZero()) { + return $this->toString() . static::EXP_MARK . '0'; + } - $value = (string)$integralPart; - if (!str_contains($value, '.')) { - $value .= '.'; + if ($this->integralPart !== '0') { + $exponent = strlen($this->integralPart) - 1; + $digits = $this->integralPart . $this->fractionalPart; + if ($this->fractionalPart === '') { + $digits = rtrim($digits, '0'); } - $value .= $this->fractionalPart; } else { - $exponent = -1; - // 00002 - // 20000 - $fractionalPart = $this->fractionalPart; - while (str_starts_with($fractionalPart, '0')) { - $fractionalPart = substr($fractionalPart, 1); - $exponent--; - } - - $pos = abs($exponent) - 1; - $value = substr($this->fractionalPart, $pos, 1) . '.' . substr($this->fractionalPart, $pos + 1); + $digits = ltrim($this->fractionalPart, '0'); + $exponent = strlen($digits) - strlen($this->fractionalPart) - 1; } + // The radix mark stays even without decimals (`5.e0`) in 1.x. + $value = $digits[0] . static::RADIX_MARK . substr($digits, 1); + if ($this->negative) { $value = '-' . $value; } - return $value . 'e' . $exponent; + return $value . static::EXP_MARK . $exponent; } /** @@ -744,6 +724,7 @@ protected function copy(?string $integerPart = null, ?string $decimalPart = null if ($negative !== null) { $clone->negative = $negative; } + $clone->normalizeZero(); return $clone; } @@ -824,39 +805,33 @@ protected function fromFloat(string $value): void */ protected function fromScientific(string $value, ?int $scale): void { - $pattern = '/^(-?)(\d+(?:' . static::RADIX_MARK . '\d*)?|' . - '[' . static::RADIX_MARK . ']' . '\d+)' . static::EXP_MARK . '(-?\d*)?$/i'; - preg_match($pattern, $value, $matches); - if (!$matches) { + $pattern = '/^(-?)(\d+)(?:' . preg_quote(static::RADIX_MARK, '/') . '(\d*))?' . static::EXP_MARK . '([+-]?\d+)$/i'; + if (preg_match($pattern, $value, $matches) !== 1) { throw new InvalidArgumentException('Invalid scientific value/notation: ' . $value); } $this->negative = $matches[1] === '-'; - /** @var string $value */ - $value = preg_replace('/\b\.0$/', '', $matches[2]); - $exp = (int)$matches[3]; + $integralPart = $matches[2]; + // A mantissa of `1.0` is read as `1` in 1.x. + $fractionalPart = $matches[3] === '0' ? '' : $matches[3]; + $exp = (int)$matches[4]; if ($exp < 0) { - $this->integralPart = '0'; - /** @var string $value */ - $value = preg_replace('/^(\d+)(\.)?(\d+)$/', '${1}${3}', $value, 1); - $this->fractionalPart = str_repeat('0', -$exp - 1) . $value; - - if ($scale !== null) { - $this->fractionalPart = str_pad($this->fractionalPart, $scale, '0'); - } + $integralPart = str_pad($integralPart, -$exp + 1, '0', STR_PAD_LEFT); + $fractionalPart = substr($integralPart, $exp) . $fractionalPart; + $integralPart = substr($integralPart, 0, $exp); } else { - $this->integralPart = bcmul($matches[2], bcpow('10', (string)$exp)); + $integralPart .= substr(str_pad($fractionalPart, $exp, '0'), 0, $exp); + $fractionalPart = substr($fractionalPart, $exp); + } - $pos = strlen($this->integralPart); - if (str_contains($value, '.')) { - $pos++; - } - $this->fractionalPart = rtrim(substr($value, $pos), '.'); + $this->integralPart = ltrim($integralPart, '0') ?: '0'; + $this->fractionalPart = $fractionalPart; - if ($scale !== null) { - $this->fractionalPart = str_pad($this->fractionalPart, $scale - strlen($this->integralPart), '0'); - } + if ($scale !== null) { + // 1.x pads positive exponents to the scale minus the integral digits. + $length = $exp < 0 ? $scale : $scale - strlen($this->integralPart); + $this->fractionalPart = str_pad($this->fractionalPart, $length, '0'); } } @@ -870,6 +845,11 @@ protected function fromScientific(string $value, ?int $scale): void */ protected function setScale(?int $scale, bool $strict): void { + if ($scale !== null && $scale < 0) { + throw new InvalidArgumentException('Scale must be >= 0.'); + } + + // A scale of 0 is treated like no scale in 1.x and does not cut off decimals. $calculatedScale = strlen($this->fractionalPart); if ($scale && $calculatedScale > $scale) { if ($strict) { @@ -881,4 +861,16 @@ protected function setScale(?int $scale, bool $strict): void $this->scale = $scale ?? $calculatedScale; } + + /** + * Zero has no sign, `-0.00` would otherwise report as negative. + * + * @return void + */ + protected function normalizeZero(): void + { + if ($this->negative && $this->isZero()) { + $this->negative = false; + } + } } diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index 7ba553f..aa5ad49 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -12,6 +12,7 @@ use DivisionByZeroError; use InvalidArgumentException; use PhpCollective\DecimalObject\Decimal; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Stringable; use TypeError; @@ -24,7 +25,7 @@ class DecimalTest extends TestCase * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('baseProvider')] + #[DataProvider('baseProvider')] public function testNewObject(mixed $value, string $expected): void { $decimal = new Decimal($value); @@ -50,7 +51,7 @@ public function testNewObjectScientific(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('baseProvider')] + #[DataProvider('baseProvider')] public function testCreate(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -142,7 +143,7 @@ public function __toString(): string * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('invalidValuesProvider')] + #[DataProvider('invalidValuesProvider')] public function testNewObjectWithInvalidValueThrowsException(mixed $value): void { $this->expectException(InvalidArgumentException::class); @@ -169,7 +170,7 @@ public static function invalidValuesProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('truncateProvider')] + #[DataProvider('truncateProvider')] public function testTruncate(mixed $input, int $scale, string $expected): void { $decimal = Decimal::create($input); @@ -197,7 +198,7 @@ public static function truncateProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('integerProvider')] + #[DataProvider('integerProvider')] public function testIsInteger(mixed $value, bool $expected): void { $decimal = Decimal::create($value); @@ -229,7 +230,7 @@ public static function integerProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('zeroProvider')] + #[DataProvider('zeroProvider')] public function testIsZero(mixed $value, bool $expected): void { $decimal = Decimal::create($value); @@ -260,7 +261,7 @@ public static function zeroProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareZeroProvider')] + #[DataProvider('compareZeroProvider')] public function testIsPositive(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -273,7 +274,7 @@ public function testIsPositive(mixed $input, int $expected): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareZeroProvider')] + #[DataProvider('compareZeroProvider')] public function testIsNegative(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -307,7 +308,7 @@ public static function compareZeroProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('scaleProvider')] + #[DataProvider('scaleProvider')] public function testScale(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -338,7 +339,7 @@ public static function scaleProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('scientificProvider')] + #[DataProvider('scientificProvider')] public function testToScientific(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -411,7 +412,7 @@ public function testToFloat(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('bigFloatDataProvider')] + #[DataProvider('bigFloatDataProvider')] public function testToFloatForBigDecimalThrowsAnException(string $value): void { $decimal = Decimal::create($value); @@ -450,7 +451,7 @@ public function testToInt(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('bigIntDataProvider')] + #[DataProvider('bigIntDataProvider')] public function testToIntForBigIntThrowsAnException(string $value): void { $decimal = Decimal::create($value); @@ -605,7 +606,7 @@ public function testEquals(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('roundProvider')] + #[DataProvider('roundProvider')] public function testRound(mixed $value, int $scale, string $expected): void { $decimal = Decimal::create($value); @@ -664,7 +665,7 @@ public static function roundProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('floorProvider')] + #[DataProvider('floorProvider')] public function testFloor(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -717,7 +718,7 @@ public static function floorProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('ceilProvider')] + #[DataProvider('ceilProvider')] public function testCeil(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -770,7 +771,7 @@ public static function ceilProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] + #[DataProvider('compareProvider')] public function testGreaterThan(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -784,7 +785,7 @@ public function testGreaterThan(mixed $a, mixed $b, int $expected): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] + #[DataProvider('compareProvider')] public function testLessThan(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -798,7 +799,7 @@ public function testLessThan(mixed $a, mixed $b, int $expected): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] + #[DataProvider('compareProvider')] public function testGreaterEquals(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -812,7 +813,7 @@ public function testGreaterEquals(mixed $a, mixed $b, int $expected): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] + #[DataProvider('compareProvider')] public function testLessEquals(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -876,7 +877,7 @@ public function testSubtract(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('multiplicationProvider')] + #[DataProvider('multiplicationProvider')] public function testMultiply(mixed $a, mixed $b, ?int $scale, string $expected): void { $decimal = Decimal::create($a); @@ -915,7 +916,7 @@ public static function multiplicationProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('multiplicationLegacyProvider')] + #[DataProvider('multiplicationLegacyProvider')] public function testMultiplyLegacy(mixed $a, mixed $b, ?int $scale, string $expected): void { $decimal = Decimal::create($a); @@ -940,7 +941,7 @@ public static function multiplicationLegacyProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('divisionProvider')] + #[DataProvider('divisionProvider')] public function testDivide(mixed $a, mixed $b, int $scale, string $expected): void { $decimal = Decimal::create($a); @@ -1049,4 +1050,226 @@ public function testPrecisionLossFail(): void Decimal::create('0.123', 2, true); } + + /** + * @param mixed $value + * @param string $expected + * + * @return void + */ + #[DataProvider('normalizationProvider')] + public function testNewObjectNormalization(mixed $value, string $expected): void + { + $this->assertSame($expected, (string)new Decimal($value)); + } + + /** + * @return array + */ + public static function normalizationProvider(): array + { + return [ + 'leading zeros' => ['0010', '10'], + 'negative leading zeros' => ['-007', '-7'], + 'positive sign leading zeros' => ['+007', '7'], + 'positive sign omitted zero' => ['+.5', '0.5'], + 'negative zero with decimals' => ['-0.0', '0.0'], + 'negative double zero' => ['-00', '0'], + 'trailing radix' => ['5.', '5'], + 'explicit positive exponent' => ['1e+5', '100000'], + 'uppercase exponent' => ['1E5', '100000'], + 'multi digit mantissa' => ['12.5e-1', '1.25'], + 'integer mantissa' => ['100e-2', '1.00'], + 'integer mantissa trailing zero' => ['120e-1', '12.0'], + 'long mantissa' => ['123.456e-2', '1.23456'], + 'negative mantissa' => ['-1.5e-2', '-0.015'], + 'omitted zero mantissa' => ['.5e1', '5'], + 'small mantissa' => ['0.00012e3', '0.12'], + 'zero exponent' => ['1.5e0', '1.5'], + 'negative zero exponent' => ['-0e5', '0'], + 'zero mantissa' => ['0.0e-3', '0.000'], + 'large float' => [1e25, '10000000000000000000000000'], + 'small float' => [1.5e-7, '0.00000015'], + 'negative zero float' => [-0.0, '0'], + ]; + } + + /** + * @param float $value + * + * @return void + */ + #[DataProvider('nonFiniteProvider')] + public function testNewObjectNonFiniteFloat(float $value): void + { + $this->expectException(InvalidArgumentException::class); + + new Decimal($value); + } + + /** + * @return array + */ + public static function nonFiniteProvider(): array + { + return [ + 'inf' => [INF], + 'negative inf' => [-INF], + 'nan' => [NAN], + ]; + } + + /** + * @return void + */ + public function testNewObjectNegativeScale(): void + { + $this->expectException(InvalidArgumentException::class); + + new Decimal('1.56', -1); + } + + /** + * @return void + */ + public function testNewObjectScientificWithScale(): void + { + $this->assertSame('0.001200', (string)new Decimal('1.2e-3', 6)); + $this->assertSame('0.0012', (string)new Decimal('1.23456e-3', 4)); + } + + /** + * @return void + */ + public function testNoNegativeZero(): void + { + $decimal = new Decimal('-0.0'); + $this->assertFalse($decimal->isNegative()); + $this->assertSame(0, $decimal->sign()); + + $this->assertSame('0', (string)(new Decimal('0'))->negate()); + $this->assertSame('0.00', (string)(new Decimal('0.00'))->negate()); + $this->assertSame('0', (string)(new Decimal('-0.5'))->truncate()); + $this->assertSame('0.0', (string)(new Decimal('-0.05'))->truncate(1)); + $this->assertSame('0.00', (string)new Decimal('-0.001', 2)); + $this->assertFalse((new Decimal('-0.5'))->truncate()->isNegative()); + } + + /** + * @param string $value + * @param int $scale + * @param int $mode + * @param string $expected + * + * @return void + */ + #[DataProvider('roundModeProvider')] + public function testRoundMode(string $value, int $scale, int $mode, string $expected): void + { + $this->assertSame($expected, (string)(new Decimal($value))->round($scale, $mode)); + } + + /** + * @return array + */ + public static function roundModeProvider(): array + { + return [ + 'floor negative whole' => ['-1.0', 0, Decimal::ROUND_FLOOR, '-1'], + 'floor positive whole' => ['1.00', 0, Decimal::ROUND_FLOOR, '1'], + 'floor negative' => ['-1.5', 0, Decimal::ROUND_FLOOR, '-2'], + 'ceil positive whole' => ['1.0', 0, Decimal::ROUND_CEIL, '1'], + 'ceil negative whole' => ['-1.00', 0, Decimal::ROUND_CEIL, '-1'], + 'ceil positive' => ['1.5', 0, Decimal::ROUND_CEIL, '2'], + 'floor small negative' => ['-0.001', 0, Decimal::ROUND_FLOOR, '-1'], + 'floor zero with decimals' => ['0.00', 0, Decimal::ROUND_FLOOR, '0'], + 'ceil small positive' => ['0.001', 0, Decimal::ROUND_CEIL, '1'], + 'ceil zero with decimals' => ['0.00', 0, Decimal::ROUND_CEIL, '0'], + 'half up negative' => ['-1.005', 2, Decimal::ROUND_HALF_UP, '-1.01'], + 'half up to zero' => ['-0.004', 2, Decimal::ROUND_HALF_UP, '0.00'], + ]; + } + + /** + * @return void + */ + public function testRoundNegativeScale(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('15'))->round(-1); + } + + /** + * @param string $value + * @param string $expected + * + * @return void + */ + #[DataProvider('scientificEdgeProvider')] + public function testToScientificEdgeCases(string $value, string $expected): void + { + $decimal = new Decimal($value); + $this->assertSame($expected, $decimal->toScientific()); + $this->assertSame($value, (string)new Decimal($decimal->toScientific())); + } + + /** + * @return array + */ + public static function scientificEdgeProvider(): array + { + return [ + 'zero' => ['0', '0e0'], + 'zero with scale' => ['0.00', '0.00e0'], + 'single digit' => ['5', '5.e0'], + 'power of ten' => ['10', '1.e1'], + 'large power of ten' => ['100000', '1.e5'], + 'trailing zero integer' => ['120', '1.2e2'], + 'trailing zero integer with fraction' => ['120.50', '1.2050e2'], + 'beyond float precision' => ['1234567890123456789012.5', '1.2345678901234567890125e21'], + 'beyond int precision' => ['12345678901234567', '1.2345678901234567e16'], + 'single fraction digit' => ['0.5', '5.e-1'], + 'small fraction' => ['0.00012', '1.2e-4'], + 'negative with scale' => ['-12.50', '-1.250e1'], + ]; + } + + /** + * @return void + */ + public function testToIntBoundaries(): void + { + $this->assertSame(PHP_INT_MIN, (new Decimal((string)PHP_INT_MIN))->toInt()); + $this->assertSame(PHP_INT_MAX, (new Decimal((string)PHP_INT_MAX))->toInt()); + $this->assertFalse((new Decimal((string)PHP_INT_MIN))->isBigInteger()); + $this->assertTrue((new Decimal('-9223372036854775809'))->isBigInteger()); + $this->assertTrue((new Decimal('9223372036854775808'))->isBigInteger()); + } + + /** + * @return void + */ + public function testModOperands(): void + { + $decimal = new Decimal('10'); + + $this->assertSame('0', (string)$decimal->mod('1e1')); + $this->assertSame('1', (string)$decimal->mod(new Decimal('3'))); + $this->assertSame('0.1', (string)$decimal->mod('0.3', 1)); + $this->assertSame('1.5', (string)(new Decimal('10.5'))->mod('3')); + } + + /** + * @return void + */ + public function testPowOperands(): void + { + $decimal = new Decimal('2'); + + $this->assertSame('100', (string)(new Decimal('10'))->pow('2e0')); + $this->assertSame('4', (string)$decimal->pow(new Decimal('2'))); + $this->assertSame('0.50', (string)$decimal->pow(-1, 2)); + $this->assertSame('2.25', (string)(new Decimal('1.50'))->pow(2)); + } } From a43e9133819f2111be8d46a9719a47b315bbe95c Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 16:52:01 +0200 Subject: [PATCH 27/29] Add a documentation website (#22) The docs were a single README listing method names. This adds a VitePress site under docs/, deployed to GitHub Pages by a workflow on pushes to master, with the same setup dereuromark/cakephp-decimal uses. Guide pages cover installation, creating decimals, arithmetic, scale, rounding, comparison and conversion. Reference pages hold the full API table, integrations, alternatives (moved over from the wiki) and upgrade notes for the behavior changes since 1.3. The root README now points at the site and keeps a short summary. The contributing notes moved there from docs/README.md. docs/ is excluded from the Composer dist archive. --- .gitattributes | 1 + .github/workflows/deploy-docs.yml | 60 + README.md | 71 +- docs/.gitignore | 3 + docs/.vitepress/config.ts | 53 + docs/.vitepress/theme/custom.css | 27 + docs/.vitepress/theme/index.ts | 4 + docs/README.md | 111 +- docs/guide/arithmetic.md | 92 + docs/guide/comparison.md | 52 + docs/guide/conversion.md | 88 + docs/guide/creating.md | 93 + docs/guide/getting-started.md | 63 + docs/guide/installation.md | 18 + docs/guide/rounding.md | 67 + docs/guide/scale.md | 76 + docs/index.md | 33 + docs/package-lock.json | 2637 +++++++++++++++++++++++++++++ docs/package.json | 17 + docs/public/favicon.svg | 1 + docs/public/logo.svg | 1 + docs/reference/alternatives.md | 25 + docs/reference/api.md | 103 ++ docs/reference/integrations.md | 32 + docs/reference/upgrading.md | 47 + 25 files changed, 3641 insertions(+), 134 deletions(-) create mode 100644 .github/workflows/deploy-docs.yml create mode 100644 docs/.gitignore create mode 100644 docs/.vitepress/config.ts create mode 100644 docs/.vitepress/theme/custom.css create mode 100644 docs/.vitepress/theme/index.ts create mode 100644 docs/guide/arithmetic.md create mode 100644 docs/guide/comparison.md create mode 100644 docs/guide/conversion.md create mode 100644 docs/guide/creating.md create mode 100644 docs/guide/getting-started.md create mode 100644 docs/guide/installation.md create mode 100644 docs/guide/rounding.md create mode 100644 docs/guide/scale.md create mode 100644 docs/index.md create mode 100644 docs/package-lock.json create mode 100644 docs/package.json create mode 100644 docs/public/favicon.svg create mode 100644 docs/public/logo.svg create mode 100644 docs/reference/alternatives.md create mode 100644 docs/reference/api.md create mode 100644 docs/reference/integrations.md create mode 100644 docs/reference/upgrading.md diff --git a/.gitattributes b/.gitattributes index 6ec0a9e..e6608e2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,5 +19,6 @@ Makefile export-ignore # Remove directories for archives .docker/ export-ignore +docs/ export-ignore .github/ export-ignore tests/ export-ignore diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..af440c4 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,60 @@ +name: Deploy Documentation + +on: + push: + branches: + - master + paths: + - 'docs/**' + - '.github/workflows/deploy-docs.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: docs/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: docs + + - name: Build with VitePress + run: npm run docs:build + working-directory: docs + + - name: Upload artifact + uses: actions/upload-pages-artifact@v5 + with: + path: docs/.vitepress/dist + + deploy: + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + needs: build + runs-on: ubuntu-latest + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/README.md b/README.md index 9193433..d0b1ee3 100644 --- a/README.md +++ b/README.md @@ -9,56 +9,67 @@ Decimal value object for PHP. +## Documentation + +**** + ## Background -When working with decimal (e.g. monetary) values, normal data types like int or float are not suitable for exact arithmetic. -Try out the following in PHP: + +Floats cannot represent most decimal fractions exactly, which makes them unsuitable for money and other values that must add up: + ```php -var_dump(0.1 + 0.2); // float(0.3) -var_dump(0.1 + 0.2 - 0.3); // float(5.5511151231258E-17) +var_dump(0.1 + 0.2 === 0.3); // bool(false) ``` -Handling them as strings is a workaround, but as value object you can more easily encapsulate some of the logic. +Handling such values as strings works, and a value object keeps the logic for it in one place: -### Alternatives -Solutions like [php-decimal](https://php-decimal.github.io/#introduction) require a custom PHP extension (would make it faster, but also more difficult for some -servers to be available). -Monetary values themselves could be handled also with [brick/money](https://github.com/brick/money) or [moneyphp/money](https://github.com/moneyphp/money). +```php +use PhpCollective\DecimalObject\Decimal; -For details see the [wiki](https://github.com/php-collective/decimal-object/wiki). +$total = Decimal::create('0.1')->add('0.2'); -Note: PHP 8.1 is covered by the 1.3 releases. For anything older, check out the [legacy library](https://github.com/spryker/decimal-object). +echo $total; // 0.3 +var_dump($total->equals('0.3')); // bool(true) +``` ## Features -- Super strict on precision/scale. Does not lose significant digits on its own. You need to `trim()` for this manually. -- Speaking API (no le, gt methods). -- Basic math operations and checks supported. -- Immutability. -- Handle very large and very small numbers. +- Strict about precision and scale. Significant digits are only dropped when you `trim()` or round. +- Readable API: `add()`, `greaterThan()`, `isZero()`. +- Arithmetic, rounding, comparison and conversion. +- Immutable. +- Handles numbers beyond the integer and float range. +- No custom PHP extension, only `bcmath`. ## Installation -### Requirements - -- PHP 8.2+ -- `bcmath` PHP extension enabled - -### Composer (preferred) ``` composer require php-collective/decimal-object ``` +Requires PHP 8.2+ and the `bcmath` extension. PHP 8.1 is covered by the 1.3 releases. For anything older, use the [legacy library](https://github.com/spryker/decimal-object). + ## Usage -See [Documentation](/docs) for more details. +- [Getting started](https://php-collective.github.io/decimal-object/guide/getting-started) +- [Arithmetic](https://php-collective.github.io/decimal-object/guide/arithmetic) +- [Precision and scale](https://php-collective.github.io/decimal-object/guide/scale) +- [Rounding](https://php-collective.github.io/decimal-object/guide/rounding) +- [API reference](https://php-collective.github.io/decimal-object/reference/api) + +## Integrations + +- [dereuromark/cakephp-decimal](https://dereuromark.github.io/cakephp-decimal/) as decimal type for the CakePHP ORM. + +See [Alternatives](https://php-collective.github.io/decimal-object/reference/alternatives) for how this library compares to php-decimal, brick/math and the money libraries. + +## Contributing -## Implementations -The following libraries are using the `Decimal` value object: +Pull requests are welcome: -- [dereuromark/cakephp-decimal](https://github.com/dereuromark/cakephp-decimal) as decimal type replacement for CakePHP ORM. +- Target `master` for bug fixes and backwards compatible improvements. +- Target `next` (the next major version) for anything that breaks backwards compatibility. -The original library here was written 2019 for [Spryker](https://spryker.com/) ecommerce framework -and since then battle-tested through millions of customers and orders as well as other complex calculations under the hood. -This here is the freshly upgraded and independently maintained version for the PHP ecosystem as a whole. +## History -Enjoy! +The library was written in 2019 for the [Spryker](https://spryker.com/) commerce framework and has been used there in order and price calculations since. This repository is the independently maintained continuation for the PHP ecosystem as a whole. diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..2c1fa99 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.vitepress/dist/ +.vitepress/cache/ diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts new file mode 100644 index 0000000..20d87aa --- /dev/null +++ b/docs/.vitepress/config.ts @@ -0,0 +1,53 @@ +import { defineConfig } from 'vitepress' + +export default defineConfig({ + title: 'Decimal Object', + description: 'Immutable decimal value object for PHP, built on bcmath.', + base: '/decimal-object/', + srcExclude: ['README.md'], + head: [['link', { rel: 'icon', type: 'image/svg+xml', href: '/decimal-object/favicon.svg' }]], + themeConfig: { + logo: '/logo.svg', + nav: [ + { text: 'Installation', link: '/guide/installation' }, + { text: 'Getting started', link: '/guide/getting-started' }, + { text: 'API', link: '/reference/api' }, + { + text: 'Ecosystem', + items: [ + { text: 'Integrations', link: '/reference/integrations' }, + { text: 'CakePHP Decimal', link: 'https://dereuromark.github.io/cakephp-decimal/' }, + { text: 'Packagist', link: 'https://packagist.org/packages/php-collective/decimal-object' }, + ], + }, + ], + sidebar: [ + { + text: 'Guide', + items: [ + { text: 'Installation', link: '/guide/installation' }, + { text: 'Getting started', link: '/guide/getting-started' }, + { text: 'Creating decimals', link: '/guide/creating' }, + { text: 'Arithmetic', link: '/guide/arithmetic' }, + { text: 'Precision and scale', link: '/guide/scale' }, + { text: 'Rounding', link: '/guide/rounding' }, + { text: 'Comparison and checks', link: '/guide/comparison' }, + { text: 'Conversion', link: '/guide/conversion' }, + ], + }, + { + text: 'Reference', + items: [ + { text: 'API', link: '/reference/api' }, + { text: 'Integrations', link: '/reference/integrations' }, + { text: 'Alternatives', link: '/reference/alternatives' }, + { text: 'Upgrading', link: '/reference/upgrading' }, + ], + }, + ], + search: { provider: 'local' }, + socialLinks: [{ icon: 'github', link: 'https://github.com/php-collective/decimal-object' }], + editLink: { pattern: 'https://github.com/php-collective/decimal-object/edit/master/docs/:path' }, + footer: { message: 'Released under the MIT License.', copyright: 'Copyright The PHP Collective' }, + }, +}) diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css new file mode 100644 index 0000000..0320544 --- /dev/null +++ b/docs/.vitepress/theme/custom.css @@ -0,0 +1,27 @@ +:root { + --vp-c-brand-1: #1e40af; + --vp-c-brand-2: #3b82f6; + --vp-c-brand-3: #1e40af; + --vp-c-brand-soft: rgba(59, 130, 246, 0.14); + + --vp-home-hero-name-color: transparent; + --vp-home-hero-name-background: linear-gradient(135deg, #1e40af 0%, #0891b2 100%); + --vp-home-hero-image-background-image: linear-gradient(135deg, #1e40af 0%, #0891b2 100%); + --vp-home-hero-image-filter: blur(42px); +} + +.dark { + --vp-c-brand-1: #93c5fd; + --vp-c-brand-2: #60a5fa; + --vp-c-brand-3: #1e40af; +} + +.vp-doc table code, +.vp-doc p code, +.vp-doc li code { + white-space: nowrap; +} + +.vp-doc .custom-block.tip { + border-color: var(--vp-c-brand-1); +} diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts new file mode 100644 index 0000000..42fe9a9 --- /dev/null +++ b/docs/.vitepress/theme/index.ts @@ -0,0 +1,4 @@ +import DefaultTheme from 'vitepress/theme' +import './custom.css' + +export default DefaultTheme diff --git a/docs/README.md b/docs/README.md index b586b0a..25b6e28 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,108 +1,11 @@ -# Decimal VO documentation +# Decimal Object documentation -Decimal value object for PHP. +Read the [documentation website](https://php-collective.github.io/decimal-object/) or start with [Installation](guide/installation.md) and [Getting started](guide/getting-started.md). -## Basic information +To preview the site locally: -The value objects are immutable, always assign them when running modifications on them. - -## Usage - -You can create the value object from -- string -- integer -- float -- object that implements Stringable (and numeric `toString()` method) - -```php -use PhpCollective\DecimalObject\Decimal; - -$value = '1.234'; -$decimalObject = Decimal::create($value); - -echo 'Value: ' . $decimalObject; +```sh +cd docs +npm install +npm run docs:dev ``` - -It will auto-cast to string where necessary, you can force it using `(string)$decimalObject`. - -### Operations - -Transformation: -- `sign()`: Returns int `0` if zero, `-1` if negative, or `1` if positive. -- `toString()`: Default casting mechanism (this method is equivalent to a cast to string). -- `toFloat()`: Returns some approximation of this Decimal as a PHP native float. -- `toInt()`: Returns integer value (this method is equivalent to a cast to integer). - -These return a new object: -- `absolute()`: Returns the absolute (positive) value of this decimal. -- `negate()`: Returns the negation (positive if negative and vice versa). -- `trim()`: Remove trailing zeros after the comma (same value, but different semantic meaning in terms of precision/scale). - -There is only one static method and acts as a convenience wrapper to create an object: -- `create()`: Internally does `new Decimal($value)`, allows for easier chaining without need of `()` wrapping. - Use this if your input can already be the `Decimal` object as it will then just be re-used. Constructor creation - builds a new object. - -### Checks - -Boolean checks: -- `isZero()`: If exactly `0`. -- `isNegative()`: If < `0`. -- `isPositive()`: If > `0` (zero itself is not included). - -### Comparison - -- `compareTo()`: Compare this Decimal to another value. -- `equals()`: This method is equivalent to the `==` operator. -- `greaterThan()`: Returns true if the Decimal is greater than given value. -- `lessThan()`: Returns true if the Decimal is smaller than given value. -- `greaterThanOrEquals()`: Returns true if the Decimal is greater than or equal to the given value. -- `lessThanOrEquals()`: Returns true if the Decimal is greater than or equal to the given value. - -### Math and Calculation -You can use -- `add()` -- `subtract()` -- `multiply()` -- `divide()` -- `round()`: Round using different modes. -- `truncate()`: Truncate after places of decimal. -- `mod()` -- `pow()` -- `sqrt()` - -```php -$decimalOne = Decimal::create('1.1'); -$decimalTwo = Decimal::create('2.2'); - -$decimalAdded = $decimalOne->add($decimalTwo); // Now '3.3' -``` - -Note that due to immutability `$decimalOne` is not modified here. The re-assignment is necessary for the operation to persist. - -### Precision/Scale -Operations like `add()` use the higher one of the scales of the operands. -If both are the same, they would also stay the same. - -With other operations like `multiply()`, the scale would be the addition of both operands' scale by default: -```php -$decimalOne = Decimal::create('1.55'); -$decimalTwo = Decimal::create('2.00'); - -echo $decimalOne->multiply($decimalTwo); // Prints '3.1000' -``` - -To produce a result with a different number of decimal places, provide a value for the `$scale` parameter: -```php -$decimalOne = Decimal::create('1.55'); -$decimalTwo = Decimal::create('2.00'); - -echo $decimalOne->multiply($decimalTwo, 2); // Prints '3.10' -``` - - -## Contributing - -You can contribute as pull request directly: -- Target `master` branch for: bugfixes, improvements that are BC -- Target `next` branch (next major version) for: Any BC breaking behavior, cleanup like removing deprecations or alike diff --git a/docs/guide/arithmetic.md b/docs/guide/arithmetic.md new file mode 100644 index 0000000..eaa28fa --- /dev/null +++ b/docs/guide/arithmetic.md @@ -0,0 +1,92 @@ +# Arithmetic + +Every operand can be a `Decimal`, a numeric string, an integer or a float. The result is always a new `Decimal`. + +```php +use PhpCollective\DecimalObject\Decimal; + +$a = Decimal::create('1.50'); +``` + +## Add and subtract + +```php +echo $a->add('2.125'); // 3.625 +echo Decimal::create('5')->subtract('7.5'); // -2.5 +``` + +The result has the larger scale of the two operands. + +## Multiply + +```php +echo Decimal::create('1.55')->multiply('2.00'); // 3.1000 +echo Decimal::create('1.55')->multiply('2.00', 2); // 3.10 +``` + +Without a scale argument the scales of both operands are added, which is always enough to hold the exact product. + +## Divide + +`divide()` requires the scale, because a quotient can have endless decimals: + +```php +echo Decimal::create('10')->divide('3', 4); // 3.3333 +echo Decimal::create('2')->divide('3', 2); // 0.66 +``` + +The result is cut off at the scale, not rounded. Divide with one more digit and round when you need the nearest value: + +```php +echo Decimal::create('2')->divide('3', 3)->round(2); // 0.67 +``` + +Dividing by zero throws a `DivisionByZeroError`. + +## Modulo + +```php +echo Decimal::create('7')->mod(2); // 1 +echo Decimal::create('-7')->mod(2); // -1 +echo Decimal::create('10')->mod('0.3', 1); // 0.1 +``` + +The sign follows the left operand, as with PHP's `%`. A zero operand throws a `DivisionByZeroError`. + +::: warning Fractional divisors +The result has the scale of the value `mod()` is called on. `Decimal::create('10')->mod('0.3')` therefore returns `0`, because the remainder `0.1` does not fit a scale of 0. Pass the scale as second argument when the divisor has more decimals. +::: + +## Power and square root + +```php +echo Decimal::create('8')->pow(2); // 64 +echo Decimal::create('1.5')->pow(2); // 2.2 +echo Decimal::create('1.5')->pow(2, 2); // 2.25 +echo Decimal::create('2')->pow(-1, 2); // 0.50 + +echo Decimal::create('18')->sqrt(); // 4 +echo Decimal::create('18')->sqrt(5); // 4.24264 +``` + +Both default to the scale of the value they are called on and cut off further digits, so pass a scale when the result needs more decimals than the input has. + +The exponent of `pow()` must be a whole number, and `sqrt()` of a negative value is not defined. bcmath reports both with a `ValueError`. + +## Sign + +```php +echo Decimal::create('-23.44')->absolute(); // 23.44 +echo Decimal::create('23.44')->negate(); // -23.44 +``` + +## Chaining + +```php +$total = Decimal::create('100') + ->subtract('12.50') + ->multiply('1.19') + ->round(2); + +echo $total; // 104.13 +``` diff --git a/docs/guide/comparison.md b/docs/guide/comparison.md new file mode 100644 index 0000000..e27a6c1 --- /dev/null +++ b/docs/guide/comparison.md @@ -0,0 +1,52 @@ +# Comparison and checks + +## Comparing values + +Comparison looks at the value only. Scale is ignored: + +```php +use PhpCollective\DecimalObject\Decimal; + +$value = Decimal::create('1.50'); + +var_dump($value->equals('1.5')); // bool(true) +var_dump($value->greaterThan('1.49')); // bool(true) +var_dump($value->lessThan(2)); // bool(true) +var_dump($value->greaterThanOrEquals('1.5')); // bool(true) +var_dump($value->lessThanOrEquals('1.5')); // bool(true) +``` + +Each method accepts a `Decimal`, a numeric string, an integer or a float. + +`compareTo()` returns `-1`, `0` or `1` and fits `usort()`: + +```php +$values = [Decimal::create('2.5'), Decimal::create('-1'), Decimal::create('2.05')]; + +usort($values, fn (Decimal $a, Decimal $b) => $a->compareTo($b)); +// -1, 2.05, 2.5 +``` + +::: warning Do not use == or === +`==` on two objects compares their properties, so `1.50 == 1.5` is `false`. `===` compares identity. Use `equals()`. +::: + +## Sign and zero + +| Method | `-3.2` | `0.00` | `3.2` | +| --- | --- | --- | --- | +| `isNegative()` | `true` | `false` | `false` | +| `isZero()` | `false` | `true` | `false` | +| `isPositive()` | `false` | `false` | `true` | +| `sign()` | `-1` | `0` | `1` | + +Zero is neither positive nor negative. + +## Whole numbers + +`isInteger()` is true when all decimals are zero: + +```php +var_dump(Decimal::create('5.00')->isInteger()); // bool(true) +var_dump(Decimal::create('5.01')->isInteger()); // bool(false) +``` diff --git a/docs/guide/conversion.md b/docs/guide/conversion.md new file mode 100644 index 0000000..132bce5 --- /dev/null +++ b/docs/guide/conversion.md @@ -0,0 +1,88 @@ +# Conversion + +## String + +A `Decimal` casts to its exact decimal string, including trailing zeros: + +```php +use PhpCollective\DecimalObject\Decimal; + +$value = Decimal::create('19.90'); + +echo $value; // 19.90 +$string = (string)$value; +$string = $value->toString(); +``` + +Feeding that string back into `Decimal::create()` gives an equal object. This is the format to store and to send over an API. + +The scale comes back as the number of decimals in the string. If you created the object with a larger scale, such as `Decimal::create('0.1', 4)`, the string is still `0.1`, so pass the scale again when you recreate it. + +## JSON + +`Decimal` implements `JsonSerializable` and encodes as a string: + +```php +echo json_encode(['total' => Decimal::create('19.90')]); +// {"total":"19.90"} +``` + +A JSON number would be read back as a float by most clients, which defeats the purpose. + +## Integer + +`toInt()` drops the decimals without rounding: + +```php +echo Decimal::create('-23.74')->toInt(); // -23 +``` + +Values outside the range of a PHP integer throw a `TypeError`. Check with `isBigInteger()` first when the value may be large: + +```php +$value = Decimal::create('9223372036854775808'); + +if (!$value->isBigInteger()) { + $int = $value->toInt(); +} +``` + +## Float + +```php +var_dump(Decimal::create('-23.44')->toFloat()); // float(-23.44) +``` + +A float is an approximation. Use it for display libraries and charts, not for further calculation. `toFloat()` throws a `TypeError` when `isBigDecimal()` is true, which is the case when the integral or the fractional digits exceed `PHP_INT_MAX`. + +## Scientific notation + +```php +echo Decimal::create('-22.345')->toScientific(); // -2.2345e1 +echo Decimal::create('0.00230')->toScientific(); // 2.30e-3 +echo Decimal::create('1200')->toScientific(); // 1.2e3 +echo Decimal::create('0')->toScientific(); // 0e0 +``` + +Decimals are kept as written. Call `trim()` first for the shortest form. A mantissa without decimals keeps its decimal point, so `5` becomes `5.e0`. + +## Localized output + +The object has no formatting of its own. Pass the string to `NumberFormatter` or to your framework's number helper. Most formatters convert to float internally, so check their precision limits for large values. + +## Debugging + +`var_dump()` and similar tools show the value and the scale instead of the internal parts: + +```php +var_dump(Decimal::create('1.10')); +``` + +``` +object(PhpCollective\DecimalObject\Decimal)#2 (2) { + ["value"]=> + string(4) "1.10" + ["scale"]=> + int(2) +} +``` diff --git a/docs/guide/creating.md b/docs/guide/creating.md new file mode 100644 index 0000000..e771455 --- /dev/null +++ b/docs/guide/creating.md @@ -0,0 +1,93 @@ +# Creating decimals + +```php +use PhpCollective\DecimalObject\Decimal; + +$a = Decimal::create('1.234'); +$b = new Decimal('1.234'); +``` + +Both forms take the same arguments. `create()` reads better in a chain and also accepts an existing `Decimal`. + +## Accepted input + +| Input | Example | Result | +| --- | --- | --- | +| Decimal string | `'-5.000067'` | `-5.000067` | +| Integer | `-25000` | `-25000` | +| Float | `1.1` | `1.1` | +| Scientific notation | `'6.22e8'`, `'2.2e-6'` | `622000000`, `0.0000022` | +| `Stringable` object with a numeric string | another `Decimal` | same value | + +Strings are normalized, the value stays the same: + +| Input | Result | +| --- | --- | +| `'.5'` | `0.5` | +| `'+5.10'` | `5.10` | +| `'0005'` | `5` | +| `' 1.5 '` | `1.5` | +| `'-0.0'` | `0.0` | + +Zero never carries a sign. + +Trailing zeros are kept. `'5.10'` has a scale of 2, and the object will not drop that zero unless you call `trim()`. + +## Invalid input + +Anything that is not a plain number throws an `InvalidArgumentException`: + +```php +Decimal::create('abc'); // Invalid non numeric value +Decimal::create('1,5'); // Invalid non numeric value +Decimal::create(INF); // Invalid non finite value +``` + +Localized strings such as `'1.018,90'` are rejected. Parse them with `NumberFormatter` or your framework's locale tools first. + +::: warning Floats +A float is converted with PHP's own float to string rules, which round to the `precision` ini setting. That hides small errors but cannot restore digits the float never had. For money and other exact values, read the number as a string from the request or the database. +::: + +::: warning Scientific notation from untrusted input +`'1e1000000000'` is twelve characters long and expands to a string of a billion digits. If the value comes from a request, reject exponent notation or check the size of the exponent before you create the object. A limit on the input length does not help. +::: + +## Setting a scale + +The second argument fixes the number of decimal places: + +```php +echo Decimal::create('0.129', 2); // 0.12 +``` + +Digits beyond the scale are cut off, not rounded. Use [`round()`](./rounding) when you want rounding. + +A scale larger than the input records the scale without padding the string. Results of later calculations use it: + +```php +$value = Decimal::create('0.1', 4); + +echo $value; // 0.1 +echo $value->scale(); // 4 +echo $value->add('0.2'); // 0.3000 +``` + +::: warning A scale of 0 +`0` is read as "no scale given" and does not cut off decimals: `Decimal::create('1.5', 0)` stays `1.5`. Use `truncate()` or `round()` to get a whole number. The next major version will treat `0` like any other scale. +::: + +### Strict mode + +Pass `true` as the third argument to refuse input that would lose digits: + +```php +Decimal::create('0.123', 2, true); +// InvalidArgumentException: Loss of precision detected. Detected scale `3` > `2` as defined. +``` + +This is useful when the scale mirrors a database column and silent truncation would hide a bug. + +## Subclassing + +All factory and arithmetic methods return `static`, so a subclass such as `Money extends Decimal` gets its own type back from every operation. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md new file mode 100644 index 0000000..1fa57be --- /dev/null +++ b/docs/guide/getting-started.md @@ -0,0 +1,63 @@ +# Getting started + +Floats cannot represent most decimal fractions exactly: + +```php +var_dump(0.1 + 0.2 === 0.3); // bool(false) +var_dump(0.1 + 0.2 - 0.3); // float(5.551115123125783E-17) +``` + +`Decimal` stores the number as a string of digits and calculates with bcmath, so the result is the one you would get on paper: + +```php +use PhpCollective\DecimalObject\Decimal; + +$sum = Decimal::create('0.1')->add('0.2'); + +echo $sum; // 0.3 +var_dump($sum->equals('0.3')); // bool(true) +``` + +## Immutability + +A `Decimal` never changes. Each operation returns a new object, so assign the result: + +```php +$price = Decimal::create('19.90'); +$total = $price->multiply('3'); + +echo $price; // 19.90 +echo $total; // 59.70 +``` + +## A small invoice + +```php +$net = Decimal::create('19.99'); +$tax = $net->multiply('0.19')->round(2); // 3.80 +$gross = $net->add($tax); // 23.79 + +if ($gross->greaterThan('20')) { + echo 'Total: ' . $gross; +} +``` + +`multiply()` returned `3.7981` here, with the scales of both operands added up. `round(2)` brings it back to two decimal places. [Precision and scale](./scale) explains the rules for each operation. + +## Use strings for input + +Pass decimal strings whenever you can. A float has already lost digits before the object sees it: + +```php +echo Decimal::create('1234567890.1234567'); // 1234567890.1234567 +echo Decimal::create(1234567890.1234567); // 1234567890.1235 +``` + +See [Creating decimals](./creating) for all accepted input. + +## Where to go next + +- [Arithmetic](./arithmetic) for the math operations +- [Rounding](./rounding) for `round()`, `floor()`, `ceil()` and `truncate()` +- [Comparison and checks](./comparison) +- [API reference](../reference/api) for every method on one page diff --git a/docs/guide/installation.md b/docs/guide/installation.md new file mode 100644 index 0000000..b852048 --- /dev/null +++ b/docs/guide/installation.md @@ -0,0 +1,18 @@ +# Installation + +```sh +composer require php-collective/decimal-object +``` + +## Requirements + +- PHP 8.2 or newer +- The `bcmath` extension + +Composer checks both. Enable `ext-bcmath` for the CLI and for the web server, as they often load different `php.ini` files. + +::: tip Older PHP versions +PHP 8.1 is served by the 1.3 releases, which Composer selects on its own. For PHP 7 use the original [spryker/decimal-object](https://github.com/spryker/decimal-object). +::: + +Continue with [Getting started](./getting-started). diff --git a/docs/guide/rounding.md b/docs/guide/rounding.md new file mode 100644 index 0000000..bba8110 --- /dev/null +++ b/docs/guide/rounding.md @@ -0,0 +1,67 @@ +# Rounding + +```php +use PhpCollective\DecimalObject\Decimal; + +$value = Decimal::create('12.375'); +``` + +## round() + +```php +echo $value->round(2); // 12.38 +echo Decimal::create('12.374')->round(2); // 12.37 +echo Decimal::create('12.5')->round(); // 13 +echo Decimal::create('-12.5')->round(); // -13 +echo Decimal::create('11')->round(2); // 11.00 +``` + +The first argument is the number of decimal places, `0` by default. The result always has exactly that scale, padded with zeros if needed. + +The default mode rounds half away from zero, like PHP's `round()`. It works on the decimal digits and never passes through a float. + +## Rounding modes + +| Constant | Direction | `12.375` | `-12.375` | +| --- | --- | --- | --- | +| `Decimal::ROUND_HALF_UP` | nearest, half away from zero, at the given scale | `12.38` at scale 2 | `-12.38` at scale 2 | +| `Decimal::ROUND_FLOOR` | whole number towards negative infinity | `12` | `-13` | +| `Decimal::ROUND_CEIL` | whole number towards positive infinity | `13` | `-12` | + +```php +echo $value->round(0, Decimal::ROUND_FLOOR); // 12 +echo $value->round(0, Decimal::ROUND_CEIL); // 13 +``` + +::: warning Limits of the directed modes +`ROUND_FLOOR` and `ROUND_CEIL` always round to a whole number. A scale passed along with them does not move the rounding position. + +Any other mode rounds half up. That includes PHP's `PHP_ROUND_HALF_EVEN`, `PHP_ROUND_HALF_ODD` and `PHP_ROUND_HALF_DOWN`, which are not supported. The next major version will apply the scale to all modes and reject unsupported ones. +::: + +## floor() and ceil() + +Shortcuts for the two directed modes at zero decimal places: + +```php +echo $value->floor(); // 12 +echo $value->ceil(); // 13 +echo Decimal::create('-12.375')->floor(); // -13 +echo Decimal::create('-12.375')->ceil(); // -12 +``` + +## truncate() + +Cuts off digits without looking at them, which rounds towards zero: + +```php +echo Decimal::create('12.379')->truncate(2); // 12.37 +echo Decimal::create('-12.379')->truncate(); // -12 +echo Decimal::create('12.3')->truncate(5); // 12.3 +``` + +`truncate()` never adds zeros. Use `round()` when the result needs a fixed scale. + +## Negative scale + +Rounding to tens or hundreds is not supported. `round()` and `truncate()` throw an `InvalidArgumentException` for a scale below zero. diff --git a/docs/guide/scale.md b/docs/guide/scale.md new file mode 100644 index 0000000..2dac922 --- /dev/null +++ b/docs/guide/scale.md @@ -0,0 +1,76 @@ +# Precision and scale + +The scale is the number of digits after the decimal point. `12.375` has a scale of 3, `120` has a scale of 0. + +```php +use PhpCollective\DecimalObject\Decimal; + +echo Decimal::create('1.50')->scale(); // 2 +``` + +The library is strict about it: a value keeps the scale it was created with, and trailing zeros count. `1.50` and `1.5` are equal in value but carry different scales. + +## Scale of a result + +| Operation | Default scale | Example | +| --- | --- | --- | +| `add()`, `subtract()` | larger scale of both operands | `1.50 + 2.125 = 3.625` | +| `multiply()` | sum of both scales | `1.55 * 2.00 = 3.1000` | +| `divide()` | none, the argument is required | `10 / 3` at scale 4 is `3.3333` | +| `pow()`, `sqrt()`, `mod()` | scale of the value | `1.5 ** 2 = 2.2` | +| `round()`, `truncate()` | the scale you pass | `12.375` rounded to 2 is `12.38` | + +Every arithmetic method takes a scale argument to override the default. When the exact result has more digits than the scale allows, the rest is cut off without rounding. + +## Repeated multiplication + +Because `multiply()` adds scales, a loop grows the scale on every pass: + +```php +$value = Decimal::create('1.5'); + +echo $value->multiply('1.5')->multiply('1.5'); // 3.375 +``` + +Pass the scale you want, or round at the end of the calculation: + +```php +echo Decimal::create('19.99')->multiply('0.19'); // 3.7981 +echo Decimal::create('19.99')->multiply('0.19')->round(2); // 3.80 +``` + +## Division loses information + +A cut-off quotient does not multiply back to the original: + +```php +echo Decimal::create('100')->divide('3', 2)->multiply(3); // 99.99 +``` + +When splitting an amount, calculate the last share as the remainder instead of dividing again. + +## Removing trailing zeros + +`trim()` drops trailing zeros and lowers the scale to match: + +```php +$trimmed = Decimal::create('1.2300')->trim(); + +echo $trimmed; // 1.23 +echo $trimmed->scale(); // 2 + +echo Decimal::create('100.00')->trim(); // 100 +``` + +Zeros before the decimal point belong to the value and stay. + +## Large and small numbers + +There is no upper limit other than memory: + +```php +echo Decimal::create('99999999999999999999.99')->add('0.01'); +// 100000000000000000000.00 +``` + +`isBigInteger()` and `isBigDecimal()` tell you whether a value still fits PHP's native types. See [Conversion](./conversion). diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..d30346a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,33 @@ +--- +layout: home +hero: + name: Decimal Object + text: Exact decimal arithmetic for PHP + tagline: An immutable value object on top of bcmath. No custom extension, no float rounding error. + image: + src: /logo.svg + alt: Decimal Object logo + actions: + - theme: brand + text: Getting started + link: /guide/getting-started + - theme: alt + text: Installation + link: /guide/installation + - theme: alt + text: API reference + link: /reference/api +features: + - title: Exact results + details: Values are stored and calculated as decimal strings, so 0.1 plus 0.2 equals 0.3. + - title: Immutable + details: Every operation returns a new object. A value you passed around cannot change behind your back. + - title: Keeps your scale + details: Trailing zeros are kept until you trim them. 19.90 stays 19.90. + - title: Readable API + details: add(), subtract(), greaterThan(), isZero(). No abbreviations to look up. + - title: Any size + details: Works with numbers far beyond the integer and float range of PHP. + - title: Framework integrations + details: Use it as the decimal column type in the CakePHP ORM through the cakephp-decimal plugin. +--- diff --git a/docs/package-lock.json b/docs/package-lock.json new file mode 100644 index 0000000..3e7dd16 --- /dev/null +++ b/docs/package-lock.json @@ -0,0 +1,2637 @@ +{ + "name": "decimal-object-docs", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "decimal-object-docs", + "devDependencies": { + "vitepress": "^1.6.3", + "vue": "^3.5.13" + } + }, + "node_modules/@algolia/abtesting": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.25.0.tgz", + "integrity": "sha512-rSTin9Uta23uaewYVQEp8XI9T3iA/zrg0/1G2vhf8oFFDxFL5vybnZ5IQwsVAg4JpKxPX4/WYNKdcfWrZymk7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", + "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", + "@algolia/autocomplete-shared": "1.17.7" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", + "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", + "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", + "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.59.0.tgz", + "integrity": "sha512-bm2XN0hCSMYwStSsCBT0/PUB2BDxoyR1Lnub3c392HMEy9bi8PUSW8vR6zltVEKWG2t4PQFWMD5C07bmJxGgPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.59.0.tgz", + "integrity": "sha512-XOFPOTa69WuqHR6c5tMgnUUwwqQgNSzMpxmhrgA9KmxRf8WIqEa0cokHJvohk5CYb7CZx0xeSL6Bk2IUJ7Lv7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.59.0.tgz", + "integrity": "sha512-PC8ipLOYFKRTfIUY1J3FJxS6ryzWziaXmIX9/sNMoUR8L+XhF7hX2QAeUI87YVl5zujvlYTSOb+HAIqKXDjyHQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.59.0.tgz", + "integrity": "sha512-yFNcCMM5fHiyoR0HuxMrzy+VjDcmhFUZTm2IJ2DHwGsVH3B5SEob4zTmeEZ3j/AZqNnmrJOCKl/tJnBg7+84bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.59.0.tgz", + "integrity": "sha512-GYja6HkDt2VrQhWmB2cLx3Z5fDwI9no7q+xwCWcFrTPm5CLH9QZvK0XLO9co1FcNIDxxkMaP3AXM39/XcecEOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.59.0.tgz", + "integrity": "sha512-Wofg7bMpWh8N5qDDZs0wy6whc+KMmdNsxgrIGp9Ug2s1Bka0uq7AjyckdxReKPHLA0Q1qH8X/SNh0wn2t2X2Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.59.0.tgz", + "integrity": "sha512-fHnALZfbEnODczGk14Y/1YBRApp6UEpZUTexGcMUPzY7RDc7q4HN2Y6jh0KUG8Jo9B+q+wOoQEc3ziR0ErbuFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.59.0.tgz", + "integrity": "sha512-Fa38s1mHgoaLCT117sfJ6P78rtxUt93CYxBYpq1VOIcslaF+cH+1h5uikAPsKfxLnsuEDZHiBiaI6eNPTsQMRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.59.0.tgz", + "integrity": "sha512-NyNsRSqM2tF1MX7ZGw/j4rduoEJiQ5wfvbSo/CFVdEzYCjyxbGFRPOZyS/GETJG9rk1TdHOq8NZqKqk/u1fm4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.59.0.tgz", + "integrity": "sha512-nXBK2uygWtvbCOffMWqqfjjcEtp9enDKY5/2Pw/Hhw8VVaOyK2ThbGK04bNX/Le+qoK1VHYkodWGVTKfw0Otkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.59.0.tgz", + "integrity": "sha512-yb+4afX/zja8QwX0KmV4/ae2kyYkRknsE79CRusYS2U5D8qwn2wq0cn1x12f3tm3F3+5FgmDdbuGdQTuCLSKMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.59.0.tgz", + "integrity": "sha512-Lp52TmpA1QtNmdHzs505Xwf4tgII7RAapkDSF9AAtWIBETcGaTIaXci4Rd9nS6SdaWK1BWTSqAPp5wzh6UN3Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.59.0.tgz", + "integrity": "sha512-YYHLEs5rC6oRTFwT7bJaKBR9NSFzikeVHGAT1ffATmUQzR8XqyhHXjoieAUoR7fBU6I5cfy1FVeh5GoRgriuzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@docsearch/css": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", + "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", + "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/react": "3.8.2", + "preact": "^10.0.0" + } + }, + "node_modules/@docsearch/react": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", + "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.17.7", + "@algolia/autocomplete-preset-algolia": "1.17.7", + "@docsearch/css": "3.8.2", + "algoliasearch": "^5.14.2" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 19.0.0", + "react": ">= 16.8.0 < 19.0.0", + "react-dom": ">= 16.8.0 < 19.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.99", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.99.tgz", + "integrity": "sha512-kouZhqvUoNdxEe+lZksbJJZMCZv4b4Ob62O1GW3fcpz/I0mVPzM+9/Qf/DUuv5ns8l6c46D8M/ubhJjjbLp9HQ==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.5.tgz", + "integrity": "sha512-MrfgRQSpD5hkkRg6XI16B6EDLb+6+xp8xje6bpdSwkYw+qxrBKbUVrRAQu9h82UT+QumSIVQ8HXsXjPd2dlhqw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.5.tgz", + "integrity": "sha512-+uu2p/R9Tzn8GpoQyX1pAo5dmKT1DhXevfaCzqzmPFhnLjHLWw7uWkaVGlxmhwYoRiYYhsGfRcD5cTBpL7rgSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.5.tgz", + "integrity": "sha512-17dys+YEmzwzzFVHQAVDONg5FALXY9kd99HFEpfk4gJVHYxoh5bGea3D4t1fXXnwkTcOe25wPyd6nWS0I8IdLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.5.tgz", + "integrity": "sha512-wg0zQEKkEvFZf1UaaX1UELOoPs//OSKUG7D+yITuqOsAvza7LvHTv5Wu4nmMfzNG7EYI2jp2PJC7Id6OETmsYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.5.tgz", + "integrity": "sha512-CoJmWcpEMuwin+TZ/ev/H9uDyF/uxUKQSWXdmIPn/UBx/uGV9+4ebMp0FwnlhWR9PE1AdzYsKf3bZevhif38+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.5.tgz", + "integrity": "sha512-PxZnLAaTUAL5xG0W2BXLeYPKrK2b3LIASV2/e/Kf7PZvSLI1iAQIm9B9aJD+YyFIT7hc6qaMV3+OOO35R28LfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.5.tgz", + "integrity": "sha512-uPo3IvnONjhbFXwWgroBeFwOicuhT7ZwQrnzkzmOtBJzeQbkUJUE1SJDfDI31ijQ5+ZUIA6sPrdUMxTSnxUWAg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.5.tgz", + "integrity": "sha512-ywI8Jyuc4LyHYTjC0mw4yA1ulvZBkHusdpWnoiEBsmBuHvSnt90btTBGCZ+s/2y+YNNfVAEKy33nPo5UI9zTmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.5.tgz", + "integrity": "sha512-tmuXJoQ/3ovSr637aykZMdQteU/CB2APAz2CrvsbibuoHZi1BS1Db13cVViBdEARGwXCLn1DSJ8wsjoThLJPGA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.5.tgz", + "integrity": "sha512-5FMjSYNcUOHx7oW8Qu9KB6jGPpvF/8sG7+BDHwU/Ihh1/6FMJ9ySmsFG4tJ1qbNRuP8OLzCBDso4sF0mQaLuFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.5.tgz", + "integrity": "sha512-K8ZJ+neLDbUkIrL8dqF/5LtuVCrKwo27kREgOlxjBEjlOXwHvDnHLU8Z/UKv/B49qi7e8hsSDOyoJwhwqhvX4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.5.tgz", + "integrity": "sha512-NlVxsq7PFPv0DBKGuNWRDKixQYQCjM53zl04mxEcKMBmJbCBIUm1JdPfIWDRHyOOFzzYPmLQzfeEwh4716ayQg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.5.tgz", + "integrity": "sha512-ARhRHJYs5eCFet5azfrHD97vOkD8OzNW2RSMlvo5CcBqXaTOLfp0OHDtUQh7/K5AL5fUYoFp2fgC9KDg4vVYMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.5.tgz", + "integrity": "sha512-atSgreM5C7jpxd9aEXN+6vJ5wTd05iYw+S3FiQpw7VsoEl8B90llbq35ARON+GOHj5QlVQEq2vOKWCHTob/BSQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.5.tgz", + "integrity": "sha512-vmlmwBFGnjVWxSRoVabNkyYuscSmg52Pd0fM8jEbNT3Jmka4A6HcB84x4uAmuumznWfBVaxKOqU6jbEgomWKCA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.5.tgz", + "integrity": "sha512-hhJrP6S9WkbsbcHgUkvCBS5Cn8IdsG8XjlLZyxjnv9+NdoYfbbZE4TWJYr8apdaj1M5t5Y1id2StnEslxpNFyA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.5.tgz", + "integrity": "sha512-NNVCyN9pGrwFmQa4bf+Cnf5aVH3g6ZpnlOg1Cs/H9swIHT9bRixijfzUqyEIWm1WtiAuR1LJ+rJH6zpBm8kgQA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.5.tgz", + "integrity": "sha512-dR9nd6OKnvSS60Ifz/L7Mj5zzYyoqyRn2NwTuaOONKaa3xsVGXyp8YusMbvFyEOR94DxnwgcbgsFqkHd29ZSzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.5.tgz", + "integrity": "sha512-1tzsMjaqhsh3bAO6SJiJqh8hIr9sSPRd+i2vPPxJHod8/XYnf9TEnK0z/u3r1z1AkI347X0KbRSg9dWcxeGahw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.5.tgz", + "integrity": "sha512-01mWMQblKoXXsmk28KXtpbA96w7g/aOWUoV3AC16fdHdqkykGalqyyM+5u8M7uP5/MEMiiPv69Hv7iv/hEH/dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.5.tgz", + "integrity": "sha512-trdnMPb/DNm4V/CgsaoGgr90HfQQ071wfK0KoIs4YpLvjBgVQGXhGabg2Z6nK4EcYNilrb9ZZPcsP6+zpJIdxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.5.tgz", + "integrity": "sha512-x2LxZlfdfsw99T6/qb9EqE/Qi84JWzibjyOJyhJsgFJy6CafaTiqIXCCnslCGrV5XWNh7fwrS3FbRENcuKnooA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.5.tgz", + "integrity": "sha512-/Qd/9aDQIo0+jmItgBmPIs8195YVbDxRRApn47GyufgbutCDOh7eP5vSKKlI4QNFeaI+wKQAkSMsPnF3EuMpaA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.5.tgz", + "integrity": "sha512-ngUJ/XvOWrqbd2IMsfXfh2xXFLTP3sY7sCKvxbGvYdBZ1KmXFd3ewDZ9n3/GNzAUgzRuhLT/UvJRivbNoAQRBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.5.tgz", + "integrity": "sha512-IhV6ew/DuzUWwHaYmlh7MrPCV3lKFUq/amTEwysi4r/mwNPAEfAMh5dYWeTSPTeMy2pFHGMEWYtPkHzfX3tG5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", + "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", + "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^3.1.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", + "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", + "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", + "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", + "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz", + "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", + "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", + "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", + "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.10.tgz", + "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.10" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz", + "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.10", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz", + "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", + "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz", + "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", + "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz", + "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz", + "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", + "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vueuse/core": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/algoliasearch": { + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.59.0.tgz", + "integrity": "sha512-wUXzaeI7B526W4y1gFg3lcxgDZ67XSgRJIiellYWOas/pLpO7rOtxm9Gr2E/C8aiSDXIgx1q5TdSsvK67Uakqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.25.0", + "@algolia/client-abtesting": "5.59.0", + "@algolia/client-analytics": "5.59.0", + "@algolia/client-common": "5.59.0", + "@algolia/client-insights": "5.59.0", + "@algolia/client-personalization": "5.59.0", + "@algolia/client-query-suggestions": "5.59.0", + "@algolia/client-search": "5.59.0", + "@algolia/ingestion": "1.59.0", + "@algolia/monitoring": "1.59.0", + "@algolia/recommend": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/copy-anything": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.1.5.tgz", + "integrity": "sha512-vePst2VLTSqPglW7Q98VwFpGiGpOHTyXIo81MO5VyLecA5zMbZw/P/V1vmwD1jksa2iJpn/YkDrJ3XhTfzUyhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oniguruma-to-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", + "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.5.tgz", + "integrity": "sha512-EAP5ZHcz2ijbgCWwPkpI7zh6bfsri2Ez5FtCy2dCp1k62duXzfG7DLGGh7RU2QQXWwavT+u4qpvDF/TWOiiGTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.5", + "@rollup/rollup-android-arm64": "4.64.5", + "@rollup/rollup-darwin-arm64": "4.64.5", + "@rollup/rollup-darwin-x64": "4.64.5", + "@rollup/rollup-freebsd-arm64": "4.64.5", + "@rollup/rollup-freebsd-x64": "4.64.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.5", + "@rollup/rollup-linux-arm-musleabihf": "4.64.5", + "@rollup/rollup-linux-arm64-gnu": "4.64.5", + "@rollup/rollup-linux-arm64-musl": "4.64.5", + "@rollup/rollup-linux-loong64-gnu": "4.64.5", + "@rollup/rollup-linux-loong64-musl": "4.64.5", + "@rollup/rollup-linux-ppc64-gnu": "4.64.5", + "@rollup/rollup-linux-ppc64-musl": "4.64.5", + "@rollup/rollup-linux-riscv64-gnu": "4.64.5", + "@rollup/rollup-linux-riscv64-musl": "4.64.5", + "@rollup/rollup-linux-s390x-gnu": "4.64.5", + "@rollup/rollup-linux-x64-gnu": "4.64.5", + "@rollup/rollup-linux-x64-musl": "4.64.5", + "@rollup/rollup-openbsd-x64": "4.64.5", + "@rollup/rollup-openharmony-arm64": "4.64.5", + "@rollup/rollup-win32-arm64-msvc": "4.64.5", + "@rollup/rollup-win32-ia32-msvc": "4.64.5", + "@rollup/rollup-win32-x64-gnu": "4.64.5", + "@rollup/rollup-win32-x64-msvc": "4.64.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/shiki": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", + "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/langs": "2.5.0", + "@shikijs/themes": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", + "mark.js": "8.11.1", + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", + "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..40b233c --- /dev/null +++ b/docs/package.json @@ -0,0 +1,17 @@ +{ + "name": "decimal-object-docs", + "private": true, + "type": "module", + "scripts": { + "docs:dev": "vitepress dev", + "docs:build": "vitepress build", + "docs:preview": "vitepress preview" + }, + "devDependencies": { + "vitepress": "^1.6.3", + "vue": "^3.5.13" + }, + "overrides": { + "vite": "^6.4.4" + } +} diff --git a/docs/public/favicon.svg b/docs/public/favicon.svg new file mode 100644 index 0000000..0d8a906 --- /dev/null +++ b/docs/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/docs/public/logo.svg b/docs/public/logo.svg new file mode 100644 index 0000000..0d8a906 --- /dev/null +++ b/docs/public/logo.svg @@ -0,0 +1 @@ + diff --git a/docs/reference/alternatives.md b/docs/reference/alternatives.md new file mode 100644 index 0000000..6ad0f22 --- /dev/null +++ b/docs/reference/alternatives.md @@ -0,0 +1,25 @@ +# Alternatives + +This library is one way to handle decimals in PHP. Depending on the project another one may fit better. + +| Library | Notes | +| --- | --- | +| [php-decimal](https://php-decimal.github.io/) | A PHP extension. Faster, and it supports the arithmetic operators, but it has to be installed on every server. Works with precision (significant digits) where this library works with scale. | +| [brick/math](https://github.com/brick/math) | `BigDecimal`, `BigInteger` and `BigRational` with several calculation engines and more rounding modes. | +| [brick/money](https://github.com/brick/money), [moneyphp/money](https://github.com/moneyphp/money) | Money with currencies, allocation and formatting. Choose one of these when you need currency handling and not only a number. | + +## Coming from the php-decimal extension + +| php-decimal | Decimal Object | +| --- | --- | +| Operators `+`, `-`, `*`, `/` | Methods `add()`, `subtract()`, `multiply()`, `divide()` | +| `abs()` | `absolute()` | +| `signum()` | `sign()` | +| `sub()`, `mul()`, `div()` | `subtract()`, `multiply()`, `divide()` | +| Precision as significant digits | Scale as decimal places | + +Not available here: `rem()`, `shift()`, `ln()`, `exp()`, `log10()`, `parity()`, `isEven()`, `isOdd()`, `toFixed()`, `Decimal::sum()` and `Decimal::avg()`. + +## History + +The library was written in 2019 for the [Spryker](https://spryker.com/) commerce framework and has been used there in order and price calculations since. This repository is the independently maintained continuation for PHP 8. The original for older PHP versions is [spryker/decimal-object](https://github.com/spryker/decimal-object). diff --git a/docs/reference/api.md b/docs/reference/api.md new file mode 100644 index 0000000..ad8f898 --- /dev/null +++ b/docs/reference/api.md @@ -0,0 +1,103 @@ +# API reference + +Class `PhpCollective\DecimalObject\Decimal`, implementing `JsonSerializable` and `Stringable`. + +`$value` arguments accept a `Decimal`, a numeric string, an integer or a float unless noted. Methods that return a `Decimal` return a new instance of the called class. + +## Creation + +| Method | Description | +| --- | --- | +| `new Decimal($value, ?int $scale = null, bool $strict = false)` | Creates a decimal. Also accepts any `Stringable` with a numeric string. | +| `Decimal::create($value, ?int $scale = null, bool $strict = false): static` | Same as the constructor, for chaining. | + +See [Creating decimals](../guide/creating). + +## Arithmetic + +| Method | Description | Default scale | +| --- | --- | --- | +| `add($value, ?int $scale = null): static` | Sum | larger of both | +| `subtract($value, ?int $scale = null): static` | Difference | larger of both | +| `multiply($value, ?int $scale = null): static` | Product | sum of both | +| `divide($value, int $scale): static` | Quotient, cut off at `$scale` | required | +| `mod($value, ?int $scale = null): static` | Remainder. `$value` is a `Decimal`, string or integer. | scale of the value | +| `pow($exponent, ?int $scale = null): static` | Power. `$exponent` is a whole number as `Decimal`, string or integer. | scale of the value | +| `sqrt(?int $scale = null): static` | Square root | scale of the value | +| `absolute(): static` | Value without sign | unchanged | +| `negate(): static` | Value with the opposite sign | unchanged | + +See [Arithmetic](../guide/arithmetic). + +## Rounding + +| Method | Description | +| --- | --- | +| `round(int $scale = 0, int $roundMode = Decimal::ROUND_HALF_UP): static` | Rounds half up to `$scale` decimal places, or to a whole number with `ROUND_FLOOR` and `ROUND_CEIL`. | +| `floor(): static` | Nearest integer towards negative infinity. | +| `ceil(): static` | Nearest integer towards positive infinity. | +| `truncate(int $scale = 0): static` | Cuts off digits beyond `$scale`. | +| `trim(): static` | Removes trailing zeros after the decimal point. | + +| Constant | Value | +| --- | --- | +| `Decimal::ROUND_HALF_UP` | `1`, same as `PHP_ROUND_HALF_UP` | +| `Decimal::ROUND_CEIL` | `7` | +| `Decimal::ROUND_FLOOR` | `8` | + +See [Rounding](../guide/rounding). + +## Comparison + +| Method | Description | +| --- | --- | +| `compareTo($value): int` | `-1`, `0` or `1` | +| `equals($value): bool` | Same value, scale ignored | +| `greaterThan($value): bool` | | +| `greaterThanOrEquals($value): bool` | | +| `lessThan($value): bool` | | +| `lessThanOrEquals($value): bool` | | + +See [Comparison and checks](../guide/comparison). + +## Checks + +| Method | Description | +| --- | --- | +| `isZero(): bool` | Value is zero at any scale | +| `isPositive(): bool` | Greater than zero | +| `isNegative(): bool` | Less than zero | +| `isInteger(): bool` | No decimals other than zeros | +| `isBigInteger(): bool` | Integral part does not fit a PHP integer | +| `isBigDecimal(): bool` | `toFloat()` would refuse the value | +| `sign(): int` | `-1`, `0` or `1` | +| `scale(): int` | Number of decimal places | + +## Conversion + +| Method | Description | +| --- | --- | +| `toString(): string` | Exact decimal string, also used for `(string)` casts | +| `toInt(): int` | Integer without decimals. Throws `TypeError` when out of range. | +| `toFloat(): float` | Float approximation. Throws `TypeError` for big decimals. | +| `toScientific(): string` | Scientific notation such as `-2.2345e1` | +| `jsonSerialize(): string` | Same as `toString()` | + +See [Conversion](../guide/conversion). + +## Exceptions + +| Situation | Exception | +| --- | --- | +| Input is not numeric, or a float is `INF` or `NAN` | `InvalidArgumentException` | +| Negative scale in the constructor, `round()` or `truncate()` | `InvalidArgumentException` | +| Strict mode and the input has more decimals than the scale | `InvalidArgumentException` | +| `divide()` or `mod()` by zero | `DivisionByZeroError` | +| `toInt()` or `toFloat()` on a value that does not fit | `TypeError` | +| `sqrt()` of a negative value, `pow()` with a fractional exponent, negative scale in an arithmetic method | `ValueError` from bcmath | + +## Deprecated + +| Method | Use instead | +| --- | --- | +| `greatherThanOrEquals()` | `greaterThanOrEquals()` | diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md new file mode 100644 index 0000000..af31833 --- /dev/null +++ b/docs/reference/integrations.md @@ -0,0 +1,32 @@ +# Integrations + +## CakePHP + +[dereuromark/cakephp-decimal](https://dereuromark.github.io/cakephp-decimal/) maps `DECIMAL` database columns to `Decimal` objects in the CakePHP ORM. Entities receive value objects on read, and form input is converted during marshalling. + +```sh +composer require dereuromark/cakephp-decimal +``` + +```php +$order = $orders->get($id); +$order->total = $order->total->add('0.10'); +$orders->saveOrFail($order); +``` + +The plugin documentation covers setup, forms, localized input and formatting. + +## Your own framework + +The object needs nothing from a framework. For an ORM or database layer, convert in two places: + +- Reading: pass the column value to `Decimal::create()` as the string the driver returns. +- Writing: bind `(string)$decimal` as a string parameter. + +Fetch decimals as strings. Some drivers can be configured to return floats, and the precision is gone before the object is created. + +For JSON APIs the object serializes to a string on its own. Create it from the string in the request payload on the way in. + +## Add yours + +Built an integration for another framework or library? Open a pull request against [this page](https://github.com/php-collective/decimal-object/edit/master/docs/reference/integrations.md). diff --git a/docs/reference/upgrading.md b/docs/reference/upgrading.md new file mode 100644 index 0000000..1f45b94 --- /dev/null +++ b/docs/reference/upgrading.md @@ -0,0 +1,47 @@ +# Upgrading + +## From 1.3 + +PHP 8.2 is now required. Composer keeps PHP 8.1 projects on 1.3. + +The release fixes wrong results and does not change the result of a call that was correct before. Check the following if your code worked around one of the bugs. + +### Parsing + +- Scientific notation with more than one digit before the decimal point and a negative exponent was parsed wrong, for example `'12.5e-1'` as `0.125`. Stored values that came from such input need correcting. +- `'1e+5'` and large floats such as `1e25` are accepted. They threw before. +- Leading zeros are removed in all forms: `'0010'` is `10`, `'-007'` is `-7`. +- `INF` and `NAN` throw an `InvalidArgumentException`. They were stored as the strings `INF` and `NAN`. +- A negative scale throws an `InvalidArgumentException`. + +### Negative zero + +Zero has no sign. `'-0.0'` becomes `0.0`, `isNegative()` is false for it, and `negate()`, `truncate()` and `trim()` no longer return `-0`. + +### Rounding + +`floor()` and `ceil()` no longer move whole numbers written with decimals. `Decimal::create('-1.0')->floor()` returned `-2`, and `Decimal::create('1.0')->ceil()` returned `2`. + +`round()` with a negative scale throws an `InvalidArgumentException`. It used to surface a `ValueError` from bcmath. + +### Conversion + +- `toScientific()` returned `.e-1` for zero, lost digits beyond the float precision for large integers, and returned `1.250e2` for `120.50`. +- `toInt()` accepts `PHP_INT_MIN`. + +### Operands + +`mod()` and `pow()` accept operands in scientific notation. + +## Coming in 2.0 + +These corrections change results or output for ordinary input, so they wait for the next major version. The current behavior is documented on the linked pages. + +| Call | 1.x | 2.0 | +| --- | --- | --- | +| `new Decimal('1.5', 0)` | `1.5`, see [scale of 0](../guide/creating#setting-a-scale) | `1` | +| `round(0, PHP_ROUND_HALF_EVEN)` | rounds half up, see [rounding modes](../guide/rounding#rounding-modes) | `InvalidArgumentException` | +| `Decimal::create('1.25')->round(1, Decimal::ROUND_FLOOR)` | `1` | `1.2` | +| `Decimal::create('10')->mod('0.3')` | `0`, see [modulo](../guide/arithmetic#modulo) | `0.1` | +| `new Decimal('1.0e-5')` | scale 5 | scale 6 | +| `Decimal::create('5')->toScientific()` | `5.e0` | `5e0` | From 81e91500ea2340e1d667829bbcd4f978d80ad51f Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 16:45:52 +0200 Subject: [PATCH 28/29] Add rounding modes, withScale(), allocation and helper methods All additions. A run of the previous and the new class over the same 10,000 calls differs in one place only: round() with a negative scale returned an exception and now rounds to tens, hundreds and so on. Rounding - ROUND_HALF_DOWN, ROUND_HALF_EVEN, ROUND_HALF_ODD, ROUND_UP and ROUND_DOWN. They get their own values (9 to 13) and not those of the PHP_ROUND_* constants: round() treats unknown modes as half up in 1.x, so reusing 2, 3 and 4 would silently change the result for callers that pass PHP_ROUND_HALF_EVEN today. - withScale() pads or rounds to an exact scale and applies every mode at that scale, including floor and ceil, which round() cannot do in 1.x. - round() accepts a negative scale for the non-directed modes. Input - Scientific notation is limited to exponents up to MAX_EXPONENT (10000). "1e1000000000" used to allocate a billion digits. - tryCreate() returns null for null and non numeric input. Calculation - shift() moves the decimal point. - sum(), average(), min() and max() over any iterable. - allocate() and split() divide a value into shares that sum to it exactly, at the scale of the value. - between(), clamp(), isEven() and isOdd(). All bcmath calls in the new code pass their scale. A global bcscale() set by the application would otherwise leak into the results. --- docs/guide/arithmetic.md | 43 ++++ docs/guide/comparison.md | 25 ++ docs/guide/creating.md | 20 +- docs/guide/rounding.md | 68 +++++- docs/guide/scale.md | 13 +- docs/reference/api.md | 32 ++- docs/reference/upgrading.md | 27 ++- src/Decimal.php | 413 ++++++++++++++++++++++++++++++-- tests/DecimalTest.php | 454 +++++++++++++++++++++++++++++++++++- 9 files changed, 1045 insertions(+), 50 deletions(-) diff --git a/docs/guide/arithmetic.md b/docs/guide/arithmetic.md index eaa28fa..d0be489 100644 --- a/docs/guide/arithmetic.md +++ b/docs/guide/arithmetic.md @@ -80,6 +80,49 @@ echo Decimal::create('-23.44')->absolute(); // 23.44 echo Decimal::create('23.44')->negate(); // -23.44 ``` +## Moving the decimal point + +`shift()` multiplies by a power of ten without touching the digits. Use it to convert between units, such as an amount and its cents: + +```php +echo Decimal::create('19.99')->shift(2); // 1999 +echo Decimal::create('1999')->shift(-2); // 19.99 +echo Decimal::create('12.34')->shift(-3); // 0.01234 +``` + +## Several values at once + +```php +echo Decimal::sum(['1.5', 2, Decimal::create('3.25')]); // 6.75 +echo Decimal::average(['1', '2', '4'], 2); // 2.33 +echo Decimal::min(['2.5', '-1', 7]); // -1 +echo Decimal::max(['2.5', '-1', 7]); // 7 +``` + +All four take any iterable. `sum()` of nothing is `0`. `average()`, `min()` and `max()` throw an `InvalidArgumentException` for an empty list. `average()` needs the scale for the same reason `divide()` does, and cuts off there. + +## Splitting an amount + +Dividing an amount and rounding each share rarely adds up to the original. `split()` and `allocate()` hand out every smallest unit: + +```php +$shares = Decimal::create('100.00')->split(3); +// 33.34, 33.33, 33.33 + +$shares = Decimal::create('100.00')->allocate([70, 30]); +// 70.00, 30.00 + +$shares = Decimal::create('10.00')->allocate(['rent' => 2, 'food' => 1]); +// ['rent' => 6.67, 'food' => 3.33] +``` + +The shares always sum to the original value. They have its scale, which also sets the smallest unit: `10` splits into `4, 3, 3`. What is left after the even division goes to the first shares, one unit each. Keys of the ratio array are kept, and a ratio of `0` receives nothing. + +Ratios can be integers or decimals such as `'0.7'` and `'0.3'`. Negative ratios, an empty list and ratios that sum to zero throw an `InvalidArgumentException`. + +> [!TIP] Added in 1.4 +> `shift()`, `sum()`, `average()`, `min()`, `max()`, `split()` and `allocate()`. + ## Chaining ```php diff --git a/docs/guide/comparison.md b/docs/guide/comparison.md index e27a6c1..ad2748d 100644 --- a/docs/guide/comparison.md +++ b/docs/guide/comparison.md @@ -27,6 +27,20 @@ usort($values, fn (Decimal $a, Decimal $b) => $a->compareTo($b)); // -1, 2.05, 2.5 ``` +## Ranges + +```php +var_dump(Decimal::create('5')->between(1, 10)); // bool(true) +var_dump(Decimal::create('5')->between(5, 10, false)); // bool(false) + +echo Decimal::create('12')->clamp(0, 10); // 10 +echo Decimal::create('-3')->clamp(0, 10); // 0 +``` + +`between()` includes both bounds unless the third argument is `false`. `clamp()` returns the nearest bound for a value outside the range and throws an `InvalidArgumentException` when the lower bound is above the upper one. + +> [!TIP] Added in 1.4 + ::: warning Do not use == or === `==` on two objects compares their properties, so `1.50 == 1.5` is `false`. `===` compares identity. Use `equals()`. ::: @@ -50,3 +64,14 @@ Zero is neither positive nor negative. var_dump(Decimal::create('5.00')->isInteger()); // bool(true) var_dump(Decimal::create('5.01')->isInteger()); // bool(false) ``` + +`isEven()` and `isOdd()` look at whole numbers of any size. Both are false for a value with decimals: + +```php +var_dump(Decimal::create('4.00')->isEven()); // bool(true) +var_dump(Decimal::create('7')->isOdd()); // bool(true) +var_dump(Decimal::create('2.5')->isEven()); // bool(false) +``` + +> [!TIP] Added in 1.4 +> `isEven()` and `isOdd()`. diff --git a/docs/guide/creating.md b/docs/guide/creating.md index e771455..a1d4ed9 100644 --- a/docs/guide/creating.md +++ b/docs/guide/creating.md @@ -49,10 +49,26 @@ Localized strings such as `'1.018,90'` are rejected. Parse them with `NumberForm A float is converted with PHP's own float to string rules, which round to the `precision` ini setting. That hides small errors but cannot restore digits the float never had. For money and other exact values, read the number as a string from the request or the database. ::: -::: warning Scientific notation from untrusted input -`'1e1000000000'` is twelve characters long and expands to a string of a billion digits. If the value comes from a request, reject exponent notation or check the size of the exponent before you create the object. A limit on the input length does not help. +::: info Exponent limit +Exponents beyond `Decimal::MAX_EXPONENT` (10000 in either direction) throw an `InvalidArgumentException`. Without a limit, the twelve characters of `'1e1000000000'` would expand to a string of a billion digits. A subclass can redefine the constant. ::: +## Optional input + +`tryCreate()` returns `null` where `create()` would throw for a value that is not numeric, and passes `null` through: + +```php +Decimal::tryCreate('1.50'); // Decimal 1.50 +Decimal::tryCreate('abc'); // null +Decimal::tryCreate(''); // null +Decimal::tryCreate(null); // null +``` + +This fits optional form fields and nullable columns. + +> [!TIP] Added in 1.4 +> `tryCreate()` and the exponent limit. + ## Setting a scale The second argument fixes the number of decimal places: diff --git a/docs/guide/rounding.md b/docs/guide/rounding.md index bba8110..6e394b8 100644 --- a/docs/guide/rounding.md +++ b/docs/guide/rounding.md @@ -22,22 +22,61 @@ The default mode rounds half away from zero, like PHP's `round()`. It works on t ## Rounding modes +Pass a mode as second argument. All of these round at the given scale: + +| Constant | Direction | `2.5` | `3.5` | `-2.5` | `2.1` | +| --- | --- | --- | --- | --- | --- | +| `Decimal::ROUND_HALF_UP` | nearest, ties away from zero (default) | `3` | `4` | `-3` | `2` | +| `Decimal::ROUND_HALF_DOWN` | nearest, ties towards zero | `2` | `3` | `-2` | `2` | +| `Decimal::ROUND_HALF_EVEN` | nearest, ties to the even neighbor | `2` | `4` | `-2` | `2` | +| `Decimal::ROUND_HALF_ODD` | nearest, ties to the odd neighbor | `3` | `3` | `-3` | `2` | +| `Decimal::ROUND_UP` | away from zero | `3` | `4` | `-3` | `3` | +| `Decimal::ROUND_DOWN` | towards zero | `2` | `3` | `-2` | `2` | + +```php +echo Decimal::create('0.125')->round(2, Decimal::ROUND_HALF_EVEN); // 0.12 +echo Decimal::create('12.375')->round(2, Decimal::ROUND_DOWN); // 12.37 +``` + +`ROUND_HALF_EVEN` is the banker's rounding used in accounting to avoid a drift over many values. + +> [!TIP] Added in 1.4 +> `ROUND_HALF_DOWN`, `ROUND_HALF_EVEN`, `ROUND_HALF_ODD`, `ROUND_UP` and `ROUND_DOWN`. + +### Floor and ceil modes + | Constant | Direction | `12.375` | `-12.375` | | --- | --- | --- | --- | -| `Decimal::ROUND_HALF_UP` | nearest, half away from zero, at the given scale | `12.38` at scale 2 | `-12.38` at scale 2 | | `Decimal::ROUND_FLOOR` | whole number towards negative infinity | `12` | `-13` | | `Decimal::ROUND_CEIL` | whole number towards positive infinity | `13` | `-12` | +::: warning Limits in 1.x +With `round()`, `ROUND_FLOOR` and `ROUND_CEIL` always round to a whole number. A scale passed along with them does not move the rounding position. Use [`withScale()`](#withscale) to floor or ceil at a scale. + +The constants have their own values. PHP's `PHP_ROUND_HALF_EVEN`, `PHP_ROUND_HALF_ODD` and `PHP_ROUND_HALF_DOWN` are not the same numbers, and `round()` treats any value it does not know as half up. The next major version will reject unknown modes. +::: + +## withScale() + +Returns the value with exactly the given number of decimal places. Missing places are filled with zeros, surplus places are rounded: + ```php -echo $value->round(0, Decimal::ROUND_FLOOR); // 12 -echo $value->round(0, Decimal::ROUND_CEIL); // 13 +echo Decimal::create('1.5')->withScale(3); // 1.500 +echo Decimal::create('7')->withScale(2); // 7.00 +echo Decimal::create('1.255')->withScale(2); // 1.26 ``` -::: warning Limits of the directed modes -`ROUND_FLOOR` and `ROUND_CEIL` always round to a whole number. A scale passed along with them does not move the rounding position. +Every mode is applied at the scale, including floor and ceil: -Any other mode rounds half up. That includes PHP's `PHP_ROUND_HALF_EVEN`, `PHP_ROUND_HALF_ODD` and `PHP_ROUND_HALF_DOWN`, which are not supported. The next major version will apply the scale to all modes and reject unsupported ones. -::: +```php +echo Decimal::create('12.375')->withScale(1, Decimal::ROUND_FLOOR); // 12.3 +echo Decimal::create('-12.375')->withScale(1, Decimal::ROUND_FLOOR); // -12.4 +echo Decimal::create('12.375')->withScale(1, Decimal::ROUND_CEIL); // 12.4 +``` + +Use it before output or storage when a column or an API expects a fixed number of decimals. An unknown mode throws an `InvalidArgumentException`. + +> [!TIP] Added in 1.4 ## floor() and ceil() @@ -62,6 +101,17 @@ echo Decimal::create('12.3')->truncate(5); // 12.3 `truncate()` never adds zeros. Use `round()` when the result needs a fixed scale. -## Negative scale +## Rounding to tens and hundreds + +A negative scale rounds left of the decimal point: + +```php +echo Decimal::create('1250')->round(-2); // 1300 +echo Decimal::create('14.99')->round(-1); // 10 +echo Decimal::create('1250')->round(-2, Decimal::ROUND_HALF_EVEN); // 1200 +``` + +This is not available for `ROUND_FLOOR`, `ROUND_CEIL`, `withScale()` and `truncate()`, which throw an `InvalidArgumentException` for a scale below zero. -Rounding to tens or hundreds is not supported. `round()` and `truncate()` throw an `InvalidArgumentException` for a scale below zero. +> [!TIP] Added in 1.4 +> `round()` threw for every negative scale before. diff --git a/docs/guide/scale.md b/docs/guide/scale.md index 2dac922..de9ec24 100644 --- a/docs/guide/scale.md +++ b/docs/guide/scale.md @@ -47,7 +47,18 @@ A cut-off quotient does not multiply back to the original: echo Decimal::create('100')->divide('3', 2)->multiply(3); // 99.99 ``` -When splitting an amount, calculate the last share as the remainder instead of dividing again. +To split an amount without losing a cent, use [`split()` or `allocate()`](./arithmetic#splitting-an-amount). + +## Setting a fixed scale + +`withScale()` pads or rounds to an exact number of decimal places: + +```php +echo Decimal::create('7')->withScale(2); // 7.00 +echo Decimal::create('1.255')->withScale(2); // 1.26 +``` + +See [Rounding](./rounding#withscale) for the rounding modes it accepts. ## Removing trailing zeros diff --git a/docs/reference/api.md b/docs/reference/api.md index ad8f898..75b05ff 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -10,6 +10,7 @@ Class `PhpCollective\DecimalObject\Decimal`, implementing `JsonSerializable` and | --- | --- | | `new Decimal($value, ?int $scale = null, bool $strict = false)` | Creates a decimal. Also accepts any `Stringable` with a numeric string. | | `Decimal::create($value, ?int $scale = null, bool $strict = false): static` | Same as the constructor, for chaining. | +| `Decimal::tryCreate($value, ?int $scale = null): ?static` | Returns `null` for `null` and for input that is not numeric. | See [Creating decimals](../guide/creating). @@ -24,16 +25,30 @@ See [Creating decimals](../guide/creating). | `mod($value, ?int $scale = null): static` | Remainder. `$value` is a `Decimal`, string or integer. | scale of the value | | `pow($exponent, ?int $scale = null): static` | Power. `$exponent` is a whole number as `Decimal`, string or integer. | scale of the value | | `sqrt(?int $scale = null): static` | Square root | scale of the value | +| `shift(int $places): static` | Moves the decimal point, multiplying by a power of ten | follows the digits | | `absolute(): static` | Value without sign | unchanged | | `negate(): static` | Value with the opposite sign | unchanged | +| Static method | Description | +| --- | --- | +| `Decimal::sum(iterable $values): static` | Sum, `0` for an empty list | +| `Decimal::average(iterable $values, int $scale): static` | Mean, cut off at `$scale` | +| `Decimal::min(iterable $values): static` | Smallest value | +| `Decimal::max(iterable $values): static` | Largest value | + +| Method | Description | +| --- | --- | +| `allocate(array $ratios): array` | Shares by ratio that sum to the value exactly, keys preserved | +| `split(int $parts): array` | Equal shares that sum to the value exactly | + See [Arithmetic](../guide/arithmetic). ## Rounding | Method | Description | | --- | --- | -| `round(int $scale = 0, int $roundMode = Decimal::ROUND_HALF_UP): static` | Rounds half up to `$scale` decimal places, or to a whole number with `ROUND_FLOOR` and `ROUND_CEIL`. | +| `round(int $scale = 0, int $roundMode = Decimal::ROUND_HALF_UP): static` | Rounds to `$scale` decimal places, or to a whole number with `ROUND_FLOOR` and `ROUND_CEIL`. A negative scale rounds to tens, hundreds and so on. | +| `withScale(int $scale, int $roundMode = Decimal::ROUND_HALF_UP): static` | Pads or rounds to exactly `$scale` decimal places, in any mode. | | `floor(): static` | Nearest integer towards negative infinity. | | `ceil(): static` | Nearest integer towards positive infinity. | | `truncate(int $scale = 0): static` | Cuts off digits beyond `$scale`. | @@ -44,6 +59,12 @@ See [Arithmetic](../guide/arithmetic). | `Decimal::ROUND_HALF_UP` | `1`, same as `PHP_ROUND_HALF_UP` | | `Decimal::ROUND_CEIL` | `7` | | `Decimal::ROUND_FLOOR` | `8` | +| `Decimal::ROUND_HALF_DOWN` | `9` | +| `Decimal::ROUND_HALF_EVEN` | `10` | +| `Decimal::ROUND_HALF_ODD` | `11` | +| `Decimal::ROUND_DOWN` | `12`, towards zero | +| `Decimal::ROUND_UP` | `13`, away from zero | +| `Decimal::MAX_EXPONENT` | `10000`, limit for scientific notation input | See [Rounding](../guide/rounding). @@ -57,6 +78,8 @@ See [Rounding](../guide/rounding). | `greaterThanOrEquals($value): bool` | | | `lessThan($value): bool` | | | `lessThanOrEquals($value): bool` | | +| `between($min, $max, bool $inclusive = true): bool` | Within the range | +| `clamp($min, $max): static` | The value, or the nearest bound when outside the range | See [Comparison and checks](../guide/comparison). @@ -68,6 +91,8 @@ See [Comparison and checks](../guide/comparison). | `isPositive(): bool` | Greater than zero | | `isNegative(): bool` | Less than zero | | `isInteger(): bool` | No decimals other than zeros | +| `isEven(): bool` | Whole number divisible by two | +| `isOdd(): bool` | Whole number not divisible by two | | `isBigInteger(): bool` | Integral part does not fit a PHP integer | | `isBigDecimal(): bool` | `toFloat()` would refuse the value | | `sign(): int` | `-1`, `0` or `1` | @@ -90,7 +115,10 @@ See [Conversion](../guide/conversion). | Situation | Exception | | --- | --- | | Input is not numeric, or a float is `INF` or `NAN` | `InvalidArgumentException` | -| Negative scale in the constructor, `round()` or `truncate()` | `InvalidArgumentException` | +| Exponent beyond `Decimal::MAX_EXPONENT` | `InvalidArgumentException` | +| Negative scale in the constructor, `withScale()`, `truncate()` or `round()` with `ROUND_FLOOR` / `ROUND_CEIL` | `InvalidArgumentException` | +| Unknown mode in `withScale()` | `InvalidArgumentException` | +| Empty list in `average()`, `min()`, `max()`; invalid ratios in `allocate()`; `clamp()` with `$min` above `$max` | `InvalidArgumentException` | | Strict mode and the input has more decimals than the scale | `InvalidArgumentException` | | `divide()` or `mod()` by zero | `DivisionByZeroError` | | `toInt()` or `toFloat()` on a value that does not fit | `TypeError` | diff --git a/docs/reference/upgrading.md b/docs/reference/upgrading.md index 1f45b94..76eadf3 100644 --- a/docs/reference/upgrading.md +++ b/docs/reference/upgrading.md @@ -1,12 +1,26 @@ # Upgrading -## From 1.3 +## From 1.3 to 1.4 PHP 8.2 is now required. Composer keeps PHP 8.1 projects on 1.3. +### New + +- Rounding modes `ROUND_HALF_DOWN`, `ROUND_HALF_EVEN`, `ROUND_HALF_ODD`, `ROUND_UP` and `ROUND_DOWN`, see [Rounding](../guide/rounding#rounding-modes) +- `withScale()` to pad or round to a fixed scale, and `round()` with a negative scale +- `Decimal::sum()`, `average()`, `min()` and `max()` +- `split()` and `allocate()` to divide an amount without a remainder getting lost +- `shift()`, `between()`, `clamp()`, `isEven()`, `isOdd()` and `Decimal::tryCreate()` + +### Exponent limit + +Scientific notation is limited to exponents between -10000 and 10000. Larger ones throw an `InvalidArgumentException`. + +### Fixes + The release fixes wrong results and does not change the result of a call that was correct before. Check the following if your code worked around one of the bugs. -### Parsing +#### Parsing - Scientific notation with more than one digit before the decimal point and a negative exponent was parsed wrong, for example `'12.5e-1'` as `0.125`. Stored values that came from such input need correcting. - `'1e+5'` and large floats such as `1e25` are accepted. They threw before. @@ -14,22 +28,21 @@ The release fixes wrong results and does not change the result of a call that wa - `INF` and `NAN` throw an `InvalidArgumentException`. They were stored as the strings `INF` and `NAN`. - A negative scale throws an `InvalidArgumentException`. -### Negative zero +#### Negative zero Zero has no sign. `'-0.0'` becomes `0.0`, `isNegative()` is false for it, and `negate()`, `truncate()` and `trim()` no longer return `-0`. -### Rounding +#### Rounding `floor()` and `ceil()` no longer move whole numbers written with decimals. `Decimal::create('-1.0')->floor()` returned `-2`, and `Decimal::create('1.0')->ceil()` returned `2`. -`round()` with a negative scale throws an `InvalidArgumentException`. It used to surface a `ValueError` from bcmath. -### Conversion +#### Conversion - `toScientific()` returned `.e-1` for zero, lost digits beyond the float precision for large integers, and returned `1.250e2` for `120.50`. - `toInt()` accepts `PHP_INT_MIN`. -### Operands +#### Operands `mod()` and `pow()` accept operands in scientific notation. diff --git a/src/Decimal.php b/src/Decimal.php index f92677b..5ffd5b4 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -39,6 +39,44 @@ class Decimal implements JsonSerializable, Stringable */ public const ROUND_FLOOR = 8; + /** + * @var int + */ + public const ROUND_HALF_DOWN = 9; + + /** + * @var int + */ + public const ROUND_HALF_EVEN = 10; + + /** + * @var int + */ + public const ROUND_HALF_ODD = 11; + + /** + * Towards zero. + * + * @var int + */ + public const ROUND_DOWN = 12; + + /** + * Away from zero. + * + * @var int + */ + public const ROUND_UP = 13; + + /** + * Largest exponent accepted in scientific notation, in either direction. + * + * `1e1000000000` would otherwise expand to a string of a billion digits. + * + * @var int + */ + public const MAX_EXPONENT = 10000; + /** * Integral part of this decimal number. * @@ -157,6 +195,109 @@ public static function create(object|string|float|int $value, ?int $scale = null return new static($value, $scale, $strict); } + /** + * Like create(), but returns null for null and for values that are not numeric. + * + * @param object|string|float|int|null $value + * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. + * + * @return static|null + */ + public static function tryCreate(object|string|float|int|null $value, ?int $scale = null): ?static + { + if ($value === null) { + return null; + } + + try { + return static::create($value, $scale); + } catch (InvalidArgumentException) { + return null; + } + } + + /** + * @param iterable $values + * + * @return static + */ + public static function sum(iterable $values): static + { + $sum = new static('0'); + foreach ($values as $value) { + $sum = $sum->add(static::create($value)); + } + + return $sum; + } + + /** + * @param iterable $values + * @param int $scale Decimal places in the result + * + * @throws \InvalidArgumentException If there are no values. + * + * @return static + */ + public static function average(iterable $values, int $scale): static + { + $sum = new static('0'); + $count = 0; + foreach ($values as $value) { + $sum = $sum->add(static::create($value)); + $count++; + } + if ($count === 0) { + throw new InvalidArgumentException('Cannot average an empty list.'); + } + + return $sum->divide($count, $scale); + } + + /** + * @param iterable $values + * + * @return static + */ + public static function min(iterable $values): static + { + return static::extreme($values, -1); + } + + /** + * @param iterable $values + * + * @return static + */ + public static function max(iterable $values): static + { + return static::extreme($values, 1); + } + + /** + * @param iterable $values + * @param int $direction -1 for the smallest, 1 for the largest value + * + * @throws \InvalidArgumentException + * + * @return static + */ + protected static function extreme(iterable $values, int $direction): static + { + $result = null; + foreach ($values as $value) { + $decimal = static::create($value); + if ($result === null || $decimal->compareTo($result) === $direction) { + $result = $decimal; + } + } + if ($result === null) { + throw new InvalidArgumentException('Cannot pick from an empty list.'); + } + + return $result; + } + /** * Equality * @@ -386,6 +527,69 @@ public function isPositive(): bool return !$this->negative && !$this->isZero(); } + /** + * Returns if a whole number divisible by two. Always false for values with decimals. + * + * @return bool + */ + public function isEven(): bool + { + return $this->isInteger() && (int)substr($this->integralPart, -1) % 2 === 0; + } + + /** + * Returns if a whole number not divisible by two. Always false for values with decimals. + * + * @return bool + */ + public function isOdd(): bool + { + return $this->isInteger() && (int)substr($this->integralPart, -1) % 2 === 1; + } + + /** + * @param self|string|float|int $min + * @param self|string|float|int $max + * @param bool $inclusive If the bounds themselves count as in between. + * + * @return bool + */ + public function between(self|string|float|int $min, self|string|float|int $max, bool $inclusive = true): bool + { + if ($inclusive) { + return $this->compareTo($min) >= 0 && $this->compareTo($max) <= 0; + } + + return $this->compareTo($min) > 0 && $this->compareTo($max) < 0; + } + + /** + * Returns this value limited to the given range. + * + * @param self|string|float|int $min + * @param self|string|float|int $max + * + * @throws \InvalidArgumentException If $min is greater than $max. + * + * @return static + */ + public function clamp(self|string|float|int $min, self|string|float|int $max): static + { + $min = static::create($min); + $max = static::create($max); + if ($min->greaterThan($max)) { + throw new InvalidArgumentException('Min must not be greater than max.'); + } + if ($this->lessThan($min)) { + return $min; + } + if ($this->greaterThan($max)) { + return $max; + } + + return clone $this; + } + /** * Multiply this Decimal by $value and return the product as a new Decimal. * @@ -475,44 +679,212 @@ public function mod(self|string|int $value, ?int $scale = null): static } /** - * @param int $scale Decimal places in the result (only used with mode "half up") - * @param int $roundMode ROUND_HALF_UP, ROUND_CEIL or ROUND_FLOOR. Anything else rounds half up. + * ROUND_FLOOR and ROUND_CEIL always round to a whole number, use withScale() to round + * in those directions at a scale. A mode that is not a ROUND_* constant rounds half up. + * + * @param int $scale Decimal places in the result. Negative values round to tens, hundreds and so on. + * @param int $roundMode One of the ROUND_* constants * * @throws \InvalidArgumentException * * @return static */ public function round(int $scale = 0, int $roundMode = self::ROUND_HALF_UP): static + { + if (in_array($roundMode, [static::ROUND_FLOOR, static::ROUND_CEIL], true)) { + if ($scale < 0) { + throw new InvalidArgumentException('Scale must be >= 0.'); + } + + return new static($this->roundToScale(0, $roundMode), $scale); + } + + if (!in_array($roundMode, static::roundModes(), true)) { + $roundMode = static::ROUND_HALF_UP; + } + + return new static($this->roundToScale($scale, $roundMode), max($scale, 0)); + } + + /** + * Returns this value with exactly the given number of decimal places. + * + * Missing places are filled with zeros, surplus places are rounded. Unlike round(), + * every mode including ROUND_FLOOR and ROUND_CEIL is applied at the scale. + * + * @param int $scale Decimal places in the result + * @param int $roundMode One of the ROUND_* constants + * + * @throws \InvalidArgumentException + * + * @return static + */ + public function withScale(int $scale, int $roundMode = self::ROUND_HALF_UP): static { if ($scale < 0) { throw new InvalidArgumentException('Scale must be >= 0.'); } + if (!in_array($roundMode, static::roundModes(), true)) { + throw new InvalidArgumentException('Unsupported round mode `' . $roundMode . '`.'); + } + if ($scale >= strlen($this->fractionalPart)) { + return $this->copy(null, str_pad($this->fractionalPart, $scale, '0')); + } - // Floor and ceil always round to a whole number in 1.x, the scale only labels the result. - $directed = in_array($roundMode, [static::ROUND_FLOOR, static::ROUND_CEIL], true); - $places = $directed ? 0 : $scale; + return new static($this->roundToScale($scale, $roundMode), $scale); + } - $value = (string)$this; - $truncated = bcadd($value, '0', $places); - if (bccomp($value, $truncated, $this->scale()) === 0) { - return new static($truncated, $scale); + /** + * Moves the decimal point, which multiplies by a power of ten without losing digits. + * + * @param int $places Positive to the right (larger value), negative to the left. + * + * @return static + */ + public function shift(int $places): static + { + $digits = $this->integralPart . $this->fractionalPart; + $point = strlen($this->integralPart) + $places; + if ($point <= 0) { + $digits = str_repeat('0', 1 - $point) . $digits; + $point = 1; } + $digits = str_pad($digits, $point, '0'); - switch ($roundMode) { - case static::ROUND_FLOOR: - $result = $this->isNegative() ? bcsub($truncated, '1', 0) : $truncated; + $value = substr($digits, 0, $point); + if (strlen($digits) > $point) { + $value .= static::RADIX_MARK . substr($digits, $point); + } - break; - case static::ROUND_CEIL: - $result = $this->isNegative() ? $truncated : bcadd($truncated, '1', 0); + return new static(($this->negative ? '-' : '') . $value); + } + + /** + * @return array + */ + protected static function roundModes(): array + { + return [ + static::ROUND_HALF_UP, + static::ROUND_HALF_DOWN, + static::ROUND_HALF_EVEN, + static::ROUND_HALF_ODD, + static::ROUND_UP, + static::ROUND_DOWN, + static::ROUND_CEIL, + static::ROUND_FLOOR, + ]; + } + + /** + * @param int $scale + * @param int $roundMode + * + * @return string + */ + protected function roundToScale(int $scale, int $roundMode): string + { + $shifted = $this->shift($scale); + $integralPart = $shifted->integralPart; + $fractionalPart = $shifted->fractionalPart; + + $exact = trim($fractionalPart, '0') === ''; + $half = $exact ? -1 : bccomp('0.' . $fractionalPart, '0.5', strlen($fractionalPart)); + $odd = (int)substr($integralPart, -1) % 2 === 1; + + $awayFromZero = match ($roundMode) { + static::ROUND_FLOOR => !$exact && $this->negative, + static::ROUND_CEIL => !$exact && !$this->negative, + static::ROUND_UP => !$exact, + static::ROUND_DOWN => false, + static::ROUND_HALF_DOWN => $half > 0, + static::ROUND_HALF_EVEN => $half > 0 || ($half === 0 && $odd), + static::ROUND_HALF_ODD => $half > 0 || ($half === 0 && !$odd), + default => $half >= 0, + }; + if ($awayFromZero) { + $integralPart = bcadd($integralPart, '1', 0); + } + + return (string)(new static(($this->negative ? '-' : '') . $integralPart))->shift(-$scale); + } + + /** + * Splits this value by the given ratios without losing or inventing anything. + * + * The shares add up to this value exactly. What cannot be divided evenly at the scale + * of this value goes to the first shares, one smallest unit each. + * + * @param array $ratios Non negative weights, keys are preserved. + * + * @throws \InvalidArgumentException + * + * @return array + */ + public function allocate(array $ratios): array + { + $weights = []; + $ratioScale = 0; + foreach ($ratios as $key => $ratio) { + $weight = static::create($ratio); + if ($weight->isNegative()) { + throw new InvalidArgumentException('Ratios must not be negative.'); + } + $weights[$key] = (string)$weight; + $ratioScale = max($ratioScale, $weight->scale()); + } + + $total = '0'; + foreach ($weights as $weight) { + $total = bcadd($total, $weight, $ratioScale); + } + if (bccomp($total, '0', $ratioScale) !== 1) { + throw new InvalidArgumentException('Ratios must add up to more than zero.'); + } + $scale = $this->scale(); + $units = $this->absolute()->shift($scale)->integralPart; + + $shares = []; + $remainder = $units; + foreach ($weights as $key => $weight) { + $shares[$key] = bcdiv(bcmul($units, $weight, $ratioScale), $total, 0); + $remainder = bcsub($remainder, $shares[$key], 0); + } + foreach ($weights as $key => $weight) { + if ($remainder === '0') { break; - default: - $e = bcpow('10', (string)($scale + 1)); - $result = bcdiv(bcadd(bcmul($value, $e, 0), $this->isNegative() ? '-5' : '5'), $e, $scale); + } + if (bccomp($weight, '0', $ratioScale) === 1) { + $shares[$key] = bcadd($shares[$key], '1', 0); + $remainder = bcsub($remainder, '1', 0); + } + } + + $sign = $this->negative ? '-' : ''; + foreach ($shares as $key => $share) { + $shares[$key] = (new static($sign . $share))->shift(-$scale); } - return new static($result, $scale); + return $shares; + } + + /** + * Splits this value into equal parts, see allocate(). + * + * @param int $parts + * + * @throws \InvalidArgumentException + * + * @return array + */ + public function split(int $parts): array + { + if ($parts < 1) { + throw new InvalidArgumentException('Parts must be >= 1.'); + } + + return $this->allocate(array_fill(0, $parts, 1)); } /** @@ -815,6 +1187,9 @@ protected function fromScientific(string $value, ?int $scale): void // A mantissa of `1.0` is read as `1` in 1.x. $fractionalPart = $matches[3] === '0' ? '' : $matches[3]; $exp = (int)$matches[4]; + if (abs($exp) > static::MAX_EXPONENT) { + throw new InvalidArgumentException('Exponent must be between -' . static::MAX_EXPONENT . ' and ' . static::MAX_EXPONENT . '.'); + } if ($exp < 0) { $integralPart = str_pad($integralPart, -$exp + 1, '0', STR_PAD_LEFT); diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index aa5ad49..df1a9e8 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -9,6 +9,7 @@ namespace PhpCollective\DecimalObject\Test; +use ArrayIterator; use DivisionByZeroError; use InvalidArgumentException; use PhpCollective\DecimalObject\Decimal; @@ -1190,16 +1191,6 @@ public static function roundModeProvider(): array ]; } - /** - * @return void - */ - public function testRoundNegativeScale(): void - { - $this->expectException(InvalidArgumentException::class); - - (new Decimal('15'))->round(-1); - } - /** * @param string $value * @param string $expected @@ -1272,4 +1263,447 @@ public function testPowOperands(): void $this->assertSame('0.50', (string)$decimal->pow(-1, 2)); $this->assertSame('2.25', (string)(new Decimal('1.50'))->pow(2)); } + + /** + * @param string $value + * @param int $scale + * @param int $mode + * @param string $expected + * + * @return void + */ + #[DataProvider('roundExtraModeProvider')] + public function testRoundExtraModes(string $value, int $scale, int $mode, string $expected): void + { + $this->assertSame($expected, (string)(new Decimal($value))->round($scale, $mode)); + } + + /** + * @return array + */ + public static function roundExtraModeProvider(): array + { + return [ + 'half down tie' => ['1.25', 1, Decimal::ROUND_HALF_DOWN, '1.2'], + 'half down above tie' => ['1.251', 1, Decimal::ROUND_HALF_DOWN, '1.3'], + 'half down negative tie' => ['-1.25', 1, Decimal::ROUND_HALF_DOWN, '-1.2'], + 'half even tie to even' => ['1.25', 1, Decimal::ROUND_HALF_EVEN, '1.2'], + 'half even tie to even up' => ['1.35', 1, Decimal::ROUND_HALF_EVEN, '1.4'], + 'half even negative' => ['-2.5', 0, Decimal::ROUND_HALF_EVEN, '-2'], + 'half even carry' => ['9.5', 0, Decimal::ROUND_HALF_EVEN, '10'], + 'half even above tie' => ['2.5000001', 0, Decimal::ROUND_HALF_EVEN, '3'], + 'half odd tie' => ['2.5', 0, Decimal::ROUND_HALF_ODD, '3'], + 'half odd tie stays' => ['1.5', 0, Decimal::ROUND_HALF_ODD, '1'], + 'down' => ['1.29', 1, Decimal::ROUND_DOWN, '1.2'], + 'down negative' => ['-1.29', 1, Decimal::ROUND_DOWN, '-1.2'], + 'up' => ['1.21', 1, Decimal::ROUND_UP, '1.3'], + 'up negative' => ['-1.21', 1, Decimal::ROUND_UP, '-1.3'], + 'up exact' => ['1.20', 1, Decimal::ROUND_UP, '1.2'], + 'pads' => ['1.5', 3, Decimal::ROUND_HALF_EVEN, '1.500'], + 'to zero keeps scale' => ['-0.004', 2, Decimal::ROUND_HALF_EVEN, '0.00'], + 'tens' => ['15', -1, Decimal::ROUND_HALF_UP, '20'], + 'tens down' => ['14.99', -1, Decimal::ROUND_HALF_UP, '10'], + 'hundreds negative' => ['-1250', -2, Decimal::ROUND_HALF_UP, '-1300'], + 'hundreds half even' => ['1250', -2, Decimal::ROUND_HALF_EVEN, '1200'], + 'tens below' => ['4', -1, Decimal::ROUND_HALF_UP, '0'], + 'php half even still rounds half up' => ['2.5', 0, PHP_ROUND_HALF_EVEN, '3'], + ]; + } + + /** + * @return void + */ + public function testRoundNegativeScaleDirected(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('15'))->round(-1, Decimal::ROUND_FLOOR); + } + + /** + * @param string $value + * @param int $scale + * @param int $mode + * @param string $expected + * + * @return void + */ + #[DataProvider('withScaleProvider')] + public function testWithScale(string $value, int $scale, int $mode, string $expected): void + { + $result = (new Decimal($value))->withScale($scale, $mode); + + $this->assertSame($expected, (string)$result); + $this->assertSame($scale, $result->scale()); + } + + /** + * @return array + */ + public static function withScaleProvider(): array + { + return [ + 'pad' => ['1.5', 3, Decimal::ROUND_HALF_UP, '1.500'], + 'pad integer' => ['7', 2, Decimal::ROUND_HALF_UP, '7.00'], + 'same' => ['1.50', 2, Decimal::ROUND_HALF_UP, '1.50'], + 'round' => ['1.255', 2, Decimal::ROUND_HALF_UP, '1.26'], + 'to integer' => ['1.5', 0, Decimal::ROUND_HALF_UP, '2'], + 'floor at scale' => ['1.25', 1, Decimal::ROUND_FLOOR, '1.2'], + 'floor negative at scale' => ['-1.21', 1, Decimal::ROUND_FLOOR, '-1.3'], + 'ceil at scale' => ['1.21', 1, Decimal::ROUND_CEIL, '1.3'], + 'ceil negative at scale' => ['-1.29', 1, Decimal::ROUND_CEIL, '-1.2'], + 'ceil small negative' => ['-0.001', 2, Decimal::ROUND_CEIL, '0.00'], + 'half even' => ['0.125', 2, Decimal::ROUND_HALF_EVEN, '0.12'], + ]; + } + + /** + * @return void + */ + public function testWithScaleDeclaredScale(): void + { + $result = Decimal::create('0.1', 4)->withScale(4); + + $this->assertSame('0.1000', (string)$result); + } + + /** + * @return void + */ + public function testWithScaleNegative(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('1.5'))->withScale(-1); + } + + /** + * @return void + */ + public function testWithScaleInvalidMode(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('1.5'))->withScale(2, PHP_ROUND_HALF_EVEN); + } + + /** + * @param string $value + * @param int $places + * @param string $expected + * + * @return void + */ + #[DataProvider('shiftProvider')] + public function testShift(string $value, int $places, string $expected): void + { + $this->assertSame($expected, (string)(new Decimal($value))->shift($places)); + } + + /** + * @return array + */ + public static function shiftProvider(): array + { + return [ + 'none' => ['12.34', 0, '12.34'], + 'right' => ['12.34', 1, '123.4'], + 'right all' => ['12.34', 2, '1234'], + 'right beyond' => ['12.34', 4, '123400'], + 'left' => ['12.34', -1, '1.234'], + 'left all' => ['12.34', -2, '0.1234'], + 'left beyond' => ['12.34', -4, '0.001234'], + 'negative' => ['-0.05', 2, '-5'], + 'zero' => ['0', 3, '0'], + 'zero left' => ['0', -2, '0.00'], + 'integer left keeps zeros' => ['100', -2, '1.00'], + ]; + } + + /** + * @return void + */ + public function testExponentLimit(): void + { + $this->assertSame(Decimal::MAX_EXPONENT + 1, strlen((string)new Decimal('1e' . Decimal::MAX_EXPONENT))); + $this->assertSame(Decimal::MAX_EXPONENT, (new Decimal('1e-' . Decimal::MAX_EXPONENT))->scale()); + } + + /** + * @param string $value + * + * @return void + */ + #[DataProvider('exponentLimitProvider')] + public function testExponentLimitExceeded(string $value): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Exponent must be between'); + + new Decimal($value); + } + + /** + * @return array + */ + public static function exponentLimitProvider(): array + { + return [ + 'positive' => ['1e' . (Decimal::MAX_EXPONENT + 1)], + 'negative' => ['1e-' . (Decimal::MAX_EXPONENT + 1)], + 'huge' => ['1e1000000000'], + 'beyond int' => ['1e99999999999999999999'], + ]; + } + + /** + * @return void + */ + public function testTryCreate(): void + { + $this->assertSame('1.50', (string)Decimal::tryCreate('1.50')); + $this->assertSame('1.5', (string)Decimal::tryCreate('1.56', 1)); + $this->assertNull(Decimal::tryCreate(null)); + $this->assertNull(Decimal::tryCreate('')); + $this->assertNull(Decimal::tryCreate('abc')); + $this->assertNull(Decimal::tryCreate('1,5')); + $this->assertNull(Decimal::tryCreate(NAN)); + } + + /** + * @return void + */ + public function testSum(): void + { + $this->assertSame('6.75', (string)Decimal::sum(['1.5', 2, Decimal::create('3.25')])); + $this->assertSame('0', (string)Decimal::sum([])); + $this->assertSame('0.3', (string)Decimal::sum(new ArrayIterator(['0.1', '0.2']))); + } + + /** + * @return void + */ + public function testAverage(): void + { + $this->assertSame('2.00', (string)Decimal::average(['1', '2', '3'], 2)); + $this->assertSame('0.333', (string)Decimal::average(['0', '0', '1'], 3)); + } + + /** + * @return void + */ + public function testAverageEmpty(): void + { + $this->expectException(InvalidArgumentException::class); + + Decimal::average([], 2); + } + + /** + * @return void + */ + public function testMinMax(): void + { + $values = ['2.5', '-1', '2.50', 7, '-1.0']; + + $this->assertSame('-1', (string)Decimal::min($values)); + $this->assertSame('7', (string)Decimal::max($values)); + $this->assertSame('2.5', (string)Decimal::max(['2.5', '2.50'])); + } + + /** + * @return void + */ + public function testMinEmpty(): void + { + $this->expectException(InvalidArgumentException::class); + + Decimal::min([]); + } + + /** + * @return void + */ + public function testMaxEmpty(): void + { + $this->expectException(InvalidArgumentException::class); + + Decimal::max([]); + } + + /** + * @param string $value + * @param bool $even + * @param bool $odd + * + * @return void + */ + #[DataProvider('parityProvider')] + public function testParity(string $value, bool $even, bool $odd): void + { + $decimal = new Decimal($value); + + $this->assertSame($even, $decimal->isEven()); + $this->assertSame($odd, $decimal->isOdd()); + } + + /** + * @return array + */ + public static function parityProvider(): array + { + return [ + 'zero' => ['0', true, false], + 'even' => ['12', true, false], + 'odd' => ['-7', false, true], + 'even with zero decimals' => ['4.00', true, false], + 'odd with zero decimals' => ['3.0', false, true], + 'fraction' => ['2.5', false, false], + 'large' => ['123456789012345678901234567891', false, true], + ]; + } + + /** + * @return void + */ + public function testBetween(): void + { + $decimal = new Decimal('5.0'); + + $this->assertTrue($decimal->between(1, 10)); + $this->assertTrue($decimal->between('5', '5')); + $this->assertTrue($decimal->between('5', 10)); + $this->assertFalse($decimal->between('5', 10, false)); + $this->assertFalse($decimal->between(1, '5.00', false)); + $this->assertTrue($decimal->between('4.99', '5.01', false)); + $this->assertFalse($decimal->between(6, 10)); + } + + /** + * @return void + */ + public function testClamp(): void + { + $this->assertSame('5', (string)(new Decimal('5'))->clamp(1, 10)); + $this->assertSame('1', (string)(new Decimal('-3.5'))->clamp(1, 10)); + $this->assertSame('10.00', (string)(new Decimal('12'))->clamp('1', '10.00')); + } + + /** + * @return void + */ + public function testClampInvalidRange(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('5'))->clamp(10, 1); + } + + /** + * @param string $value + * @param array $ratios + * @param array $expected + * + * @return void + */ + #[DataProvider('allocateProvider')] + public function testAllocate(string $value, array $ratios, array $expected): void + { + $decimal = new Decimal($value); + $shares = $decimal->allocate($ratios); + + $this->assertSame($expected, array_map('strval', $shares)); + $this->assertSame((string)$decimal, (string)Decimal::sum($shares)->withScale($decimal->scale())); + } + + /** + * @return array + */ + public static function allocateProvider(): array + { + return [ + 'even' => ['100.00', [1, 1], ['50.00', '50.00']], + 'thirds' => ['100.00', [1, 1, 1], ['33.34', '33.33', '33.33']], + 'weighted' => ['0.05', [70, 30], ['0.04', '0.01']], + 'decimal ratios' => ['10.00', ['0.7', '0.3'], ['7.00', '3.00']], + 'integer' => ['10', [1, 1, 1], ['4', '3', '3']], + 'negative' => ['-100.00', [1, 1, 1], ['-33.34', '-33.33', '-33.33']], + 'zero ratio gets nothing' => ['0.01', [0, 1, 1], ['0.00', '0.01', '0.00']], + 'keys' => ['1.00', ['a' => 1, 'b' => 3], ['a' => '0.25', 'b' => '0.75']], + 'zero' => ['0.00', [1, 2], ['0.00', '0.00']], + ]; + } + + /** + * @param array $ratios + * + * @return void + */ + #[DataProvider('allocateInvalidProvider')] + public function testAllocateInvalid(array $ratios): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('1.00'))->allocate($ratios); + } + + /** + * @return array + */ + public static function allocateInvalidProvider(): array + { + return [ + 'empty' => [[]], + 'all zero' => [[0, 0]], + 'negative' => [[1, -1]], + 'not numeric' => [['a']], + ]; + } + + /** + * @return void + */ + public function testSplit(): void + { + $shares = (new Decimal('10.00'))->split(3); + + $this->assertSame(['3.34', '3.33', '3.33'], array_map('strval', $shares)); + $this->assertSame(['5'], array_map('strval', (new Decimal('5'))->split(1))); + } + + /** + * @return void + */ + public function testSplitInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('10.00'))->split(0); + } + + /** + * A global bcmath scale set by the application must not leak into results. + * + * @return void + */ + public function testGlobalBcScale(): void + { + $previous = bcscale(2); + try { + $this->assertSame('2', (string)(new Decimal('1.5'))->round()); + $this->assertSame('1.3', (string)(new Decimal('1.25'))->round(1)); + $this->assertSame('2', (string)(new Decimal('1.5'))->withScale(0)); + $this->assertSame('20', (string)(new Decimal('15'))->round(-1)); + $this->assertSame('2', (string)(new Decimal('1.5'))->ceil()); + $this->assertSame(['0.50', '0.50'], array_map('strval', (new Decimal('1.00'))->split(2))); + $this->assertSame(['3.34', '3.33', '3.33'], array_map('strval', (new Decimal('10.00'))->split(3))); + $this->assertSame('3', (string)Decimal::sum([1, 2])); + $this->assertSame('1000', (string)new Decimal('1e3')); + $this->assertSame('1.e3', (new Decimal('1000'))->toScientific()); + $this->assertSame(PHP_INT_MAX, (new Decimal((string)PHP_INT_MAX))->toInt()); + $this->assertTrue((new Decimal('4'))->isEven()); + } finally { + bcscale($previous); + } + } } From a1ec406188eaabdbcee43e5a55dfabb911e14290 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 16:54:45 +0200 Subject: [PATCH 29/29] Add rounding modes, withScale(), allocation and helper methods (#24) All additions. A run of the previous and the new class over the same 10,000 calls differs in one place only: round() with a negative scale returned an exception and now rounds to tens, hundreds and so on. Rounding - ROUND_HALF_DOWN, ROUND_HALF_EVEN, ROUND_HALF_ODD, ROUND_UP and ROUND_DOWN. They get their own values (9 to 13) and not those of the PHP_ROUND_* constants: round() treats unknown modes as half up in 1.x, so reusing 2, 3 and 4 would silently change the result for callers that pass PHP_ROUND_HALF_EVEN today. - withScale() pads or rounds to an exact scale and applies every mode at that scale, including floor and ceil, which round() cannot do in 1.x. - round() accepts a negative scale for the non-directed modes. Input - Scientific notation is limited to exponents up to MAX_EXPONENT (10000). "1e1000000000" used to allocate a billion digits. - tryCreate() returns null for null and non numeric input. Calculation - shift() moves the decimal point. - sum(), average(), min() and max() over any iterable. - allocate() and split() divide a value into shares that sum to it exactly, at the scale of the value. - between(), clamp(), isEven() and isOdd(). All bcmath calls in the new code pass their scale. A global bcscale() set by the application would otherwise leak into the results. --- docs/guide/arithmetic.md | 43 ++++ docs/guide/comparison.md | 25 ++ docs/guide/creating.md | 20 +- docs/guide/rounding.md | 68 +++++- docs/guide/scale.md | 13 +- docs/reference/api.md | 32 ++- docs/reference/upgrading.md | 27 ++- src/Decimal.php | 413 ++++++++++++++++++++++++++++++-- tests/DecimalTest.php | 454 +++++++++++++++++++++++++++++++++++- 9 files changed, 1045 insertions(+), 50 deletions(-) diff --git a/docs/guide/arithmetic.md b/docs/guide/arithmetic.md index eaa28fa..d0be489 100644 --- a/docs/guide/arithmetic.md +++ b/docs/guide/arithmetic.md @@ -80,6 +80,49 @@ echo Decimal::create('-23.44')->absolute(); // 23.44 echo Decimal::create('23.44')->negate(); // -23.44 ``` +## Moving the decimal point + +`shift()` multiplies by a power of ten without touching the digits. Use it to convert between units, such as an amount and its cents: + +```php +echo Decimal::create('19.99')->shift(2); // 1999 +echo Decimal::create('1999')->shift(-2); // 19.99 +echo Decimal::create('12.34')->shift(-3); // 0.01234 +``` + +## Several values at once + +```php +echo Decimal::sum(['1.5', 2, Decimal::create('3.25')]); // 6.75 +echo Decimal::average(['1', '2', '4'], 2); // 2.33 +echo Decimal::min(['2.5', '-1', 7]); // -1 +echo Decimal::max(['2.5', '-1', 7]); // 7 +``` + +All four take any iterable. `sum()` of nothing is `0`. `average()`, `min()` and `max()` throw an `InvalidArgumentException` for an empty list. `average()` needs the scale for the same reason `divide()` does, and cuts off there. + +## Splitting an amount + +Dividing an amount and rounding each share rarely adds up to the original. `split()` and `allocate()` hand out every smallest unit: + +```php +$shares = Decimal::create('100.00')->split(3); +// 33.34, 33.33, 33.33 + +$shares = Decimal::create('100.00')->allocate([70, 30]); +// 70.00, 30.00 + +$shares = Decimal::create('10.00')->allocate(['rent' => 2, 'food' => 1]); +// ['rent' => 6.67, 'food' => 3.33] +``` + +The shares always sum to the original value. They have its scale, which also sets the smallest unit: `10` splits into `4, 3, 3`. What is left after the even division goes to the first shares, one unit each. Keys of the ratio array are kept, and a ratio of `0` receives nothing. + +Ratios can be integers or decimals such as `'0.7'` and `'0.3'`. Negative ratios, an empty list and ratios that sum to zero throw an `InvalidArgumentException`. + +> [!TIP] Added in 1.4 +> `shift()`, `sum()`, `average()`, `min()`, `max()`, `split()` and `allocate()`. + ## Chaining ```php diff --git a/docs/guide/comparison.md b/docs/guide/comparison.md index e27a6c1..ad2748d 100644 --- a/docs/guide/comparison.md +++ b/docs/guide/comparison.md @@ -27,6 +27,20 @@ usort($values, fn (Decimal $a, Decimal $b) => $a->compareTo($b)); // -1, 2.05, 2.5 ``` +## Ranges + +```php +var_dump(Decimal::create('5')->between(1, 10)); // bool(true) +var_dump(Decimal::create('5')->between(5, 10, false)); // bool(false) + +echo Decimal::create('12')->clamp(0, 10); // 10 +echo Decimal::create('-3')->clamp(0, 10); // 0 +``` + +`between()` includes both bounds unless the third argument is `false`. `clamp()` returns the nearest bound for a value outside the range and throws an `InvalidArgumentException` when the lower bound is above the upper one. + +> [!TIP] Added in 1.4 + ::: warning Do not use == or === `==` on two objects compares their properties, so `1.50 == 1.5` is `false`. `===` compares identity. Use `equals()`. ::: @@ -50,3 +64,14 @@ Zero is neither positive nor negative. var_dump(Decimal::create('5.00')->isInteger()); // bool(true) var_dump(Decimal::create('5.01')->isInteger()); // bool(false) ``` + +`isEven()` and `isOdd()` look at whole numbers of any size. Both are false for a value with decimals: + +```php +var_dump(Decimal::create('4.00')->isEven()); // bool(true) +var_dump(Decimal::create('7')->isOdd()); // bool(true) +var_dump(Decimal::create('2.5')->isEven()); // bool(false) +``` + +> [!TIP] Added in 1.4 +> `isEven()` and `isOdd()`. diff --git a/docs/guide/creating.md b/docs/guide/creating.md index e771455..a1d4ed9 100644 --- a/docs/guide/creating.md +++ b/docs/guide/creating.md @@ -49,10 +49,26 @@ Localized strings such as `'1.018,90'` are rejected. Parse them with `NumberForm A float is converted with PHP's own float to string rules, which round to the `precision` ini setting. That hides small errors but cannot restore digits the float never had. For money and other exact values, read the number as a string from the request or the database. ::: -::: warning Scientific notation from untrusted input -`'1e1000000000'` is twelve characters long and expands to a string of a billion digits. If the value comes from a request, reject exponent notation or check the size of the exponent before you create the object. A limit on the input length does not help. +::: info Exponent limit +Exponents beyond `Decimal::MAX_EXPONENT` (10000 in either direction) throw an `InvalidArgumentException`. Without a limit, the twelve characters of `'1e1000000000'` would expand to a string of a billion digits. A subclass can redefine the constant. ::: +## Optional input + +`tryCreate()` returns `null` where `create()` would throw for a value that is not numeric, and passes `null` through: + +```php +Decimal::tryCreate('1.50'); // Decimal 1.50 +Decimal::tryCreate('abc'); // null +Decimal::tryCreate(''); // null +Decimal::tryCreate(null); // null +``` + +This fits optional form fields and nullable columns. + +> [!TIP] Added in 1.4 +> `tryCreate()` and the exponent limit. + ## Setting a scale The second argument fixes the number of decimal places: diff --git a/docs/guide/rounding.md b/docs/guide/rounding.md index bba8110..6e394b8 100644 --- a/docs/guide/rounding.md +++ b/docs/guide/rounding.md @@ -22,22 +22,61 @@ The default mode rounds half away from zero, like PHP's `round()`. It works on t ## Rounding modes +Pass a mode as second argument. All of these round at the given scale: + +| Constant | Direction | `2.5` | `3.5` | `-2.5` | `2.1` | +| --- | --- | --- | --- | --- | --- | +| `Decimal::ROUND_HALF_UP` | nearest, ties away from zero (default) | `3` | `4` | `-3` | `2` | +| `Decimal::ROUND_HALF_DOWN` | nearest, ties towards zero | `2` | `3` | `-2` | `2` | +| `Decimal::ROUND_HALF_EVEN` | nearest, ties to the even neighbor | `2` | `4` | `-2` | `2` | +| `Decimal::ROUND_HALF_ODD` | nearest, ties to the odd neighbor | `3` | `3` | `-3` | `2` | +| `Decimal::ROUND_UP` | away from zero | `3` | `4` | `-3` | `3` | +| `Decimal::ROUND_DOWN` | towards zero | `2` | `3` | `-2` | `2` | + +```php +echo Decimal::create('0.125')->round(2, Decimal::ROUND_HALF_EVEN); // 0.12 +echo Decimal::create('12.375')->round(2, Decimal::ROUND_DOWN); // 12.37 +``` + +`ROUND_HALF_EVEN` is the banker's rounding used in accounting to avoid a drift over many values. + +> [!TIP] Added in 1.4 +> `ROUND_HALF_DOWN`, `ROUND_HALF_EVEN`, `ROUND_HALF_ODD`, `ROUND_UP` and `ROUND_DOWN`. + +### Floor and ceil modes + | Constant | Direction | `12.375` | `-12.375` | | --- | --- | --- | --- | -| `Decimal::ROUND_HALF_UP` | nearest, half away from zero, at the given scale | `12.38` at scale 2 | `-12.38` at scale 2 | | `Decimal::ROUND_FLOOR` | whole number towards negative infinity | `12` | `-13` | | `Decimal::ROUND_CEIL` | whole number towards positive infinity | `13` | `-12` | +::: warning Limits in 1.x +With `round()`, `ROUND_FLOOR` and `ROUND_CEIL` always round to a whole number. A scale passed along with them does not move the rounding position. Use [`withScale()`](#withscale) to floor or ceil at a scale. + +The constants have their own values. PHP's `PHP_ROUND_HALF_EVEN`, `PHP_ROUND_HALF_ODD` and `PHP_ROUND_HALF_DOWN` are not the same numbers, and `round()` treats any value it does not know as half up. The next major version will reject unknown modes. +::: + +## withScale() + +Returns the value with exactly the given number of decimal places. Missing places are filled with zeros, surplus places are rounded: + ```php -echo $value->round(0, Decimal::ROUND_FLOOR); // 12 -echo $value->round(0, Decimal::ROUND_CEIL); // 13 +echo Decimal::create('1.5')->withScale(3); // 1.500 +echo Decimal::create('7')->withScale(2); // 7.00 +echo Decimal::create('1.255')->withScale(2); // 1.26 ``` -::: warning Limits of the directed modes -`ROUND_FLOOR` and `ROUND_CEIL` always round to a whole number. A scale passed along with them does not move the rounding position. +Every mode is applied at the scale, including floor and ceil: -Any other mode rounds half up. That includes PHP's `PHP_ROUND_HALF_EVEN`, `PHP_ROUND_HALF_ODD` and `PHP_ROUND_HALF_DOWN`, which are not supported. The next major version will apply the scale to all modes and reject unsupported ones. -::: +```php +echo Decimal::create('12.375')->withScale(1, Decimal::ROUND_FLOOR); // 12.3 +echo Decimal::create('-12.375')->withScale(1, Decimal::ROUND_FLOOR); // -12.4 +echo Decimal::create('12.375')->withScale(1, Decimal::ROUND_CEIL); // 12.4 +``` + +Use it before output or storage when a column or an API expects a fixed number of decimals. An unknown mode throws an `InvalidArgumentException`. + +> [!TIP] Added in 1.4 ## floor() and ceil() @@ -62,6 +101,17 @@ echo Decimal::create('12.3')->truncate(5); // 12.3 `truncate()` never adds zeros. Use `round()` when the result needs a fixed scale. -## Negative scale +## Rounding to tens and hundreds + +A negative scale rounds left of the decimal point: + +```php +echo Decimal::create('1250')->round(-2); // 1300 +echo Decimal::create('14.99')->round(-1); // 10 +echo Decimal::create('1250')->round(-2, Decimal::ROUND_HALF_EVEN); // 1200 +``` + +This is not available for `ROUND_FLOOR`, `ROUND_CEIL`, `withScale()` and `truncate()`, which throw an `InvalidArgumentException` for a scale below zero. -Rounding to tens or hundreds is not supported. `round()` and `truncate()` throw an `InvalidArgumentException` for a scale below zero. +> [!TIP] Added in 1.4 +> `round()` threw for every negative scale before. diff --git a/docs/guide/scale.md b/docs/guide/scale.md index 2dac922..de9ec24 100644 --- a/docs/guide/scale.md +++ b/docs/guide/scale.md @@ -47,7 +47,18 @@ A cut-off quotient does not multiply back to the original: echo Decimal::create('100')->divide('3', 2)->multiply(3); // 99.99 ``` -When splitting an amount, calculate the last share as the remainder instead of dividing again. +To split an amount without losing a cent, use [`split()` or `allocate()`](./arithmetic#splitting-an-amount). + +## Setting a fixed scale + +`withScale()` pads or rounds to an exact number of decimal places: + +```php +echo Decimal::create('7')->withScale(2); // 7.00 +echo Decimal::create('1.255')->withScale(2); // 1.26 +``` + +See [Rounding](./rounding#withscale) for the rounding modes it accepts. ## Removing trailing zeros diff --git a/docs/reference/api.md b/docs/reference/api.md index ad8f898..75b05ff 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -10,6 +10,7 @@ Class `PhpCollective\DecimalObject\Decimal`, implementing `JsonSerializable` and | --- | --- | | `new Decimal($value, ?int $scale = null, bool $strict = false)` | Creates a decimal. Also accepts any `Stringable` with a numeric string. | | `Decimal::create($value, ?int $scale = null, bool $strict = false): static` | Same as the constructor, for chaining. | +| `Decimal::tryCreate($value, ?int $scale = null): ?static` | Returns `null` for `null` and for input that is not numeric. | See [Creating decimals](../guide/creating). @@ -24,16 +25,30 @@ See [Creating decimals](../guide/creating). | `mod($value, ?int $scale = null): static` | Remainder. `$value` is a `Decimal`, string or integer. | scale of the value | | `pow($exponent, ?int $scale = null): static` | Power. `$exponent` is a whole number as `Decimal`, string or integer. | scale of the value | | `sqrt(?int $scale = null): static` | Square root | scale of the value | +| `shift(int $places): static` | Moves the decimal point, multiplying by a power of ten | follows the digits | | `absolute(): static` | Value without sign | unchanged | | `negate(): static` | Value with the opposite sign | unchanged | +| Static method | Description | +| --- | --- | +| `Decimal::sum(iterable $values): static` | Sum, `0` for an empty list | +| `Decimal::average(iterable $values, int $scale): static` | Mean, cut off at `$scale` | +| `Decimal::min(iterable $values): static` | Smallest value | +| `Decimal::max(iterable $values): static` | Largest value | + +| Method | Description | +| --- | --- | +| `allocate(array $ratios): array` | Shares by ratio that sum to the value exactly, keys preserved | +| `split(int $parts): array` | Equal shares that sum to the value exactly | + See [Arithmetic](../guide/arithmetic). ## Rounding | Method | Description | | --- | --- | -| `round(int $scale = 0, int $roundMode = Decimal::ROUND_HALF_UP): static` | Rounds half up to `$scale` decimal places, or to a whole number with `ROUND_FLOOR` and `ROUND_CEIL`. | +| `round(int $scale = 0, int $roundMode = Decimal::ROUND_HALF_UP): static` | Rounds to `$scale` decimal places, or to a whole number with `ROUND_FLOOR` and `ROUND_CEIL`. A negative scale rounds to tens, hundreds and so on. | +| `withScale(int $scale, int $roundMode = Decimal::ROUND_HALF_UP): static` | Pads or rounds to exactly `$scale` decimal places, in any mode. | | `floor(): static` | Nearest integer towards negative infinity. | | `ceil(): static` | Nearest integer towards positive infinity. | | `truncate(int $scale = 0): static` | Cuts off digits beyond `$scale`. | @@ -44,6 +59,12 @@ See [Arithmetic](../guide/arithmetic). | `Decimal::ROUND_HALF_UP` | `1`, same as `PHP_ROUND_HALF_UP` | | `Decimal::ROUND_CEIL` | `7` | | `Decimal::ROUND_FLOOR` | `8` | +| `Decimal::ROUND_HALF_DOWN` | `9` | +| `Decimal::ROUND_HALF_EVEN` | `10` | +| `Decimal::ROUND_HALF_ODD` | `11` | +| `Decimal::ROUND_DOWN` | `12`, towards zero | +| `Decimal::ROUND_UP` | `13`, away from zero | +| `Decimal::MAX_EXPONENT` | `10000`, limit for scientific notation input | See [Rounding](../guide/rounding). @@ -57,6 +78,8 @@ See [Rounding](../guide/rounding). | `greaterThanOrEquals($value): bool` | | | `lessThan($value): bool` | | | `lessThanOrEquals($value): bool` | | +| `between($min, $max, bool $inclusive = true): bool` | Within the range | +| `clamp($min, $max): static` | The value, or the nearest bound when outside the range | See [Comparison and checks](../guide/comparison). @@ -68,6 +91,8 @@ See [Comparison and checks](../guide/comparison). | `isPositive(): bool` | Greater than zero | | `isNegative(): bool` | Less than zero | | `isInteger(): bool` | No decimals other than zeros | +| `isEven(): bool` | Whole number divisible by two | +| `isOdd(): bool` | Whole number not divisible by two | | `isBigInteger(): bool` | Integral part does not fit a PHP integer | | `isBigDecimal(): bool` | `toFloat()` would refuse the value | | `sign(): int` | `-1`, `0` or `1` | @@ -90,7 +115,10 @@ See [Conversion](../guide/conversion). | Situation | Exception | | --- | --- | | Input is not numeric, or a float is `INF` or `NAN` | `InvalidArgumentException` | -| Negative scale in the constructor, `round()` or `truncate()` | `InvalidArgumentException` | +| Exponent beyond `Decimal::MAX_EXPONENT` | `InvalidArgumentException` | +| Negative scale in the constructor, `withScale()`, `truncate()` or `round()` with `ROUND_FLOOR` / `ROUND_CEIL` | `InvalidArgumentException` | +| Unknown mode in `withScale()` | `InvalidArgumentException` | +| Empty list in `average()`, `min()`, `max()`; invalid ratios in `allocate()`; `clamp()` with `$min` above `$max` | `InvalidArgumentException` | | Strict mode and the input has more decimals than the scale | `InvalidArgumentException` | | `divide()` or `mod()` by zero | `DivisionByZeroError` | | `toInt()` or `toFloat()` on a value that does not fit | `TypeError` | diff --git a/docs/reference/upgrading.md b/docs/reference/upgrading.md index 1f45b94..76eadf3 100644 --- a/docs/reference/upgrading.md +++ b/docs/reference/upgrading.md @@ -1,12 +1,26 @@ # Upgrading -## From 1.3 +## From 1.3 to 1.4 PHP 8.2 is now required. Composer keeps PHP 8.1 projects on 1.3. +### New + +- Rounding modes `ROUND_HALF_DOWN`, `ROUND_HALF_EVEN`, `ROUND_HALF_ODD`, `ROUND_UP` and `ROUND_DOWN`, see [Rounding](../guide/rounding#rounding-modes) +- `withScale()` to pad or round to a fixed scale, and `round()` with a negative scale +- `Decimal::sum()`, `average()`, `min()` and `max()` +- `split()` and `allocate()` to divide an amount without a remainder getting lost +- `shift()`, `between()`, `clamp()`, `isEven()`, `isOdd()` and `Decimal::tryCreate()` + +### Exponent limit + +Scientific notation is limited to exponents between -10000 and 10000. Larger ones throw an `InvalidArgumentException`. + +### Fixes + The release fixes wrong results and does not change the result of a call that was correct before. Check the following if your code worked around one of the bugs. -### Parsing +#### Parsing - Scientific notation with more than one digit before the decimal point and a negative exponent was parsed wrong, for example `'12.5e-1'` as `0.125`. Stored values that came from such input need correcting. - `'1e+5'` and large floats such as `1e25` are accepted. They threw before. @@ -14,22 +28,21 @@ The release fixes wrong results and does not change the result of a call that wa - `INF` and `NAN` throw an `InvalidArgumentException`. They were stored as the strings `INF` and `NAN`. - A negative scale throws an `InvalidArgumentException`. -### Negative zero +#### Negative zero Zero has no sign. `'-0.0'` becomes `0.0`, `isNegative()` is false for it, and `negate()`, `truncate()` and `trim()` no longer return `-0`. -### Rounding +#### Rounding `floor()` and `ceil()` no longer move whole numbers written with decimals. `Decimal::create('-1.0')->floor()` returned `-2`, and `Decimal::create('1.0')->ceil()` returned `2`. -`round()` with a negative scale throws an `InvalidArgumentException`. It used to surface a `ValueError` from bcmath. -### Conversion +#### Conversion - `toScientific()` returned `.e-1` for zero, lost digits beyond the float precision for large integers, and returned `1.250e2` for `120.50`. - `toInt()` accepts `PHP_INT_MIN`. -### Operands +#### Operands `mod()` and `pow()` accept operands in scientific notation. diff --git a/src/Decimal.php b/src/Decimal.php index f92677b..5ffd5b4 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -39,6 +39,44 @@ class Decimal implements JsonSerializable, Stringable */ public const ROUND_FLOOR = 8; + /** + * @var int + */ + public const ROUND_HALF_DOWN = 9; + + /** + * @var int + */ + public const ROUND_HALF_EVEN = 10; + + /** + * @var int + */ + public const ROUND_HALF_ODD = 11; + + /** + * Towards zero. + * + * @var int + */ + public const ROUND_DOWN = 12; + + /** + * Away from zero. + * + * @var int + */ + public const ROUND_UP = 13; + + /** + * Largest exponent accepted in scientific notation, in either direction. + * + * `1e1000000000` would otherwise expand to a string of a billion digits. + * + * @var int + */ + public const MAX_EXPONENT = 10000; + /** * Integral part of this decimal number. * @@ -157,6 +195,109 @@ public static function create(object|string|float|int $value, ?int $scale = null return new static($value, $scale, $strict); } + /** + * Like create(), but returns null for null and for values that are not numeric. + * + * @param object|string|float|int|null $value + * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. + * + * @return static|null + */ + public static function tryCreate(object|string|float|int|null $value, ?int $scale = null): ?static + { + if ($value === null) { + return null; + } + + try { + return static::create($value, $scale); + } catch (InvalidArgumentException) { + return null; + } + } + + /** + * @param iterable $values + * + * @return static + */ + public static function sum(iterable $values): static + { + $sum = new static('0'); + foreach ($values as $value) { + $sum = $sum->add(static::create($value)); + } + + return $sum; + } + + /** + * @param iterable $values + * @param int $scale Decimal places in the result + * + * @throws \InvalidArgumentException If there are no values. + * + * @return static + */ + public static function average(iterable $values, int $scale): static + { + $sum = new static('0'); + $count = 0; + foreach ($values as $value) { + $sum = $sum->add(static::create($value)); + $count++; + } + if ($count === 0) { + throw new InvalidArgumentException('Cannot average an empty list.'); + } + + return $sum->divide($count, $scale); + } + + /** + * @param iterable $values + * + * @return static + */ + public static function min(iterable $values): static + { + return static::extreme($values, -1); + } + + /** + * @param iterable $values + * + * @return static + */ + public static function max(iterable $values): static + { + return static::extreme($values, 1); + } + + /** + * @param iterable $values + * @param int $direction -1 for the smallest, 1 for the largest value + * + * @throws \InvalidArgumentException + * + * @return static + */ + protected static function extreme(iterable $values, int $direction): static + { + $result = null; + foreach ($values as $value) { + $decimal = static::create($value); + if ($result === null || $decimal->compareTo($result) === $direction) { + $result = $decimal; + } + } + if ($result === null) { + throw new InvalidArgumentException('Cannot pick from an empty list.'); + } + + return $result; + } + /** * Equality * @@ -386,6 +527,69 @@ public function isPositive(): bool return !$this->negative && !$this->isZero(); } + /** + * Returns if a whole number divisible by two. Always false for values with decimals. + * + * @return bool + */ + public function isEven(): bool + { + return $this->isInteger() && (int)substr($this->integralPart, -1) % 2 === 0; + } + + /** + * Returns if a whole number not divisible by two. Always false for values with decimals. + * + * @return bool + */ + public function isOdd(): bool + { + return $this->isInteger() && (int)substr($this->integralPart, -1) % 2 === 1; + } + + /** + * @param self|string|float|int $min + * @param self|string|float|int $max + * @param bool $inclusive If the bounds themselves count as in between. + * + * @return bool + */ + public function between(self|string|float|int $min, self|string|float|int $max, bool $inclusive = true): bool + { + if ($inclusive) { + return $this->compareTo($min) >= 0 && $this->compareTo($max) <= 0; + } + + return $this->compareTo($min) > 0 && $this->compareTo($max) < 0; + } + + /** + * Returns this value limited to the given range. + * + * @param self|string|float|int $min + * @param self|string|float|int $max + * + * @throws \InvalidArgumentException If $min is greater than $max. + * + * @return static + */ + public function clamp(self|string|float|int $min, self|string|float|int $max): static + { + $min = static::create($min); + $max = static::create($max); + if ($min->greaterThan($max)) { + throw new InvalidArgumentException('Min must not be greater than max.'); + } + if ($this->lessThan($min)) { + return $min; + } + if ($this->greaterThan($max)) { + return $max; + } + + return clone $this; + } + /** * Multiply this Decimal by $value and return the product as a new Decimal. * @@ -475,44 +679,212 @@ public function mod(self|string|int $value, ?int $scale = null): static } /** - * @param int $scale Decimal places in the result (only used with mode "half up") - * @param int $roundMode ROUND_HALF_UP, ROUND_CEIL or ROUND_FLOOR. Anything else rounds half up. + * ROUND_FLOOR and ROUND_CEIL always round to a whole number, use withScale() to round + * in those directions at a scale. A mode that is not a ROUND_* constant rounds half up. + * + * @param int $scale Decimal places in the result. Negative values round to tens, hundreds and so on. + * @param int $roundMode One of the ROUND_* constants * * @throws \InvalidArgumentException * * @return static */ public function round(int $scale = 0, int $roundMode = self::ROUND_HALF_UP): static + { + if (in_array($roundMode, [static::ROUND_FLOOR, static::ROUND_CEIL], true)) { + if ($scale < 0) { + throw new InvalidArgumentException('Scale must be >= 0.'); + } + + return new static($this->roundToScale(0, $roundMode), $scale); + } + + if (!in_array($roundMode, static::roundModes(), true)) { + $roundMode = static::ROUND_HALF_UP; + } + + return new static($this->roundToScale($scale, $roundMode), max($scale, 0)); + } + + /** + * Returns this value with exactly the given number of decimal places. + * + * Missing places are filled with zeros, surplus places are rounded. Unlike round(), + * every mode including ROUND_FLOOR and ROUND_CEIL is applied at the scale. + * + * @param int $scale Decimal places in the result + * @param int $roundMode One of the ROUND_* constants + * + * @throws \InvalidArgumentException + * + * @return static + */ + public function withScale(int $scale, int $roundMode = self::ROUND_HALF_UP): static { if ($scale < 0) { throw new InvalidArgumentException('Scale must be >= 0.'); } + if (!in_array($roundMode, static::roundModes(), true)) { + throw new InvalidArgumentException('Unsupported round mode `' . $roundMode . '`.'); + } + if ($scale >= strlen($this->fractionalPart)) { + return $this->copy(null, str_pad($this->fractionalPart, $scale, '0')); + } - // Floor and ceil always round to a whole number in 1.x, the scale only labels the result. - $directed = in_array($roundMode, [static::ROUND_FLOOR, static::ROUND_CEIL], true); - $places = $directed ? 0 : $scale; + return new static($this->roundToScale($scale, $roundMode), $scale); + } - $value = (string)$this; - $truncated = bcadd($value, '0', $places); - if (bccomp($value, $truncated, $this->scale()) === 0) { - return new static($truncated, $scale); + /** + * Moves the decimal point, which multiplies by a power of ten without losing digits. + * + * @param int $places Positive to the right (larger value), negative to the left. + * + * @return static + */ + public function shift(int $places): static + { + $digits = $this->integralPart . $this->fractionalPart; + $point = strlen($this->integralPart) + $places; + if ($point <= 0) { + $digits = str_repeat('0', 1 - $point) . $digits; + $point = 1; } + $digits = str_pad($digits, $point, '0'); - switch ($roundMode) { - case static::ROUND_FLOOR: - $result = $this->isNegative() ? bcsub($truncated, '1', 0) : $truncated; + $value = substr($digits, 0, $point); + if (strlen($digits) > $point) { + $value .= static::RADIX_MARK . substr($digits, $point); + } - break; - case static::ROUND_CEIL: - $result = $this->isNegative() ? $truncated : bcadd($truncated, '1', 0); + return new static(($this->negative ? '-' : '') . $value); + } + + /** + * @return array + */ + protected static function roundModes(): array + { + return [ + static::ROUND_HALF_UP, + static::ROUND_HALF_DOWN, + static::ROUND_HALF_EVEN, + static::ROUND_HALF_ODD, + static::ROUND_UP, + static::ROUND_DOWN, + static::ROUND_CEIL, + static::ROUND_FLOOR, + ]; + } + + /** + * @param int $scale + * @param int $roundMode + * + * @return string + */ + protected function roundToScale(int $scale, int $roundMode): string + { + $shifted = $this->shift($scale); + $integralPart = $shifted->integralPart; + $fractionalPart = $shifted->fractionalPart; + + $exact = trim($fractionalPart, '0') === ''; + $half = $exact ? -1 : bccomp('0.' . $fractionalPart, '0.5', strlen($fractionalPart)); + $odd = (int)substr($integralPart, -1) % 2 === 1; + + $awayFromZero = match ($roundMode) { + static::ROUND_FLOOR => !$exact && $this->negative, + static::ROUND_CEIL => !$exact && !$this->negative, + static::ROUND_UP => !$exact, + static::ROUND_DOWN => false, + static::ROUND_HALF_DOWN => $half > 0, + static::ROUND_HALF_EVEN => $half > 0 || ($half === 0 && $odd), + static::ROUND_HALF_ODD => $half > 0 || ($half === 0 && !$odd), + default => $half >= 0, + }; + if ($awayFromZero) { + $integralPart = bcadd($integralPart, '1', 0); + } + + return (string)(new static(($this->negative ? '-' : '') . $integralPart))->shift(-$scale); + } + + /** + * Splits this value by the given ratios without losing or inventing anything. + * + * The shares add up to this value exactly. What cannot be divided evenly at the scale + * of this value goes to the first shares, one smallest unit each. + * + * @param array $ratios Non negative weights, keys are preserved. + * + * @throws \InvalidArgumentException + * + * @return array + */ + public function allocate(array $ratios): array + { + $weights = []; + $ratioScale = 0; + foreach ($ratios as $key => $ratio) { + $weight = static::create($ratio); + if ($weight->isNegative()) { + throw new InvalidArgumentException('Ratios must not be negative.'); + } + $weights[$key] = (string)$weight; + $ratioScale = max($ratioScale, $weight->scale()); + } + + $total = '0'; + foreach ($weights as $weight) { + $total = bcadd($total, $weight, $ratioScale); + } + if (bccomp($total, '0', $ratioScale) !== 1) { + throw new InvalidArgumentException('Ratios must add up to more than zero.'); + } + $scale = $this->scale(); + $units = $this->absolute()->shift($scale)->integralPart; + + $shares = []; + $remainder = $units; + foreach ($weights as $key => $weight) { + $shares[$key] = bcdiv(bcmul($units, $weight, $ratioScale), $total, 0); + $remainder = bcsub($remainder, $shares[$key], 0); + } + foreach ($weights as $key => $weight) { + if ($remainder === '0') { break; - default: - $e = bcpow('10', (string)($scale + 1)); - $result = bcdiv(bcadd(bcmul($value, $e, 0), $this->isNegative() ? '-5' : '5'), $e, $scale); + } + if (bccomp($weight, '0', $ratioScale) === 1) { + $shares[$key] = bcadd($shares[$key], '1', 0); + $remainder = bcsub($remainder, '1', 0); + } + } + + $sign = $this->negative ? '-' : ''; + foreach ($shares as $key => $share) { + $shares[$key] = (new static($sign . $share))->shift(-$scale); } - return new static($result, $scale); + return $shares; + } + + /** + * Splits this value into equal parts, see allocate(). + * + * @param int $parts + * + * @throws \InvalidArgumentException + * + * @return array + */ + public function split(int $parts): array + { + if ($parts < 1) { + throw new InvalidArgumentException('Parts must be >= 1.'); + } + + return $this->allocate(array_fill(0, $parts, 1)); } /** @@ -815,6 +1187,9 @@ protected function fromScientific(string $value, ?int $scale): void // A mantissa of `1.0` is read as `1` in 1.x. $fractionalPart = $matches[3] === '0' ? '' : $matches[3]; $exp = (int)$matches[4]; + if (abs($exp) > static::MAX_EXPONENT) { + throw new InvalidArgumentException('Exponent must be between -' . static::MAX_EXPONENT . ' and ' . static::MAX_EXPONENT . '.'); + } if ($exp < 0) { $integralPart = str_pad($integralPart, -$exp + 1, '0', STR_PAD_LEFT); diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index aa5ad49..df1a9e8 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -9,6 +9,7 @@ namespace PhpCollective\DecimalObject\Test; +use ArrayIterator; use DivisionByZeroError; use InvalidArgumentException; use PhpCollective\DecimalObject\Decimal; @@ -1190,16 +1191,6 @@ public static function roundModeProvider(): array ]; } - /** - * @return void - */ - public function testRoundNegativeScale(): void - { - $this->expectException(InvalidArgumentException::class); - - (new Decimal('15'))->round(-1); - } - /** * @param string $value * @param string $expected @@ -1272,4 +1263,447 @@ public function testPowOperands(): void $this->assertSame('0.50', (string)$decimal->pow(-1, 2)); $this->assertSame('2.25', (string)(new Decimal('1.50'))->pow(2)); } + + /** + * @param string $value + * @param int $scale + * @param int $mode + * @param string $expected + * + * @return void + */ + #[DataProvider('roundExtraModeProvider')] + public function testRoundExtraModes(string $value, int $scale, int $mode, string $expected): void + { + $this->assertSame($expected, (string)(new Decimal($value))->round($scale, $mode)); + } + + /** + * @return array + */ + public static function roundExtraModeProvider(): array + { + return [ + 'half down tie' => ['1.25', 1, Decimal::ROUND_HALF_DOWN, '1.2'], + 'half down above tie' => ['1.251', 1, Decimal::ROUND_HALF_DOWN, '1.3'], + 'half down negative tie' => ['-1.25', 1, Decimal::ROUND_HALF_DOWN, '-1.2'], + 'half even tie to even' => ['1.25', 1, Decimal::ROUND_HALF_EVEN, '1.2'], + 'half even tie to even up' => ['1.35', 1, Decimal::ROUND_HALF_EVEN, '1.4'], + 'half even negative' => ['-2.5', 0, Decimal::ROUND_HALF_EVEN, '-2'], + 'half even carry' => ['9.5', 0, Decimal::ROUND_HALF_EVEN, '10'], + 'half even above tie' => ['2.5000001', 0, Decimal::ROUND_HALF_EVEN, '3'], + 'half odd tie' => ['2.5', 0, Decimal::ROUND_HALF_ODD, '3'], + 'half odd tie stays' => ['1.5', 0, Decimal::ROUND_HALF_ODD, '1'], + 'down' => ['1.29', 1, Decimal::ROUND_DOWN, '1.2'], + 'down negative' => ['-1.29', 1, Decimal::ROUND_DOWN, '-1.2'], + 'up' => ['1.21', 1, Decimal::ROUND_UP, '1.3'], + 'up negative' => ['-1.21', 1, Decimal::ROUND_UP, '-1.3'], + 'up exact' => ['1.20', 1, Decimal::ROUND_UP, '1.2'], + 'pads' => ['1.5', 3, Decimal::ROUND_HALF_EVEN, '1.500'], + 'to zero keeps scale' => ['-0.004', 2, Decimal::ROUND_HALF_EVEN, '0.00'], + 'tens' => ['15', -1, Decimal::ROUND_HALF_UP, '20'], + 'tens down' => ['14.99', -1, Decimal::ROUND_HALF_UP, '10'], + 'hundreds negative' => ['-1250', -2, Decimal::ROUND_HALF_UP, '-1300'], + 'hundreds half even' => ['1250', -2, Decimal::ROUND_HALF_EVEN, '1200'], + 'tens below' => ['4', -1, Decimal::ROUND_HALF_UP, '0'], + 'php half even still rounds half up' => ['2.5', 0, PHP_ROUND_HALF_EVEN, '3'], + ]; + } + + /** + * @return void + */ + public function testRoundNegativeScaleDirected(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('15'))->round(-1, Decimal::ROUND_FLOOR); + } + + /** + * @param string $value + * @param int $scale + * @param int $mode + * @param string $expected + * + * @return void + */ + #[DataProvider('withScaleProvider')] + public function testWithScale(string $value, int $scale, int $mode, string $expected): void + { + $result = (new Decimal($value))->withScale($scale, $mode); + + $this->assertSame($expected, (string)$result); + $this->assertSame($scale, $result->scale()); + } + + /** + * @return array + */ + public static function withScaleProvider(): array + { + return [ + 'pad' => ['1.5', 3, Decimal::ROUND_HALF_UP, '1.500'], + 'pad integer' => ['7', 2, Decimal::ROUND_HALF_UP, '7.00'], + 'same' => ['1.50', 2, Decimal::ROUND_HALF_UP, '1.50'], + 'round' => ['1.255', 2, Decimal::ROUND_HALF_UP, '1.26'], + 'to integer' => ['1.5', 0, Decimal::ROUND_HALF_UP, '2'], + 'floor at scale' => ['1.25', 1, Decimal::ROUND_FLOOR, '1.2'], + 'floor negative at scale' => ['-1.21', 1, Decimal::ROUND_FLOOR, '-1.3'], + 'ceil at scale' => ['1.21', 1, Decimal::ROUND_CEIL, '1.3'], + 'ceil negative at scale' => ['-1.29', 1, Decimal::ROUND_CEIL, '-1.2'], + 'ceil small negative' => ['-0.001', 2, Decimal::ROUND_CEIL, '0.00'], + 'half even' => ['0.125', 2, Decimal::ROUND_HALF_EVEN, '0.12'], + ]; + } + + /** + * @return void + */ + public function testWithScaleDeclaredScale(): void + { + $result = Decimal::create('0.1', 4)->withScale(4); + + $this->assertSame('0.1000', (string)$result); + } + + /** + * @return void + */ + public function testWithScaleNegative(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('1.5'))->withScale(-1); + } + + /** + * @return void + */ + public function testWithScaleInvalidMode(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('1.5'))->withScale(2, PHP_ROUND_HALF_EVEN); + } + + /** + * @param string $value + * @param int $places + * @param string $expected + * + * @return void + */ + #[DataProvider('shiftProvider')] + public function testShift(string $value, int $places, string $expected): void + { + $this->assertSame($expected, (string)(new Decimal($value))->shift($places)); + } + + /** + * @return array + */ + public static function shiftProvider(): array + { + return [ + 'none' => ['12.34', 0, '12.34'], + 'right' => ['12.34', 1, '123.4'], + 'right all' => ['12.34', 2, '1234'], + 'right beyond' => ['12.34', 4, '123400'], + 'left' => ['12.34', -1, '1.234'], + 'left all' => ['12.34', -2, '0.1234'], + 'left beyond' => ['12.34', -4, '0.001234'], + 'negative' => ['-0.05', 2, '-5'], + 'zero' => ['0', 3, '0'], + 'zero left' => ['0', -2, '0.00'], + 'integer left keeps zeros' => ['100', -2, '1.00'], + ]; + } + + /** + * @return void + */ + public function testExponentLimit(): void + { + $this->assertSame(Decimal::MAX_EXPONENT + 1, strlen((string)new Decimal('1e' . Decimal::MAX_EXPONENT))); + $this->assertSame(Decimal::MAX_EXPONENT, (new Decimal('1e-' . Decimal::MAX_EXPONENT))->scale()); + } + + /** + * @param string $value + * + * @return void + */ + #[DataProvider('exponentLimitProvider')] + public function testExponentLimitExceeded(string $value): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Exponent must be between'); + + new Decimal($value); + } + + /** + * @return array + */ + public static function exponentLimitProvider(): array + { + return [ + 'positive' => ['1e' . (Decimal::MAX_EXPONENT + 1)], + 'negative' => ['1e-' . (Decimal::MAX_EXPONENT + 1)], + 'huge' => ['1e1000000000'], + 'beyond int' => ['1e99999999999999999999'], + ]; + } + + /** + * @return void + */ + public function testTryCreate(): void + { + $this->assertSame('1.50', (string)Decimal::tryCreate('1.50')); + $this->assertSame('1.5', (string)Decimal::tryCreate('1.56', 1)); + $this->assertNull(Decimal::tryCreate(null)); + $this->assertNull(Decimal::tryCreate('')); + $this->assertNull(Decimal::tryCreate('abc')); + $this->assertNull(Decimal::tryCreate('1,5')); + $this->assertNull(Decimal::tryCreate(NAN)); + } + + /** + * @return void + */ + public function testSum(): void + { + $this->assertSame('6.75', (string)Decimal::sum(['1.5', 2, Decimal::create('3.25')])); + $this->assertSame('0', (string)Decimal::sum([])); + $this->assertSame('0.3', (string)Decimal::sum(new ArrayIterator(['0.1', '0.2']))); + } + + /** + * @return void + */ + public function testAverage(): void + { + $this->assertSame('2.00', (string)Decimal::average(['1', '2', '3'], 2)); + $this->assertSame('0.333', (string)Decimal::average(['0', '0', '1'], 3)); + } + + /** + * @return void + */ + public function testAverageEmpty(): void + { + $this->expectException(InvalidArgumentException::class); + + Decimal::average([], 2); + } + + /** + * @return void + */ + public function testMinMax(): void + { + $values = ['2.5', '-1', '2.50', 7, '-1.0']; + + $this->assertSame('-1', (string)Decimal::min($values)); + $this->assertSame('7', (string)Decimal::max($values)); + $this->assertSame('2.5', (string)Decimal::max(['2.5', '2.50'])); + } + + /** + * @return void + */ + public function testMinEmpty(): void + { + $this->expectException(InvalidArgumentException::class); + + Decimal::min([]); + } + + /** + * @return void + */ + public function testMaxEmpty(): void + { + $this->expectException(InvalidArgumentException::class); + + Decimal::max([]); + } + + /** + * @param string $value + * @param bool $even + * @param bool $odd + * + * @return void + */ + #[DataProvider('parityProvider')] + public function testParity(string $value, bool $even, bool $odd): void + { + $decimal = new Decimal($value); + + $this->assertSame($even, $decimal->isEven()); + $this->assertSame($odd, $decimal->isOdd()); + } + + /** + * @return array + */ + public static function parityProvider(): array + { + return [ + 'zero' => ['0', true, false], + 'even' => ['12', true, false], + 'odd' => ['-7', false, true], + 'even with zero decimals' => ['4.00', true, false], + 'odd with zero decimals' => ['3.0', false, true], + 'fraction' => ['2.5', false, false], + 'large' => ['123456789012345678901234567891', false, true], + ]; + } + + /** + * @return void + */ + public function testBetween(): void + { + $decimal = new Decimal('5.0'); + + $this->assertTrue($decimal->between(1, 10)); + $this->assertTrue($decimal->between('5', '5')); + $this->assertTrue($decimal->between('5', 10)); + $this->assertFalse($decimal->between('5', 10, false)); + $this->assertFalse($decimal->between(1, '5.00', false)); + $this->assertTrue($decimal->between('4.99', '5.01', false)); + $this->assertFalse($decimal->between(6, 10)); + } + + /** + * @return void + */ + public function testClamp(): void + { + $this->assertSame('5', (string)(new Decimal('5'))->clamp(1, 10)); + $this->assertSame('1', (string)(new Decimal('-3.5'))->clamp(1, 10)); + $this->assertSame('10.00', (string)(new Decimal('12'))->clamp('1', '10.00')); + } + + /** + * @return void + */ + public function testClampInvalidRange(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('5'))->clamp(10, 1); + } + + /** + * @param string $value + * @param array $ratios + * @param array $expected + * + * @return void + */ + #[DataProvider('allocateProvider')] + public function testAllocate(string $value, array $ratios, array $expected): void + { + $decimal = new Decimal($value); + $shares = $decimal->allocate($ratios); + + $this->assertSame($expected, array_map('strval', $shares)); + $this->assertSame((string)$decimal, (string)Decimal::sum($shares)->withScale($decimal->scale())); + } + + /** + * @return array + */ + public static function allocateProvider(): array + { + return [ + 'even' => ['100.00', [1, 1], ['50.00', '50.00']], + 'thirds' => ['100.00', [1, 1, 1], ['33.34', '33.33', '33.33']], + 'weighted' => ['0.05', [70, 30], ['0.04', '0.01']], + 'decimal ratios' => ['10.00', ['0.7', '0.3'], ['7.00', '3.00']], + 'integer' => ['10', [1, 1, 1], ['4', '3', '3']], + 'negative' => ['-100.00', [1, 1, 1], ['-33.34', '-33.33', '-33.33']], + 'zero ratio gets nothing' => ['0.01', [0, 1, 1], ['0.00', '0.01', '0.00']], + 'keys' => ['1.00', ['a' => 1, 'b' => 3], ['a' => '0.25', 'b' => '0.75']], + 'zero' => ['0.00', [1, 2], ['0.00', '0.00']], + ]; + } + + /** + * @param array $ratios + * + * @return void + */ + #[DataProvider('allocateInvalidProvider')] + public function testAllocateInvalid(array $ratios): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('1.00'))->allocate($ratios); + } + + /** + * @return array + */ + public static function allocateInvalidProvider(): array + { + return [ + 'empty' => [[]], + 'all zero' => [[0, 0]], + 'negative' => [[1, -1]], + 'not numeric' => [['a']], + ]; + } + + /** + * @return void + */ + public function testSplit(): void + { + $shares = (new Decimal('10.00'))->split(3); + + $this->assertSame(['3.34', '3.33', '3.33'], array_map('strval', $shares)); + $this->assertSame(['5'], array_map('strval', (new Decimal('5'))->split(1))); + } + + /** + * @return void + */ + public function testSplitInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('10.00'))->split(0); + } + + /** + * A global bcmath scale set by the application must not leak into results. + * + * @return void + */ + public function testGlobalBcScale(): void + { + $previous = bcscale(2); + try { + $this->assertSame('2', (string)(new Decimal('1.5'))->round()); + $this->assertSame('1.3', (string)(new Decimal('1.25'))->round(1)); + $this->assertSame('2', (string)(new Decimal('1.5'))->withScale(0)); + $this->assertSame('20', (string)(new Decimal('15'))->round(-1)); + $this->assertSame('2', (string)(new Decimal('1.5'))->ceil()); + $this->assertSame(['0.50', '0.50'], array_map('strval', (new Decimal('1.00'))->split(2))); + $this->assertSame(['3.34', '3.33', '3.33'], array_map('strval', (new Decimal('10.00'))->split(3))); + $this->assertSame('3', (string)Decimal::sum([1, 2])); + $this->assertSame('1000', (string)new Decimal('1e3')); + $this->assertSame('1.e3', (new Decimal('1000'))->toScientific()); + $this->assertSame(PHP_INT_MAX, (new Decimal((string)PHP_INT_MAX))->toInt()); + $this->assertTrue((new Decimal('4'))->isEven()); + } finally { + bcscale($previous); + } + } }