diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3954972..39cca97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,8 @@ jobs: # scale_ini test skipped: bcmath.scale INI setting ignored without native extension # gh20006 test skipped: requires BcMath\Number OOP API (not supported by polyfill) # bcround_precision_bounds test skipped: requires BcMath\Number OOP API (not supported by polyfill) - docker run --rm -v $PWD:/app bcmath-phpt-test:${{ matrix.php-version }} --skip bcdivmod,bug54598,bug72093,bug75178,gh15968,gh16262,scale_ini,bcmul_check_overflow,bug78878,bcpow_error1,bcpow_error2,bcpow_error3,bcpowmod_error,bcpowmod_with_mod_1,bcpowmod_zero_modulus,bcround_all,bcround_away_from_zero,bcround_ceiling,bcround_early_return,bcround_floor,bcround_toward_zero,bcscale_variation003,bcdivmod_by_zero,gh20006,bcround_precision_bounds + # bcround directional modes + bcdivmod are now implemented, so those phpt tests no longer need skipping. + docker run --rm -v $PWD:/app bcmath-phpt-test:${{ matrix.php-version }} --skip bug54598,bug72093,bug75178,gh15968,gh16262,scale_ini,bcmul_check_overflow,bug78878,bcpow_error1,bcpow_error2,bcpow_error3,bcpowmod_error,bcpowmod_with_mod_1,bcpowmod_zero_modulus,bcscale_variation003,gh20006,bcround_precision_bounds strategy: fail-fast: false matrix: diff --git a/README.md b/README.md index 074e19f..d3a9789 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ ## 🚀 Features - ✅ Complete bcmath extension polyfill for PHP 8.1+ -- ✅ Supports all PHP 8.4+ bcmath functions including `bcfloor()`, `bcceil()`, and `bcround()` +- ✅ Supports all PHP 8.4+ bcmath functions including `bcfloor()`, `bcceil()`, `bcround()`, and `bcdivmod()` - ✅ PHP 8.5 compatibility tested and verified - ✅ Zero dependencies in production (uses phpseclib for arbitrary precision math) - ✅ Seamless fallback when bcmath extension is not available @@ -54,6 +54,13 @@ echo bcround('2.5', 0, \RoundingMode::HalfAwayFromZero); // 3 echo bcround('2.5', 0, \RoundingMode::HalfTowardsZero); // 2 echo bcround('2.5', 0, \RoundingMode::HalfEven); // 2 echo bcround('2.5', 0, \RoundingMode::HalfOdd); // 3 +echo bcround('2.5', 0, \RoundingMode::TowardsZero); // 2 +echo bcround('2.5', 0, \RoundingMode::AwayFromZero); // 3 +echo bcround('2.5', 0, \RoundingMode::PositiveInfinity); // 3 +echo bcround('2.5', 0, \RoundingMode::NegativeInfinity); // 2 + +// bcdivmod() returns [quotient, remainder] +[$quot, $rem] = bcdivmod('17', '5'); // ['3', '2'] ``` ## 📋 Supported Functions @@ -74,20 +81,39 @@ echo bcround('2.5', 0, \RoundingMode::HalfOdd); // 3 - `bcfloor()` - Round down to the nearest integer - `bcceil()` - Round up to the nearest integer - `bcround()` - Round to a specified precision with configurable rounding modes +- `bcdivmod()` - Get the quotient and remainder of a division in a single call #### RoundingMode Enum Support -The `bcround()` function supports PHP 8.4's `RoundingMode` enum through a polyfill for PHP 8.1-8.3: +The `bcround()` function supports PHP 8.4's `RoundingMode` enum through a polyfill for PHP 8.1-8.3. +All eight native modes are implemented with exact, arbitrary-precision string arithmetic (no float +fallback), so results match the native `bcround()` even for very large or high-precision numbers: -**Supported Modes:** - `RoundingMode::HalfAwayFromZero` (equivalent to `PHP_ROUND_HALF_UP`) - `RoundingMode::HalfTowardsZero` (equivalent to `PHP_ROUND_HALF_DOWN`) - `RoundingMode::HalfEven` (equivalent to `PHP_ROUND_HALF_EVEN`) - `RoundingMode::HalfOdd` (equivalent to `PHP_ROUND_HALF_ODD`) +- `RoundingMode::TowardsZero` +- `RoundingMode::AwayFromZero` +- `RoundingMode::NegativeInfinity` +- `RoundingMode::PositiveInfinity` + +The legacy integer `PHP_ROUND_*` constants are also accepted for the four half-modes. + +> **Note:** The polyfilled `RoundingMode` is a *pure* enum, matching native PHP 8.4 (it has no +> backing value; `->value` / `from()` / `tryFrom()` are intentionally unavailable). + +#### Interoperability with `symfony/polyfill-php84` + +`symfony/polyfill-php84` also declares `bcceil()`, `bcfloor()`, `bcround()` and `bcdivmod()`, but only +when the native bcmath extension is **already loaded** (its purpose is to backport the new PHP 8.4 +functions to older PHP that already has bcmath). This package instead provides the full bcmath API for +environments **without** the extension. -**Not Yet Supported:** -- `RoundingMode::TowardsZero` - Throws `ValueError` (planned for future release) -- `RoundingMode::AwayFromZero` - Throws `ValueError` (planned for future release) -- `RoundingMode::NegativeInfinity` - Throws `ValueError` (planned for future release) +When both packages are installed on an environment that *does* have the bcmath extension and runs +**PHP 8.2/8.3**, both declare `bcceil()`/`bcfloor()`/`bcround()`; whichever autoloads first wins (the +`function_exists()` guards prevent a fatal redeclaration). This package's rounding is implemented with a +native-compatible string algorithm, so the numeric results are identical regardless of which +implementation is used. On environments without the extension only this package is active. ## ⚡ Performance @@ -193,34 +219,33 @@ While the polyfill is significantly slower than native bcmath: - PHP >= 7.3.0: Use `bcscale()` without arguments - PHP < 7.3.0: Use `max(0, strlen(bcadd('0', '0')) - 2)` -### RoundingMode Enum Limitations -- Three `RoundingMode` enum values are not yet implemented: - - `RoundingMode::TowardsZero` - Will throw `ValueError` - - `RoundingMode::AwayFromZero` - Will throw `ValueError` - - `RoundingMode::NegativeInfinity` - Will throw `ValueError` -- These modes are planned for implementation in a future release -- Use traditional `PHP_ROUND_*` constants as alternatives when needed - -## 🔄 Key Differences from phpseclib/bcmath_compat - -| Feature | phpseclib/bcmath_compat | bcmath-polyfill | -|---------------------------|--------------------------------|-------------------------------------| -| **PHP 8.4 functions** | ❌ Not supported | ✅ Full support | -| `bcfloor()` | ❌ | ✅ | -| `bcceil()` | ❌ | ✅ | -| `bcround()` | ❌ | ✅ | -| **RoundingMode enum** | ❌ Not supported | ✅ Partial (4/7 modes) | -| `HalfAwayFromZero` | ❌ | ✅ | -| `HalfTowardsZero` | ❌ | ✅ | -| `HalfEven` | ❌ | ✅ | -| `HalfOdd` | ❌ | ✅ | -| `TowardsZero` | ❌ | ⏳ Planned | -| `AwayFromZero` | ❌ | ⏳ Planned | -| `NegativeInfinity` | ❌ | ⏳ Planned | -| **PHP 8.2+ deprecations** | ⚠️ Warnings | ✅ Fixed | -| **Test suite pollution** | ⚠️ Issues | ✅ Fixed | -| **Active maintenance** | ❌ Limited | ✅ Active | -| **CI/CD (PHP versions)** | GitHub Actions (8.1, 8.2, 8.3) | GitHub Actions (8.1, 8.2, 8.3, 8.4, 8.5) | +## 🔄 Key Differences from phpseclib/bcmath_compat and symfony/polyfill-php84 + +The three libraries target different goals. `phpseclib/bcmath_compat` is the classic pre-8.4 polyfill. +`symfony/polyfill-php84` backports **only** the new PHP 8.4 functions and activates them **only when the +native bcmath extension is already loaded**. `bcmath-polyfill` provides the complete bcmath API — +including the PHP 8.4 additions — for environments **with or without** the extension. + +| Feature | phpseclib/bcmath_compat | symfony/polyfill-php84 | bcmath-polyfill | +|------------------------------------------------|--------------------------------|---------------------------------|------------------------------------------| +| Classic bc functions (`bcadd`, `bcmul`, …) | ✅ | ❌ Not provided | ✅ | +| Works **without** the bcmath extension | ✅ | ❌ Requires the extension | ✅ | +| `bcfloor()` / `bcceil()` / `bcround()` | ❌ | ✅ (extension required) | ✅ | +| `bcdivmod()` | ❌ | ✅ (delegates to native bc) | ✅ (works without the extension) | +| **RoundingMode enum** (all 8 modes) | ❌ | ✅ | ✅ | +| Arbitrary-precision rounding (no float fallback)| ❌ | ✅ (pure string algorithm) | ✅ (pure string algorithm) | +| `RoundingMode` is a *pure* enum (native shape) | — | ✅ | ✅ | +| Implementation of rounding | — | Pure string digits | Pure string digits (phpseclib elsewhere) | +| Extra dependency | phpseclib | none | phpseclib | +| **PHP 8.2+ deprecations** | ⚠️ Warnings | ✅ Fixed | ✅ Fixed | +| **Active maintenance** | ❌ Limited | ✅ Active | ✅ Active | +| **CI/CD (PHP versions)** | GitHub Actions (8.1, 8.2, 8.3) | 7.2+ | GitHub Actions (8.1, 8.2, 8.3, 8.4, 8.5) | + +> When `bcmath-polyfill` and `symfony/polyfill-php84` are installed together on an environment that has +> the bcmath extension and runs PHP 8.2/8.3, both declare `bcceil()`/`bcfloor()`/`bcround()` and whichever +> autoloads first wins (guarded by `function_exists()`, so no fatal redeclaration). Because this package's +> rounding uses a native-compatible string algorithm, the results are identical either way. See +> [Interoperability with `symfony/polyfill-php84`](#interoperability-with-symfonypolyfill-php84). ### Migration from phpseclib/bcmath_compat diff --git a/lib/RoundingMode.php b/lib/RoundingMode.php index 7f09514..e29a9cf 100644 --- a/lib/RoundingMode.php +++ b/lib/RoundingMode.php @@ -3,9 +3,10 @@ /** * RoundingMode enum polyfill for PHP 8.1-8.3. * - * This enum provides the same interface as PHP 8.4's native RoundingMode enum, - * but TowardsZero, AwayFromZero, NegativeInfinity, and PositiveInfinity will throw exceptions - * when used in PHP < 8.4 to maintain compatibility expectations. + * This enum mirrors PHP 8.4's native RoundingMode, which is a *pure* enum + * (no backing type). Keeping it backing-less here ensures the polyfill behaves + * identically to the native enum on 8.4+ (e.g. no `->value`, `from()`/`tryFrom()`), + * avoiding an 8.1-8.3 vs. 8.4+ inconsistency. * * The enum is only defined if: * - PHP version is 8.1 or higher (enum support) @@ -18,15 +19,15 @@ // PHP to >=8.1, so PHPStan always infers this as always-true; silence that. // @phpstan-ignore-next-line booleanAnd.rightAlwaysTrue if (!class_exists('RoundingMode', false) && version_compare(PHP_VERSION, '8.1', '>=')) { - enum RoundingMode: string + enum RoundingMode { - case HalfAwayFromZero = 'half_away_from_zero'; - case HalfTowardsZero = 'half_towards_zero'; - case HalfEven = 'half_even'; - case HalfOdd = 'half_odd'; - case TowardsZero = 'towards_zero'; - case AwayFromZero = 'away_from_zero'; - case NegativeInfinity = 'negative_infinity'; - case PositiveInfinity = 'positive_infinity'; + case HalfAwayFromZero; + case HalfTowardsZero; + case HalfEven; + case HalfOdd; + case TowardsZero; + case AwayFromZero; + case NegativeInfinity; + case PositiveInfinity; } } diff --git a/lib/bcmath.php b/lib/bcmath.php index 48cbee4..66bba30 100644 --- a/lib/bcmath.php +++ b/lib/bcmath.php @@ -136,9 +136,9 @@ function bcsub(string $left_operand, string $right_operand, ?int $scale = null): /** * Round down to the nearest integer (PHP 8.4+). */ - function bcfloor(string $operand): string + function bcfloor(string $num): string { - return BCMath::floor($operand); + return BCMath::floor($num); } } @@ -146,9 +146,9 @@ function bcfloor(string $operand): string /** * Round up to the nearest integer (PHP 8.4+). */ - function bcceil(string $operand): string + function bcceil(string $num): string { - return BCMath::ceil($operand); + return BCMath::ceil($num); } } @@ -157,11 +157,25 @@ function bcceil(string $operand): string * Round to a given decimal place (PHP 8.4+). * * @param int $precision optional - * @param int $mode optional + * @param int|\RoundingMode $mode optional */ - function bcround(string $operand, int $precision = 0, $mode = PHP_ROUND_HALF_UP): string + function bcround(string $num, int $precision = 0, $mode = PHP_ROUND_HALF_UP): string { - return BCMath::round($operand, $precision, $mode); + return BCMath::round($num, $precision, $mode); + } +} + +if (!function_exists('bcdivmod')) { + /** + * Get the quotient and remainder of dividing two arbitrary precision numbers (PHP 8.4+). + * + * @param null|int $scale optional + * + * @return string[] the quotient (index 0) and remainder (index 1) + */ + function bcdivmod(string $num1, string $num2, ?int $scale = null): array + { + return BCMath::divmod($num1, $num2, $scale); } } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index c1912d9..6a3c485 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -29,7 +29,7 @@ parameters: message: "#Match arm comparison between RoundingMode and '(halfawayfromzero|halftowardszero|halfeven|halfodd|towardszero|awayfromzero|negativeinfinity|positiveinfinity)' is always false\\.#" - identifier: argument.type - message: "#Parameter \\#3 \\$mode of (static method bcmath_compat\\\\BCMath::round\\(\\)|function bcround) expects int\\|RoundingMode, (string|int) given\\.#" + message: "#Parameter \\#3 \\$mode of (static method bcmath_compat\\\\BCMath::round\\(\\)|function bcround) expects (int\\|RoundingMode|RoundingMode), (string|int) given\\.#" paths: - src/BCMath.php - tests/BCMathTest.php @@ -61,3 +61,10 @@ parameters: identifier: property.nonObject path: tests/BCMathTest.php message: "#Cannot access property \\$name on string\\.#" + # On PHP 8.1 PHPStan narrows $function to the six non-pow operations and + # reports this in_array() guard as always-true, while PHP 8.5 does not. + # The guard deliberately excludes bcpow() from the second-argument check, + # so it must stay. Pre-existing on main; ignored to keep the 8.1 matrix green. + - + path: tests/BCMathTest.php + message: "#Call to function in_array\\(\\) with arguments .* and true will always evaluate to true\\.#" diff --git a/src/BCMath.php b/src/BCMath.php index 51fb1c6..fa452ed 100644 --- a/src/BCMath.php +++ b/src/BCMath.php @@ -49,6 +49,19 @@ abstract class BCMath private const DIVISION_BY_ZERO_MESSAGE = 'Division by zero'; private const MODULO_BY_ZERO_MESSAGE = 'Modulo by zero'; + /** + * Canonical rounding-mode tokens shared by the string-digit rounding core. + * They mirror the names of PHP 8.4's RoundingMode enum cases. + */ + private const ROUND_HALF_AWAY_FROM_ZERO = 'HalfAwayFromZero'; + private const ROUND_HALF_TOWARDS_ZERO = 'HalfTowardsZero'; + private const ROUND_HALF_EVEN = 'HalfEven'; + private const ROUND_HALF_ODD = 'HalfOdd'; + private const ROUND_TOWARDS_ZERO = 'TowardsZero'; + private const ROUND_AWAY_FROM_ZERO = 'AwayFromZero'; + private const ROUND_NEGATIVE_INFINITY = 'NegativeInfinity'; + private const ROUND_POSITIVE_INFINITY = 'PositiveInfinity'; + /** * Validate and normalize two input numbers. * @@ -353,15 +366,16 @@ private static function prepareForComparison(string $num1, string $num2, int $sc } /** - * Handle rounding operations with negative zero normalization. + * Validate the argument for bcfloor()/bcceil() and round it to an integer + * using the shared string-digit rounding core. * * @param string $num The number to process * @param string $functionName Function name for error messages - * @param callable(string, string, string): ?string $fractionHandler Handler for numbers with non-zero fractional parts + * @param string $mode Canonical rounding mode token (self::ROUND_NEGATIVE_INFINITY or self::ROUND_POSITIVE_INFINITY) * * @return string The processed result */ - private static function normalizeZeroForRounding(string $num, string $functionName, callable $fractionHandler): string + private static function roundToInteger(string $num, string $functionName, string $mode): string { self::validateNumberString($num, $functionName, 1, 'num'); @@ -374,36 +388,7 @@ private static function normalizeZeroForRounding(string $num, string $functionNa return '0'; } - // Handle the case where input is exactly '-0' (no decimal point) - if ($num === '-0') { - return '0'; - } - - // Remove any fractional part - if (str_contains($num, '.')) { - $dotPos = (int) strpos($num, '.'); - $integerPart = substr($num, 0, $dotPos); - $fractionalPart = substr($num, $dotPos + 1); - - // Check if there's a non-zero fractional part - $hasNonZeroFraction = ltrim($fractionalPart, '0') !== ''; - - if ($hasNonZeroFraction) { - $result = $fractionHandler($num, $integerPart, $fractionalPart); - if ($result !== null) { - return $result; - } - } - - // Handle special cases: empty, '-', or '-0' should return '0' - if (in_array($integerPart, ['', '-', '-0'], true)) { - return '0'; - } - - return $integerPart; - } - - return $num; + return self::roundDigits($num, 0, $mode); } /** @@ -600,6 +585,42 @@ public static function mod(string $num1, string $num2, ?int $scale = null): stri return self::formatFinalResult($result, $scale, $maxPad); } + /** + * Get the quotient and remainder of an arbitrary precision division. + * + * The quotient is always computed at scale 0 (integer division) and the + * remainder honours $scale, matching PHP 8.4's native bcdivmod(). Because it + * is built on self::div()/self::mod(), it works without the native bcmath + * extension (unlike symfony/polyfill-php84, which delegates to native bc). + * + * @return string[] the quotient (index 0) and remainder (index 1) + * + * @throws \DivisionByZeroError When divisor is zero + * @throws \ValueError if inputs are not well-formed + */ + public static function divmod(string $num1, string $num2, ?int $scale = null): array + { + // Validate operands and scale under the bcdivmod() name for correct messages. + [$num1, $num2] = self::validateAndNormalizeInputs($num1, $num2, 'bcdivmod'); + $scale = self::resolveScale($scale); + self::validateScale($scale, 'bcdivmod', 3); + self::checkDivisionByZero($num2); + + // Divide once for the quotient, then derive the remainder as + // num1 - num2 * quotient. This avoids the second division that separate + // div()/mod() calls would perform, and matches bcmod()'s truncated + // remainder convention (phpseclib's divide() returns a non-negative + // remainder, which differs for negative operands). + [$num1Big, $num2Big, $maxPad] = self::prepareBigIntegerInputs($num1, $num2); + [$quotient] = $num1Big->divide($num2Big); + $remainder = $num1Big->subtract($num2Big->multiply($quotient)); + + return [ + self::normalizeZeroResult(self::format($quotient, 0, 0)), + self::formatFinalResult($remainder, $scale, $maxPad), + ]; + } + /** * Compare two arbitrary precision numbers. * @@ -968,37 +989,25 @@ public static function sqrt(string $num, ?int $scale = null): string /** * Round down to the nearest integer. * + * Equivalent to rounding towards negative infinity. + * * @throws \ValueError if inputs are not well-formed */ public static function floor(string $num): string { - return self::normalizeZeroForRounding($num, 'bcfloor', static function (string $num, string $integerPart, string $fractionalPart): ?string { - // For negative numbers with fractional parts, we need to subtract 1 - if (self::startsWithNegativeSign($num)) { - return self::sub($integerPart, '1', 0); - } - - return null; // Let the common logic handle this case - }); + return self::roundToInteger($num, 'bcfloor', self::ROUND_NEGATIVE_INFINITY); } /** * Round up to the nearest integer. * + * Equivalent to rounding towards positive infinity. + * * @throws \ValueError if inputs are not well-formed */ public static function ceil(string $num): string { - return self::normalizeZeroForRounding($num, 'bcceil', static function (string $num, string $integerPart, string $fractionalPart): ?string { - // For positive numbers with fractional parts, we need to add 1 - if (!self::startsWithNegativeSign($num)) { - $integerPart = $integerPart === '' ? '0' : $integerPart; - - return self::add($integerPart, '1', 0); - } - - return null; // Let the common logic handle this case - }); + return self::roundToInteger($num, 'bcceil', self::ROUND_POSITIVE_INFINITY); } /** @@ -1014,7 +1023,7 @@ public static function round(string $num, int $precision = 0, $mode = PHP_ROUND_ { // PHP's native bcround() (8.4+) rejects a $precision above INT_MAX before // performing any computation. Without this guard a huge precision triggers - // an out-of-memory fatal via str_repeat() in bcroundHelper(). Mirror + // an out-of-memory fatal via str_repeat() in roundDigits(). Mirror // php-src's bcmath_check_precision(): only the upper bound overflows int. if ($precision > 2147483647) { throw new \ValueError( @@ -1033,143 +1042,248 @@ public static function round(string $num, int $precision = 0, $mode = PHP_ROUND_ return '0'; } - // Convert RoundingMode enum to integer constant for PHP 8.4+ compatibility - $roundingMode = self::convertRoundingMode($mode); - - // Based on: https://stackoverflow.com/a/1653826 - if ($precision < 0) { - // When precision is negative, we round to the left of the decimal point - $absPrecision = abs($precision); - $factor = self::pow('10', (string) $absPrecision, max($absPrecision, 0)); - $shifted = self::div($num, $factor, 10); // Use a high precision for intermediate calculation - - // Apply rounding - $rounded = self::bcroundHelper($shifted, 0, $roundingMode); - - // Shift back - return self::mul($rounded, $factor, 0); - } + return self::roundDigits($num, $precision, self::convertRoundingMode($mode)); + } - return self::bcroundHelper($num, $precision, $roundingMode); + /** + * Helper function for bcround. + * + * Retained as a public entry point for backward compatibility. It accepts the + * legacy integer PHP_ROUND_* modes and delegates to round() so it shares the + * same input, precision and mode validation. + */ + public static function bcroundHelper(string $number, int $precision, int $mode = PHP_ROUND_HALF_UP): string + { + return self::round($number, $precision, $mode); } /** - * Convert RoundingMode enum to integer constant for backward compatibility. + * Normalise a rounding mode (RoundingMode enum or legacy PHP_ROUND_* int) + * to one of the canonical self::ROUND_* string tokens. * - * Note: Parameter type declaration is intentionally omitted to prevent PHP's type coercion. - * With int|\RoundingMode type hint, float values like 1.5 would be auto-converted to int (1), - * preventing proper validation and exception throwing for invalid types. + * Note: the parameter type declaration is intentionally omitted to prevent + * PHP's type coercion. With an int|\RoundingMode hint, a float such as 1.5 + * would be silently truncated to int (1), bypassing validation. * * @param int|\RoundingMode $mode * - * @return int The corresponding PHP_ROUND_* constant - * * @throws \ValueError If an invalid rounding mode is provided */ - private static function convertRoundingMode($mode): int + private static function convertRoundingMode($mode): string { // RoundingMode enum support (both native PHP 8.4+ and polyfill PHP 8.1-8.3) if (enum_exists('RoundingMode') && $mode instanceof \RoundingMode) { return match ($mode) { - \RoundingMode::HalfAwayFromZero => PHP_ROUND_HALF_UP, - \RoundingMode::HalfTowardsZero => PHP_ROUND_HALF_DOWN, - \RoundingMode::HalfEven => PHP_ROUND_HALF_EVEN, - \RoundingMode::HalfOdd => PHP_ROUND_HALF_ODD, - // TODO: Support additional modes if needed - \RoundingMode::NegativeInfinity => throw new \ValueError('RoundingMode::NegativeInfinity is not supported'), - \RoundingMode::PositiveInfinity => throw new \ValueError('RoundingMode::PositiveInfinity is not supported'), - \RoundingMode::TowardsZero => throw new \ValueError('RoundingMode::TowardsZero is not supported'), - \RoundingMode::AwayFromZero => throw new \ValueError('RoundingMode::AwayFromZero is not supported'), // @phpstan-ignore-line - default => throw new \ValueError('Unsupported RoundingMode') + \RoundingMode::HalfAwayFromZero => self::ROUND_HALF_AWAY_FROM_ZERO, + \RoundingMode::HalfTowardsZero => self::ROUND_HALF_TOWARDS_ZERO, + \RoundingMode::HalfEven => self::ROUND_HALF_EVEN, + \RoundingMode::HalfOdd => self::ROUND_HALF_ODD, + \RoundingMode::TowardsZero => self::ROUND_TOWARDS_ZERO, + \RoundingMode::AwayFromZero => self::ROUND_AWAY_FROM_ZERO, + \RoundingMode::NegativeInfinity => self::ROUND_NEGATIVE_INFINITY, + \RoundingMode::PositiveInfinity => self::ROUND_POSITIVE_INFINITY, + // Unreachable: RoundingMode has exactly the eight cases above. + // Present because PHPStan binds \RoundingMode to Rector's scoped + // polyfill class (not our enum) and cannot prove exhaustiveness. + default => throw new \ValueError('Unsupported RoundingMode'), }; } - // Backward compatibility for PHP_ROUND_* constants + // Backward compatibility for the legacy PHP_ROUND_* integer constants. if (is_int($mode)) { - return $mode; + return match ($mode) { + PHP_ROUND_HALF_UP => self::ROUND_HALF_AWAY_FROM_ZERO, + PHP_ROUND_HALF_DOWN => self::ROUND_HALF_TOWARDS_ZERO, + PHP_ROUND_HALF_EVEN => self::ROUND_HALF_EVEN, + PHP_ROUND_HALF_ODD => self::ROUND_HALF_ODD, + default => throw new \ValueError('Invalid rounding mode provided'), + }; } throw new \ValueError('Invalid rounding mode provided'); } /** - * Helper function for bcround. + * Core arbitrary-precision rounding using pure string/digit arithmetic. + * + * Handles every RoundingMode (including the directional modes) and both + * positive and negative precision without ever converting to float, so the + * result stays exact for arbitrarily large or high-precision inputs. + * + * String-digit rounding approach inspired by symfony/polyfill-php84 (MIT). + * + * @param string $number A well-formed numeric string + * @param int $precision Decimal places to round to (may be negative) + * @param string $mode One of the self::ROUND_* canonical tokens */ - public static function bcroundHelper(string $number, int $precision, int $mode = PHP_ROUND_HALF_UP): string + private static function roundDigits(string $number, int $precision, string $mode): string { - if (!str_contains($number, '.')) { - $number .= '.0'; + // Split off an optional sign. + $sign = 1; + if ($number !== '' && ($number[0] === '-' || $number[0] === '+')) { + if ($number[0] === '-') { + $sign = -1; + } + $number = substr($number, 1); } - // Extract sign - $sign = ''; - if (self::startsWithNegativeSign($number)) { - $sign = '-'; - $trimmedNumber = ltrim($number); - $number = substr($trimmedNumber, 1); + // Split into integer and fractional digit runs. + if (str_contains($number, '.')) { + [$intPart, $fracPart] = explode('.', $number, 2); } else { - $number = ltrim($number); + $intPart = $number; + $fracPart = ''; } - - // Add 0.5 * 10^(-$precision) for rounding (for HALF_UP mode) - if ($mode === PHP_ROUND_HALF_UP) { - $addition = '0.'.str_repeat('0', $precision).'5'; - $number = self::add($number, $addition, $precision + 1); - } elseif ($mode === PHP_ROUND_HALF_DOWN) { - // PHP_ROUND_HALF_DOWN (HalfTowardsZero): - // - For exactly 0.5: round towards zero (down for positive, up for negative) - // - For > 0.5: always round away from zero - // - For < 0.5: always round towards zero - - [$int, $dec] = explode('.', $number); - if (isset($dec[$precision])) { - $digit = (int) $dec[$precision]; - $isExactlyHalf = ($digit === 5 && (!isset($dec[$precision + 1]) || ltrim(substr($dec, $precision + 1), '0') === '')); - - if ($digit > 5 || ($digit === 5 && !$isExactlyHalf)) { - // Greater than 0.5: round away from zero (add 0.5) - $addition = '0.'.str_repeat('0', $precision).'5'; - $number = self::add($number, $addition, $precision + 1); - } elseif ($isExactlyHalf && $sign === '-') { - // Exactly 0.5 and negative: round towards zero (which means don't add anything - truncate) - // Do nothing - let truncation handle it - } - // For exactly 0.5 and positive: round towards zero (which is down, so don't add) + if ($intPart === '') { + $intPart = '0'; + } + $intPart = self::stripLeadingZeros($intPart); + + // Partition the digits at the rounding position into a "kept" run + // (scaledInt) and the "dropped" run (scaledFrac). + if ($precision >= 0) { + $fracLength = strlen($fracPart); + if ($precision <= $fracLength) { + $scaledInt = $intPart.substr($fracPart, 0, $precision); + $scaledFrac = substr($fracPart, $precision); + } else { + $scaledInt = $intPart.$fracPart.str_repeat('0', $precision - $fracLength); + $scaledFrac = ''; } } else { - // For other modes, use PHP's round and convert back - // Ensure mode is within valid range (PHP_ROUND_HALF_UP to PHP_ROUND_HALF_ODD) - $validMode = max(PHP_ROUND_HALF_UP, min(PHP_ROUND_HALF_ODD, $mode)); - $rounded = round((float) ($sign.$number), $precision, $validMode); + $shift = -$precision; + $intLength = strlen($intPart); + if ($shift <= $intLength) { + $splitPos = $intLength - $shift; + $scaledInt = substr($intPart, 0, $splitPos); + $scaledInt = $scaledInt === '' ? '0' : $scaledInt; + $scaledFrac = substr($intPart, $splitPos).$fracPart; + } else { + $scaledInt = '0'; + // applyRounding() only inspects the leading dropped digit (always + // '0' here) and whether any non-zero digit follows, so we avoid + // materialising ($shift - $intLength) zeros for a huge negative + // precision. '01' preserves "leading 0, non-zero tail"; '0' means + // everything dropped is zero. + $scaledFrac = trim($intPart.$fracPart, '0') !== '' ? '01' : '0'; + } + } - return number_format($rounded, $precision, '.', ''); + $roundedInt = self::applyRounding($scaledInt, $scaledFrac, $sign, $mode); + $isZero = trim($roundedInt, '0') === ''; + $result = self::placeDecimalPoint($roundedInt, $precision); + + if ($sign === -1 && !$isZero) { + $result = '-'.$result; } - // Truncate to the desired precision - $pos = strpos($number, '.'); - if ($pos !== false) { - if ($precision > 0) { - $number = substr($number, 0, $pos + $precision + 1); - // Pad with zeros if necessary - $currentPrecision = strlen($number) - $pos - 1; - if ($currentPrecision < $precision) { - $number .= str_repeat('0', $precision - $currentPrecision); - } - } else { - $number = substr($number, 0, $pos); - } + return $result; + } + + /** + * Decide whether the kept digits must be incremented for the given mode and + * return the (still unscaled) rounded integer run. + * + * @param int $sign 1 for a non-negative value, -1 for a negative value + */ + private static function applyRounding(string $intPart, string $fracPart, int $sign, string $mode): string + { + $intPart = self::stripLeadingZeros($intPart); + + // Nothing is dropped -> no rounding needed. + if ($fracPart === '' || trim($fracPart, '0') === '') { + return $intPart; } - $result = $sign.$number; + $firstDigit = $fracPart[0]; + $tailNonZero = trim(substr($fracPart, 1), '0') !== ''; + $isGreaterThanHalf = $firstDigit > '5' || ($firstDigit === '5' && $tailNonZero); + $isExactlyHalf = $firstDigit === '5' && !$tailNonZero; + + $increase = match ($mode) { + self::ROUND_TOWARDS_ZERO => false, + self::ROUND_AWAY_FROM_ZERO => true, + self::ROUND_POSITIVE_INFINITY => $sign > 0, + self::ROUND_NEGATIVE_INFINITY => $sign < 0, + self::ROUND_HALF_AWAY_FROM_ZERO => $isGreaterThanHalf || $isExactlyHalf, + self::ROUND_HALF_TOWARDS_ZERO => $isGreaterThanHalf, + self::ROUND_HALF_EVEN => $isGreaterThanHalf || ($isExactlyHalf && self::lastDigitValue($intPart) % 2 === 1), + self::ROUND_HALF_ODD => $isGreaterThanHalf || ($isExactlyHalf && self::lastDigitValue($intPart) % 2 === 0), + default => throw new \ValueError('Invalid rounding mode provided'), + }; + + if ($increase) { + $intPart = self::incrementDigitString($intPart); + } - // Handle negative zero case - if ($result === '-0' || $result === '-0.' || preg_match('/^-0\.0+$/', $result)) { - $result = ltrim($result, '-'); - if ($result === self::DEFAULT_NUMBER || $result === self::DEFAULT_NUMBER.'.' || preg_match('/^0\.0+$/', $result)) { - $result = $precision > 0 ? '0.'.str_repeat('0', $precision) : '0'; + return self::stripLeadingZeros($intPart); + } + + /** + * Re-insert the decimal point after rounding, restoring the requested number + * of fractional digits (or trailing zeros for negative precision). + */ + private static function placeDecimalPoint(string $roundedInt, int $precision): string + { + if ($precision > 0) { + if (strlen($roundedInt) <= $precision) { + $roundedInt = str_pad($roundedInt, $precision + 1, '0', STR_PAD_LEFT); } + $intDigits = substr($roundedInt, 0, -$precision); + $fracDigits = substr($roundedInt, -$precision); + $intDigits = self::stripLeadingZeros($intDigits === '' ? '0' : $intDigits); + + return $intDigits.'.'.$fracDigits; } - return $result; + if ($precision === 0) { + return self::stripLeadingZeros($roundedInt); + } + + // A zero result stays '0' regardless of scale; short-circuit before + // allocating -$precision trailing zeros for a huge negative precision. + if (trim($roundedInt, '0') === '') { + return '0'; + } + + return self::stripLeadingZeros($roundedInt.str_repeat('0', -$precision)); + } + + /** + * Strip leading zeros, keeping at least a single '0'. + */ + private static function stripLeadingZeros(string $digits): string + { + $digits = ltrim($digits, '0'); + + return $digits === '' ? '0' : $digits; + } + + /** + * Add one to a non-negative run of decimal digits, propagating any carry. + */ + private static function incrementDigitString(string $digits): string + { + $digits = $digits === '' ? '0' : $digits; + $index = strlen($digits) - 1; + $carry = 1; + while ($index >= 0 && $carry === 1) { + $value = ord($digits[$index]) - 48 + $carry; + $carry = $value >= 10 ? 1 : 0; + $digits[$index] = chr(48 + $value % 10); + --$index; + } + + return $carry === 1 ? '1'.$digits : $digits; + } + + /** + * Return the numeric value of the last digit in a run (0 when empty). + */ + private static function lastDigitValue(string $digits): int + { + $length = strlen($digits); + + return $length > 0 ? ord($digits[$length - 1]) - 48 : 0; } } diff --git a/tests/BCMathTest.php b/tests/BCMathTest.php index 3ae351d..c2e0197 100644 --- a/tests/BCMathTest.php +++ b/tests/BCMathTest.php @@ -1660,13 +1660,6 @@ public function testRoundingModeEnumSupport(string $number, int $scale, $mode, s $this->markTestSkipped('RoundingMode enum not available'); } - // Skip unsupported modes in all versions - $unsupportedModes = [\RoundingMode::TowardsZero, \RoundingMode::AwayFromZero, \RoundingMode::NegativeInfinity]; - if (in_array($mode, $unsupportedModes, true)) { - $this->expectException(\ValueError::class); - $this->expectExceptionMessage("RoundingMode::{$mode->name} is not supported"); - } - $result = BCMath::round($number, $scale, $mode); $this->assertSame($expected, $result); } @@ -1788,6 +1781,10 @@ public function testRoundingModeComprehensive(string $number, int $scale): void ['mode' => \RoundingMode::HalfTowardsZero, 'name' => 'HalfTowardsZero'], ['mode' => \RoundingMode::HalfEven, 'name' => 'HalfEven'], ['mode' => \RoundingMode::HalfOdd, 'name' => 'HalfOdd'], + ['mode' => \RoundingMode::TowardsZero, 'name' => 'TowardsZero'], + ['mode' => \RoundingMode::AwayFromZero, 'name' => 'AwayFromZero'], + ['mode' => \RoundingMode::NegativeInfinity, 'name' => 'NegativeInfinity'], + ['mode' => \RoundingMode::PositiveInfinity, 'name' => 'PositiveInfinity'], ]; foreach ($testCases as $testCase) { @@ -1821,6 +1818,32 @@ public function testRoundingModeComprehensive(string $number, int $scale): void $number === '1.255' && $scale === 2 && $modeName === 'HalfEven' => '1.26', $number === '1.255' && $scale === 2 && $modeName === 'HalfOdd' => '1.25', + // Directional modes (PHP 8.4+ semantics, now supported by the polyfill) + $number === '2.5' && $scale === 0 && $modeName === 'TowardsZero' => '2', + $number === '2.5' && $scale === 0 && $modeName === 'AwayFromZero' => '3', + $number === '2.5' && $scale === 0 && $modeName === 'NegativeInfinity' => '2', + $number === '2.5' && $scale === 0 && $modeName === 'PositiveInfinity' => '3', + + $number === '-2.5' && $scale === 0 && $modeName === 'TowardsZero' => '-2', + $number === '-2.5' && $scale === 0 && $modeName === 'AwayFromZero' => '-3', + $number === '-2.5' && $scale === 0 && $modeName === 'NegativeInfinity' => '-3', + $number === '-2.5' && $scale === 0 && $modeName === 'PositiveInfinity' => '-2', + + $number === '3.5' && $scale === 0 && $modeName === 'TowardsZero' => '3', + $number === '3.5' && $scale === 0 && $modeName === 'AwayFromZero' => '4', + $number === '3.5' && $scale === 0 && $modeName === 'NegativeInfinity' => '3', + $number === '3.5' && $scale === 0 && $modeName === 'PositiveInfinity' => '4', + + $number === '-3.5' && $scale === 0 && $modeName === 'TowardsZero' => '-3', + $number === '-3.5' && $scale === 0 && $modeName === 'AwayFromZero' => '-4', + $number === '-3.5' && $scale === 0 && $modeName === 'NegativeInfinity' => '-4', + $number === '-3.5' && $scale === 0 && $modeName === 'PositiveInfinity' => '-3', + + $number === '1.255' && $scale === 2 && $modeName === 'TowardsZero' => '1.25', + $number === '1.255' && $scale === 2 && $modeName === 'AwayFromZero' => '1.26', + $number === '1.255' && $scale === 2 && $modeName === 'NegativeInfinity' => '1.25', + $number === '1.255' && $scale === 2 && $modeName === 'PositiveInfinity' => '1.26', + default => null }; @@ -1833,29 +1856,6 @@ public function testRoundingModeComprehensive(string $number, int $scale): void ); } } - - // Test unsupported modes throw ValueError - $unsupportedModes = [ - ['mode' => \RoundingMode::TowardsZero, 'name' => 'TowardsZero'], - ['mode' => \RoundingMode::AwayFromZero, 'name' => 'AwayFromZero'], - ['mode' => \RoundingMode::NegativeInfinity, 'name' => 'NegativeInfinity'], - ]; - - foreach ($unsupportedModes as $testCase) { - $mode = $testCase['mode']; - $modeName = $testCase['name']; - - try { - BCMath::round($number, $scale, $mode); - $this->fail("Expected ValueError for unsupported mode {$modeName}"); - } catch (\ValueError $e) { - $this->assertStringContainsString( - 'is not supported', - $e->getMessage(), - "Expected specific error message for unsupported mode {$modeName}" - ); - } - } } /** @@ -1967,34 +1967,35 @@ public function testPolyfillRoundingModeEnumSupport(): void } /** - * Test unsupported RoundingMode enum values in PHP < 8.4. - * These modes should throw ValueError when used in polyfill environment. + * Test the directional RoundingMode enum values (TowardsZero, AwayFromZero, + * NegativeInfinity, PositiveInfinity). These are backported by the polyfill + * for PHP 8.1-8.3 and match the native PHP 8.4+ semantics. */ #[RequiresPhp('>=8.1')] - public function testPolyfillUnsupportedModes(): void + public function testPolyfillDirectionalModes(): void { if (!enum_exists('RoundingMode')) { $this->markTestSkipped('RoundingMode enum not available'); } - // Skip this test on PHP 8.4+ where these modes are supported - $unsupportedModes = [ - \RoundingMode::TowardsZero, - \RoundingMode::AwayFromZero, - \RoundingMode::NegativeInfinity, + // [mode, number, scale, expected] + $directionalCases = [ + [\RoundingMode::TowardsZero, '1.55', 1, '1.5'], + [\RoundingMode::TowardsZero, '-1.55', 1, '-1.5'], + [\RoundingMode::AwayFromZero, '1.55', 1, '1.6'], + [\RoundingMode::AwayFromZero, '-1.55', 1, '-1.6'], + [\RoundingMode::PositiveInfinity, '1.51', 1, '1.6'], + [\RoundingMode::PositiveInfinity, '-1.59', 1, '-1.5'], + [\RoundingMode::NegativeInfinity, '1.59', 1, '1.5'], + [\RoundingMode::NegativeInfinity, '-1.51', 1, '-1.6'], ]; - foreach ($unsupportedModes as $mode) { - try { - BCMath::round('1.55', 1, $mode); - $this->fail("Expected ValueError for unsupported mode {$mode->name}"); - } catch (\ValueError $e) { - $this->assertStringContainsString( - 'is not supported', - $e->getMessage(), - "Expected specific error message for unsupported mode {$mode->name}" - ); - } + foreach ($directionalCases as [$mode, $number, $scale, $expected]) { + $this->assertSame( + $expected, + BCMath::round($number, $scale, $mode), + "Failed for mode {$mode->name}, number={$number}, scale={$scale}" + ); } } @@ -2059,6 +2060,7 @@ public function testRoundingModeEnvironmentDetection(): void 'TowardsZero', 'AwayFromZero', 'NegativeInfinity', + 'PositiveInfinity', ]; foreach ($expectedCases as $caseName) { @@ -2070,12 +2072,79 @@ public function testRoundingModeEnvironmentDetection(): void ); } - // Test that unsupported modes always throw ValueError regardless of PHP version - try { - BCMath::round('1.55', 1, \RoundingMode::TowardsZero); - $this->fail('TowardsZero mode should always throw ValueError'); - } catch (\ValueError $e) { - $this->assertStringContainsString('is not supported', $e->getMessage()); + // Directional modes are supported (polyfilled for 8.1-8.3, native on 8.4+) + // and produce the native PHP 8.4 result regardless of PHP version. + $this->assertSame('1', BCMath::round('1.55', 0, \RoundingMode::TowardsZero)); + $this->assertSame('2', BCMath::round('1.55', 0, \RoundingMode::AwayFromZero)); + } + + /** + * Regression test: HalfEven/HalfOdd rounding must stay exact for large and + * high-precision inputs. The previous implementation fell back to float + * (round((float) ...)), which corrupted digits beyond IEEE-754 precision. + */ + #[RequiresPhp('>=8.1')] + public function testHighPrecisionHalfEvenOddRounding(): void + { + if (!enum_exists('RoundingMode')) { + $this->markTestSkipped('RoundingMode enum not available'); + } + + // Larger than 2^53: float conversion would lose the low-order digits. + $this->assertSame('12345678901234567890', BCMath::round('12345678901234567890.5', 0, \RoundingMode::HalfEven)); + $this->assertSame('12345678901234567891', BCMath::round('12345678901234567890.5', 0, \RoundingMode::HalfOdd)); + + // Around 2^53 (9007199254740992). + $this->assertSame('9007199254740994', BCMath::round('9007199254740993.5', 0, \RoundingMode::HalfEven)); + $this->assertSame('9007199254740993', BCMath::round('9007199254740993.5', 0, \RoundingMode::HalfOdd)); + + // 30 significant digits kept exactly. + $this->assertSame('0.12345678901234567890', BCMath::round('0.123456789012345678901234567890', 20, \RoundingMode::HalfEven)); + $this->assertSame('0.12345678901234567890', BCMath::round('0.123456789012345678901234567890', 20, \RoundingMode::HalfOdd)); + } + + /** + * Test bcdivmod() (PHP 8.4+), implemented on top of BCMath::div()/mod() so it + * works without the native bcmath extension. + */ + public function testDivmod(): void + { + // [num1, num2, scale, expectedQuotient, expectedRemainder] + $cases = [ + ['17', '5', 0, '3', '2'], + ['17', '5', 2, '3', '2.00'], + ['-17', '5', 0, '-3', '-2'], + ['17', '-5', 0, '-3', '2'], + ['14.14', '6', 2, '2', '2.14'], + ['0.15', '-0.01', 0, '-15', '0'], + ]; + + foreach ($cases as [$num1, $num2, $scale, $expectedQuot, $expectedRem]) { + [$quot, $rem] = BCMath::divmod($num1, $num2, $scale); + $this->assertSame($expectedQuot, $quot, "quotient of {$num1} / {$num2} @ {$scale}"); + $this->assertSame($expectedRem, $rem, "remainder of {$num1} / {$num2} @ {$scale}"); + + // On PHP 8.4+ (native bcdivmod available) the polyfill must match it. + if (function_exists('bcdivmod')) { + // @phpstan-ignore-next-line + $this->assertSame([$expectedQuot, $expectedRem], bcdivmod($num1, $num2, $scale)); + } } + + // The result must equal the composition of bcdiv()/bcmod(). + [$quot, $rem] = BCMath::divmod('15151324141414.412', '7.3', 10); + $this->assertSame(BCMath::div('15151324141414.412', '7.3', 0), $quot); + $this->assertSame(BCMath::mod('15151324141414.412', '7.3', 10), $rem); + } + + /** + * bcdivmod() by zero must throw DivisionByZeroError with the native message. + */ + public function testDivmodByZero(): void + { + $this->expectException(\DivisionByZeroError::class); + $this->expectExceptionMessage('Division by zero'); + + BCMath::divmod('1', '0'); } }