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/.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/ci.yml b/.github/workflows/ci.yml index 1a8a4e9..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.2', '8.3'] + php-version: ['8.2', '8.3', '8.4', '8.5'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -26,35 +26,35 @@ 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 + 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@v1 + 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@v3 + - 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/.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 afd26aa..d0b1ee3 100644 --- a/README.md +++ b/README.md @@ -3,52 +3,73 @@ [![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) Decimal value object for PHP. +## Documentation + +**** + ## Background -When working with 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 string 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 https://php-decimal.io 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). +```php +use PhpCollective\DecimalObject\Decimal; + +$total = Decimal::create('0.1')->add('0.2'); -Note: If you are looking for pre 8.1 versions, check out 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 - -- `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 + +Pull requests are welcome: + +- Target `master` for bug fixes and backwards compatible improvements. +- Target `next` (the next major version) for anything that breaks backwards compatibility. -### Implementations -The following libraries are using the `Decimal` value object: +## History -- [dereuromark/cakephp-decimal](https://github.com/dereuromark/cakephp-decimal) as decimal type replacement for CakePHP ORM. +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/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 d0f9ef8..c37719e 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,16 @@ { "name": "php-collective/decimal-object", "description": "PHP decimal handling as value object", - "type": "library", "license": "MIT", + "type": "library", + "keywords": [ + "decimal", + "value-object", + "bcmath", + "precision", + "arbitrary-precision", + "math" + ], "authors": [ { "name": "The PHP Collective", @@ -13,16 +21,22 @@ "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": { - "phpunit/phpunit": "^10.3.0", - "php-collective/code-sniffer": "^0.2.1", - "phpstan/phpstan": "^1.0.0" + "php-collective/code-sniffer": "^0.6.8", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^11.5.0 || ^12.0.0 || ^13.0.0" }, + "minimum-stability": "stable", + "prefer-stable": true, "autoload": { "psr-4": { "PhpCollective\\DecimalObject\\": "src/" @@ -33,25 +47,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" } } 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 a74f6ab..25b6e28 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,87 +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 (careful) - -```php -use PhpCollective\DecimalObject\Decimal; - -$value = '1.234'; -$decimalObject = Decimal::create($value); - -echo 'Value: ' . $decimalObject; -``` - -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 term 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' +```sh +cd docs +npm install +npm run docs:dev ``` - -Note that due to immutability `$decimalOne` is not modified here. The re-assignment is necessary for the operation to persist. - - -## 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..f515f0c --- /dev/null +++ b/docs/guide/arithmetic.md @@ -0,0 +1,131 @@ +# 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'); // 0.1 +``` + +The sign follows the left operand, as with PHP's `%`. The result has the larger scale of both operands. A zero operand throws a `DivisionByZeroError`. + +## 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 +``` + +## 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 +$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..ad2748d --- /dev/null +++ b/docs/guide/comparison.md @@ -0,0 +1,77 @@ +# 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 +``` + +## 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()`. +::: + +## 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) +``` + +`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/conversion.md b/docs/guide/conversion.md new file mode 100644 index 0000000..3845158 --- /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, so the scale survives a round trip through `Decimal::create()`. Call `trim()` first for the shortest form. + +## 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..c4f6e25 --- /dev/null +++ b/docs/guide/creating.md @@ -0,0 +1,105 @@ +# 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. +::: + +::: 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: + +```php +echo Decimal::create('0.129', 2); // 0.12 +``` + +Digits beyond the scale are cut off, not rounded. A scale of `0` cuts off all decimals. 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 +``` + +### 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..b11567c --- /dev/null +++ b/docs/guide/rounding.md @@ -0,0 +1,104 @@ +# 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 + +Pass a mode as second argument. Every mode rounds 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` | +| `Decimal::ROUND_CEIL` | towards positive infinity | `3` | `4` | `-2` | `3` | +| `Decimal::ROUND_FLOOR` | towards negative infinity | `2` | `3` | `-3` | `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 +echo Decimal::create('12.375')->round(1, Decimal::ROUND_FLOOR); // 12.3 +echo Decimal::create('-12.375')->round(1, Decimal::ROUND_FLOOR); // -12.4 +``` + +`ROUND_HALF_EVEN` is the banker's rounding used in accounting to avoid a drift over many values. + +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. An unknown mode throws an `InvalidArgumentException`. + +> [!TIP] Changed in 2.0 +> `ROUND_FLOOR` and `ROUND_CEIL` round at the scale, and unknown modes throw. See [Upgrading](../reference/upgrading#from-1-x-to-2-0). + +## withScale() + +Returns the value with exactly the given number of decimal places. Missing places are filled with zeros, surplus places are rounded: + +```php +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 +``` + +It accepts the same modes as `round()` and returns the same result. The name states the intent when the point is a fixed scale and not the rounding, and a negative scale is rejected. + +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() + +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. + +## 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 +``` + +`withScale()` 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 new file mode 100644 index 0000000..0768366 --- /dev/null +++ b/docs/guide/scale.md @@ -0,0 +1,87 @@ +# 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()`, `mod()` | 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()` | 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 +``` + +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 + +`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..7ed8b1f --- /dev/null +++ b/docs/reference/api.md @@ -0,0 +1,125 @@ +# 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. | +| `Decimal::tryCreate($value, ?int $scale = null): ?static` | Returns `null` for `null` and for input that is not numeric. | + +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. | larger of both | +| `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 to `$scale` decimal places in the given mode. 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. | +| `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` | +| `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). + +## 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` | | +| `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). + +## 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 | +| `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` | +| `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` | +| Exponent beyond `Decimal::MAX_EXPONENT` | `InvalidArgumentException` | +| Negative scale in the constructor, `withScale()` or `truncate()` | `InvalidArgumentException` | +| Unknown mode in `round()` or `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` | +| `sqrt()` of a negative value, `pow()` with a fractional exponent, negative scale in an arithmetic method | `ValueError` from bcmath | 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..c0fbb15 --- /dev/null +++ b/docs/reference/upgrading.md @@ -0,0 +1,70 @@ +# Upgrading + +## From 1.x to 2.0 + +2.0 applies the corrections that change results or output for ordinary input. Review each row against your code. + +| Call | 1.x | 2.0 | +| --- | --- | --- | +| `new Decimal('1.5', 0)` | `1.5` | `1`, throws in strict mode | +| `round(0, PHP_ROUND_HALF_EVEN)` | rounds half up | `InvalidArgumentException` | +| `Decimal::create('1.25')->round(1, Decimal::ROUND_FLOOR)` | `1` | `1.2` | +| `Decimal::create('10')->mod('0.3')` | `0` | `0.1` | +| `Decimal::create('10')->mod('3.00')` | `1` | `1.00` | +| `new Decimal('1.0e-5')` | scale 5 | scale 6 | +| `new Decimal('1e3', 2)` | `1000` | `1000.00` | +| `Decimal::create('5')->toScientific()` | `5.e0` | `5e0` | + +What to do: + +- **Scale of 0**: a scale of `0` now cuts off decimals like any other scale. Pass `null` when you mean "no scale". +- **Rounding modes**: replace `PHP_ROUND_*` constants with the `Decimal::ROUND_*` ones. `round()` with `ROUND_FLOOR` or `ROUND_CEIL` and a scale above zero used to return a whole number; call `floor()` or `ceil()` if that is what you want. +- **Modulo**: the result takes the larger scale of both operands. Pass the scale as second argument to keep the old one. +- **Scientific notation**: a zero written in a string mantissa counts towards the scale, and `toScientific()` no longer prints a decimal point without decimals. +- **Removed**: the misspelled `greatherThanOrEquals()`. Use `greaterThanOrEquals()`. + +## 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 + +- 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`. + + +#### 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. diff --git a/phpstan.neon b/phpstan.neon index cdcfb1f..aa161e6 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/phpunit.xml.dist b/phpunit.xml.dist index 3542715..eca8d3f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,11 +1,12 @@ - diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..9a9e6f3 --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "github>php-collective/.github" + ] +} diff --git a/src/Decimal.php b/src/Decimal.php index d58ce43..899724a 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. * @@ -56,21 +94,25 @@ 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 + * @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); + $this->normalizeZero(); } /** @@ -94,15 +136,24 @@ 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'); } - 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; } /** @@ -113,22 +164,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; } /** @@ -139,17 +181,121 @@ protected function normalizeValue(string $value): string * it. * * @param object|string|float|int $value - * @param int|null $scale + * @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); + } + + /** + * 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; } /** @@ -157,7 +303,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. @@ -168,7 +314,7 @@ public function equals(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -178,7 +324,7 @@ public function greaterThan(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -188,23 +334,23 @@ public function lessThan(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ public function greaterThanOrEquals(self|string|float|int $value): bool { - return ($this->compareTo($value) >= 0); + return $this->compareTo($value) >= 0; } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ public function lessThanOrEquals(self|string|float|int $value): bool { - return ($this->compareTo($value) <= 0); + return $this->compareTo($value) <= 0; } /** @@ -215,7 +361,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 */ @@ -230,8 +376,8 @@ 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 self|string|float|int $value + * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static */ @@ -264,8 +410,8 @@ 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 int|null $scale + * @param self|string|float|int $value + * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static */ @@ -369,15 +515,78 @@ 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. * - * @param static|string|float|int $value - * @param int|null $scale + * @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) { @@ -390,14 +599,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 int $scale + * @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()) { @@ -410,8 +619,8 @@ 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 self|string|int $exponent + * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static */ @@ -421,13 +630,13 @@ 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)); } /** * Returns the square root of this decimal, with the same scale as this decimal. * - * @param int|null $scale + * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static */ @@ -443,46 +652,214 @@ public function sqrt(?int $scale = null): static /** * This method is equivalent to the % operator. * - * @param static|string|int $value - * @param int|null $scale + * @param self|string|int $value + * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return 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)$decimal, $scale)); + } + + /** + * @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::roundModes(), true)) { + throw new InvalidArgumentException('Unsupported round mode `' . $roundMode . '`.'); } - return new static(bcmod((string)$this, (string)$value, $scale)); + 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. + * + * @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')); + } + + return new static($this->roundToScale($scale, $roundMode), $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'); + + $value = substr($digits, 0, $point); + if (strlen($digits) > $point) { + $value .= static::RADIX_MARK . substr($digits, $point); + } + + 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 static + * @return string */ - public function round(int $scale = 0, int $roundMode = self::ROUND_HALF_UP): static + protected function roundToScale(int $scale, int $roundMode): string { - $exponent = $scale + 1; + $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); + } - $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); + return (string)(new static(($this->negative ? '-' : '') . $integralPart))->shift(-$scale); + } - break; - case static::ROUND_CEIL: - $v = bcdiv(bcadd(bcmul((string)$this, $e, 0), $this->isNegative() ? '0' : '9'), $e, 0); + /** + * 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; - case static::ROUND_HALF_UP: - default: - $v = bcdiv(bcadd(bcmul((string)$this, $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 $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 new static($v); + return $this->allocate(array_fill(0, $parts, 1)); } /** @@ -508,7 +885,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 * @@ -568,7 +945,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; } /** @@ -576,8 +955,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; } /** @@ -593,38 +971,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; } /** @@ -697,11 +1068,12 @@ 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; } + $clone->normalizeZero(); return $clone; } @@ -782,56 +1154,71 @@ 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 (abs($exp) > static::MAX_EXPONENT) { + throw new InvalidArgumentException('Exponent must be between -' . static::MAX_EXPONENT . ' and ' . static::MAX_EXPONENT . '.'); + } 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'); } } /** * @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 { + if ($scale !== null && $scale < 0) { + throw new InvalidArgumentException('Scale must be >= 0.'); + } + $calculatedScale = strlen($this->fractionalPart); - if ($scale && $calculatedScale > $scale) { - throw new InvalidArgumentException('Loss of precision detected. Detected scale `' . $calculatedScale . '` > `' . $scale . '` as defined.'); + if ($scale !== null && $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; } + + /** + * 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 e8fd9ac..4bd5e98 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -9,22 +9,24 @@ namespace PhpCollective\DecimalObject\Test; +use ArrayIterator; use DivisionByZeroError; use InvalidArgumentException; use PhpCollective\DecimalObject\Decimal; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Stringable; use TypeError; class DecimalTest extends TestCase { /** - * @dataProvider baseProvider - * * @param mixed $value * @param string $expected * * @return void */ + #[DataProvider('baseProvider')] public function testNewObject(mixed $value, string $expected): void { $decimal = new Decimal($value); @@ -45,19 +47,39 @@ public function testNewObjectScientific(): void } /** - * @dataProvider baseProvider - * * @param mixed $value * @param string $expected * * @return void */ + #[DataProvider('baseProvider')] public function testCreate(mixed $value, string $expected): void { $decimal = Decimal::create($value); $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,16 +135,16 @@ public function __toString(): string ['622000000000000000000000', '622000000000000000000000'], ['3.11e2', '311'], [$objectWithToStringMethod, '12.12'], + [$objectStringable, '12.12'], ]; } /** - * @dataProvider invalidValuesProvider - * * @param mixed $value * * @return void */ + #[DataProvider('invalidValuesProvider')] public function testNewObjectWithInvalidValueThrowsException(mixed $value): void { $this->expectException(InvalidArgumentException::class); @@ -132,14 +165,13 @@ public static function invalidValuesProvider(): array } /** - * @dataProvider truncateProvider - * * @param mixed $input * @param int $scale * @param string $expected * * @return void */ + #[DataProvider('truncateProvider')] public function testTruncate(mixed $input, int $scale, string $expected): void { $decimal = Decimal::create($input); @@ -162,13 +194,12 @@ public static function truncateProvider(): array } /** - * @dataProvider integerProvider - * * @param mixed $value * @param bool $expected * * @return void */ + #[DataProvider('integerProvider')] public function testIsInteger(mixed $value, bool $expected): void { $decimal = Decimal::create($value); @@ -195,13 +226,12 @@ public static function integerProvider(): array } /** - * @dataProvider zeroProvider - * * @param mixed $value * @param bool $expected * * @return void */ + #[DataProvider('zeroProvider')] public function testIsZero(mixed $value, bool $expected): void { $decimal = Decimal::create($value); @@ -227,13 +257,12 @@ public static function zeroProvider(): array } /** - * @dataProvider compareZeroProvider - * * @param mixed $input * @param int $expected * * @return void */ + #[DataProvider('compareZeroProvider')] public function testIsPositive(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -241,13 +270,12 @@ public function testIsPositive(mixed $input, int $expected): void } /** - * @dataProvider compareZeroProvider - * * @param mixed $input * @param int $expected * * @return void */ + #[DataProvider('compareZeroProvider')] public function testIsNegative(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -276,13 +304,12 @@ public static function compareZeroProvider(): array } /** - * @dataProvider scaleProvider - * * @param mixed $input * @param int $expected * * @return void */ + #[DataProvider('scaleProvider')] public function testScale(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -308,13 +335,12 @@ public static function scaleProvider(): array } /** - * @dataProvider scientificProvider - * * @param mixed $value * @param string $expected * * @return void */ + #[DataProvider('scientificProvider')] public function testToScientific(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -383,12 +409,11 @@ public function testToFloat(): void } /** - * @dataProvider bigFloatDataProvider - * * @param string $value * * @return void */ + #[DataProvider('bigFloatDataProvider')] public function testToFloatForBigDecimalThrowsAnException(string $value): void { $decimal = Decimal::create($value); @@ -423,12 +448,11 @@ public function testToInt(): void } /** - * @dataProvider bigIntDataProvider - * * @param string $value * * @return void */ + #[DataProvider('bigIntDataProvider')] public function testToIntForBigIntThrowsAnException(string $value): void { $decimal = Decimal::create($value); @@ -577,14 +601,13 @@ public function testEquals(): void } /** - * @dataProvider roundProvider - * * @param mixed $value * @param int $scale * @param string $expected * * @return void */ + #[DataProvider('roundProvider')] public function testRound(mixed $value, int $scale, string $expected): void { $decimal = Decimal::create($value); @@ -602,7 +625,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); } /** @@ -625,17 +653,20 @@ 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'], ]; } /** - * @dataProvider floorProvider - * * @param mixed $value * @param string $expected * * @return void */ + #[DataProvider('floorProvider')] public function testFloor(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -651,7 +682,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); } /** @@ -672,17 +708,18 @@ public static function floorProvider(): array ['13.6999', '13'], ['13.1', '13'], ['13.9', '13'], + [0.0001, '0'], + [-0.0001, '-1'], ]; } /** - * @dataProvider ceilProvider - * * @param mixed $value * @param string $expected * * @return void */ + #[DataProvider('ceilProvider')] public function testCeil(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -698,7 +735,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); } /** @@ -719,18 +760,19 @@ public static function ceilProvider(): array ['13.6999', '14'], ['13.1', '14'], ['13.9', '14'], + [0.0001, '1'], + [-0.0001, '0'], ]; } /** - * @dataProvider compareProvider - * * @param mixed $a * @param mixed $b * @param int $expected * * @return void */ + #[DataProvider('compareProvider')] public function testGreaterThan(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -738,14 +780,13 @@ public function testGreaterThan(mixed $a, mixed $b, int $expected): void } /** - * @dataProvider compareProvider - * * @param mixed $a * @param mixed $b * @param int $expected * * @return void */ + #[DataProvider('compareProvider')] public function testLessThan(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -753,14 +794,13 @@ public function testLessThan(mixed $a, mixed $b, int $expected): void } /** - * @dataProvider compareProvider - * * @param mixed $a * @param mixed $b * @param int $expected * * @return void */ + #[DataProvider('compareProvider')] public function testGreaterEquals(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -768,14 +808,13 @@ public function testGreaterEquals(mixed $a, mixed $b, int $expected): void } /** - * @dataProvider compareProvider - * * @param mixed $a * @param mixed $b * @param int $expected * * @return void */ + #[DataProvider('compareProvider')] public function testLessEquals(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -832,8 +871,6 @@ public function testSubtract(): void } /** - * @dataProvider multiplicationProvider - * * @param mixed $a * @param mixed $b * @param int|null $scale @@ -841,6 +878,7 @@ public function testSubtract(): void * * @return void */ + #[DataProvider('multiplicationProvider')] public function testMultiply(mixed $a, mixed $b, ?int $scale, string $expected): void { $decimal = Decimal::create($a); @@ -872,8 +910,6 @@ public static function multiplicationProvider(): array } /** - * @dataProvider multiplicationLegacyProvider - * * @param mixed $a * @param mixed $b * @param int|null $scale @@ -881,6 +917,7 @@ public static function multiplicationProvider(): array * * @return void */ + #[DataProvider('multiplicationLegacyProvider')] public function testMultiplyLegacy(mixed $a, mixed $b, ?int $scale, string $expected): void { $decimal = Decimal::create($a); @@ -898,8 +935,6 @@ public static function multiplicationLegacyProvider(): array } /** - * @dataProvider divisionProvider - * * @param mixed $a * @param mixed $b * @param int $scale @@ -907,6 +942,7 @@ public static function multiplicationLegacyProvider(): array * * @return void */ + #[DataProvider('divisionProvider')] public function testDivide(mixed $a, mixed $b, int $scale, string $expected): void { $decimal = Decimal::create($a); @@ -975,21 +1011,33 @@ 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 */ 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()); } @@ -1001,6 +1049,707 @@ 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); + } + + /** + * @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'], + '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 + */ + #[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 + */ + #[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'], + ]; + } + + /** + * @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 + * + * @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', '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)); + } + + /** + * @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'], + 'floor at scale' => ['1.25', 1, Decimal::ROUND_FLOOR, '1.2'], + 'floor tens' => ['15', -1, Decimal::ROUND_FLOOR, '10'], + 'ceil tens negative' => ['-15', -1, Decimal::ROUND_CEIL, '-10'], + ]; + } + + /** + * @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('1e3', (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); + } } }