Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
552c183
refactor(BCMath): extract zero detection logic to reduce code duplicaโ€ฆ
nanasess Sep 8, 2025
55066fe
Merge remote-tracking branch 'origin/main' into fix/zero-base
nanasess Sep 8, 2025
5a53bcc
fix(BCMath): implement comprehensive bcsqrt validation and algorithm โ€ฆ
nanasess Sep 8, 2025
015dd75
test(BCMathTest): add test cases for negative zero handling
nanasess Sep 8, 2025
a0d3f0d
[rector] Rector fixes
actions-user Sep 8, 2025
4641ef2
ci(workflows): remove redundant test from skip list in BCMath workflow
nanasess Sep 8, 2025
adf4795
Merge remote-tracking branch 'origin/fix/zero-base' into fix/zero-base
nanasess Sep 8, 2025
d0d231f
refactor(BCMath): improve code consistency and formatting
nanasess Sep 8, 2025
ef881ed
Update src/BCMath.php
nanasess Sep 8, 2025
477d9a7
docs(BCMath): enhance isZero method documentation with examples
nanasess Sep 8, 2025
b1982ed
security(BCMath): fix whitespace handling vulnerabilities in characteโ€ฆ
nanasess Sep 9, 2025
e892429
test(BCMathTest): expand comprehensive whitespace character test coveโ€ฆ
nanasess Sep 9, 2025
2975805
feat(BCMath): add validation for malformed input with ValueError
nanasess Sep 9, 2025
deebd4a
Update src/BCMath.php
nanasess Sep 9, 2025
d54df99
Update tests/BCMathTest.php
nanasess Sep 9, 2025
ed6c176
Update tests/BCMathTest.php
nanasess Sep 9, 2025
1964a53
Update tests/BCMathTest.php
nanasess Sep 9, 2025
1eec590
refactor(BCMath): extract negative sign check into a reusable method
nanasess Sep 9, 2025
13c0cc2
test(BCMathTest): fix whitespace formatting in test case
nanasess Sep 9, 2025
83ca621
Revert "Update tests/BCMathTest.php"
nanasess Sep 9, 2025
ca08de6
refactor(BCMath): apply php-cs-fixer rules and improve readability
nanasess Sep 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/php-src-bcmath-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ jobs:
cp php-src/ext/bcmath/tests/*.inc tests/php-src/

- name: Run php-src BCMath tests
run: ./scripts/run-php-src-tests.sh --skip gh17398,gh16262,gh15968,bcround_toward_zero,bcround_half_up,bcround_half_odd,bcround_half_even,bcround_half_down,bcround_all,bcround_floor,bcround_early_return,bcround_ceiling,bcround_away_from_zero,bcdivmod,bcpow_error2,bcpowmod_zero_modulus,bcsqrt,bug60377,bug78878,bcdiv_error2,bcmod_error3
run: ./scripts/run-php-src-tests.sh --skip gh17398,gh16262,gh15968,bcround_toward_zero,bcround_half_up,bcround_half_odd,bcround_half_even,bcround_half_down,bcround_all,bcround_floor,bcround_early_return,bcround_ceiling,bcround_away_from_zero,bcdivmod,bcpow_error2,bcpowmod_zero_modulus,bug60377,bug78878,bcdiv_error2,bcmod_error3
204 changes: 173 additions & 31 deletions src/BCMath.php
Original file line number Diff line number Diff line change
Expand Up @@ -238,18 +238,57 @@ private static function checkEarlyZero(string $num1, string $num2, int $scale):
}

/**
* Check for division by zero and throw exception.
* Check if a numeric string represents zero.
*
* @throws \DivisionByZeroError If divisor is zero
* Handles various zero formats: '0', '0.00', '-0.00', '+0.000', etc.
* Uses consistent normalization logic for reliable zero detection.
*
* Examples of inputs that return true:
* - '0', '0.0', '0.00', '0.000'
* - '-0', '-0.0', '-0.00', '-0.000'
* - '+0', '+0.0', '+0.00', '+0.000'
* - '00', '00.00', '000.000'
*
* Examples of inputs that return false:
* - '1', '0.1', '0.001', '-0.001'
* - 'abc', '', '.'
*
* @param string $number The numeric string to check
*
* @return bool True if the number is zero, false otherwise
*/
private static function checkDivisionByZero(string $divisor): void
private static function isZero(string $number): bool
{
// Normalize and check for zero - handle '0', '0.00', '-0.00', etc.
$normalized = ltrim($divisor, '+-');
$normalized = ltrim($number, '+-');
$normalized = ltrim($normalized, '0');
$normalized = ltrim($normalized, '.');
$normalized = rtrim($normalized, '0');
if ($normalized === '' || $normalized === '.') {

return $normalized === '' || $normalized === '.';
}

/**
* Check if a string starts with a negative sign after trimming whitespace.
*
* @param string $num The string to check
*
* @return bool True if the string starts with '-' after trimming, false otherwise
*/
private static function startsWithNegativeSign(string $num): bool
{
$trimmed = ltrim($num);

return $trimmed !== '' && $trimmed[0] === '-';
Comment on lines +279 to +281
Copy link

Copilot AI Sep 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method trims whitespace but the validation should have already caught malformed numbers with whitespace. Consider either removing the trim operation for consistency or documenting why it's needed here.

Suggested change
$trimmed = ltrim($num);
return $trimmed !== '' && $trimmed[0] === '-';
return $num !== '' && $num[0] === '-';

Copilot uses AI. Check for mistakes.
}

/**
* Check for division by zero and throw exception.
*
* @throws \DivisionByZeroError If divisor is zero
*/
private static function checkDivisionByZero(string $divisor): void
{
if (self::isZero($divisor)) {
throw new \DivisionByZeroError(self::DIVISION_BY_ZERO_MESSAGE);
}
}
Expand Down Expand Up @@ -292,14 +331,15 @@ protected static function validateIntegerInputs(array $numbers, array $names = [
}

// Check non-negative constraint
if (isset($constraints['non_negative']) && in_array($index, $constraints['non_negative'], true) && isset($intPart[0]) && $intPart[0] === '-') {
if (isset($constraints['non_negative']) && in_array($index, $constraints['non_negative'], true) && self::startsWithNegativeSign($intPart)) {
throw new \ValueError("{$paramName} must be greater than or equal to 0");
}

// Handle negative numbers by removing the sign if allowed
if ($intPart[0] === '-'
if (self::startsWithNegativeSign($intPart)
&& (!isset($constraints['non_negative']) || !in_array($index, $constraints['non_negative'], true))) {
$intPart = substr($intPart, 1);
$trimmedIntPart = ltrim($intPart);
$intPart = substr($trimmedIntPart, 1);
}

$results[] = $intPart;
Expand Down Expand Up @@ -399,6 +439,8 @@ public static function isNegative(BigInteger $x): bool

/**
* Add two arbitrary precision numbers.
*
* @throws \ValueError if inputs are not well-formed
*/
public static function add(string $num1, string $num2, ?int $scale = null): string
{
Expand All @@ -421,6 +463,8 @@ public static function add(string $num1, string $num2, ?int $scale = null): stri

/**
* Subtract one arbitrary precision number from another.
*
* @throws \ValueError if inputs are not well-formed
*/
public static function sub(string $num1, string $num2, ?int $scale = null): string
{
Expand All @@ -443,6 +487,8 @@ public static function sub(string $num1, string $num2, ?int $scale = null): stri

/**
* Multiply two arbitrary precision numbers.
*
* @throws \ValueError if inputs are not well-formed
*/
public static function mul(string $num1, string $num2, ?int $scale = null): string
{
Expand Down Expand Up @@ -474,6 +520,7 @@ public static function mul(string $num1, string $num2, ?int $scale = null): stri
* Divide two arbitrary precision numbers.
*
* @throws \DivisionByZeroError When divisor is zero
* @throws \ValueError if inputs are not well-formed
*/
public static function div(string $num1, string $num2, ?int $scale = null): string
{
Expand Down Expand Up @@ -505,6 +552,7 @@ public static function div(string $num1, string $num2, ?int $scale = null): stri
* Uses the PHP 7.2+ behavior
*
* @throws \DivisionByZeroError When divisor is zero
* @throws \ValueError if inputs are not well-formed
*/
public static function mod(string $num1, string $num2, ?int $scale = null): string
{
Expand All @@ -530,6 +578,8 @@ public static function mod(string $num1, string $num2, ?int $scale = null): stri

/**
* Compare two arbitrary precision numbers.
*
* @throws \ValueError if inputs are not well-formed
*/
public static function comp(string $num1, string $num2, ?int $scale = null): int
{
Expand Down Expand Up @@ -578,12 +628,7 @@ public static function pow(string $base, string $exponent, ?int $scale = null):
[$base, $exponent] = self::validateAndNormalizeInputs($base, $exponent, 'bcpow');

// Handle special case: 0 to any power is 0 (except 0^0 which is handled above)
// Check for zero using same logic as division by zero check
$normalized = ltrim($base, '+-');
$normalized = ltrim($normalized, '0');
$normalized = ltrim($normalized, '.');
$normalized = rtrim($normalized, '0');
if ($normalized === '' || $normalized === '.') {
if (self::isZero($base)) {
$result = '0';
if ($scale !== 0) {
$result .= '.'.str_repeat('0', $scale);
Expand Down Expand Up @@ -688,6 +733,8 @@ public static function powmod(string $base, string $exponent, string $modulus, ?

/**
* Get the square root of an arbitrary precision number.
*
* @throws \ValueError if inputs are not well-formed
*/
public static function sqrt(string $num, ?int $scale = null): string
{
Expand All @@ -697,7 +744,7 @@ public static function sqrt(string $num, ?int $scale = null): string
// Argument validation
self::validateNumberString($num, 'bcsqrt', 1, 'num');

// Use default scale if not provided
// Use default scale if not provided (needed for early zero return)
if ($scale === null) {
if (!isset(self::$scale)) {
$defaultScale = ini_get('bcmath.scale');
Expand All @@ -707,21 +754,78 @@ public static function sqrt(string $num, ?int $scale = null): string
}
self::validateScale($scale, 'bcsqrt', 2);

// Check for negative numbers (except negative zero)
if (self::startsWithNegativeSign($num) && !self::isZero($num)) {
Copy link

Copilot AI Sep 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The startsWithNegativeSign method calls ltrim($num) which could be expensive for large strings. Since we're only checking the first character after trimming, consider using a more direct approach or caching the trimmed result.

Copilot uses AI. Check for mistakes.
throw new \ValueError('bcsqrt(): Argument #1 ($num) must be greater than or equal to 0');
}

// Handle zero case early (including negative zero)
if (self::isZero($num)) {
return $scale !== 0 ? '0.'.str_repeat('0', $scale) : '0';
}

$temp = explode('.', $num);
$numStr = implode('', $temp);
$wasPadded = strlen($numStr) % 2 !== 0;
if ($wasPadded) {
$numStr = "0{$numStr}";
}
// Calculate decimal start position: original integer length + padding, divided by 2
$integerLength = strlen($temp[0]) + ($wasPadded ? 1 : 0);
$decStart = $integerLength / 2;
$integerPart = $temp[0];
$decimalPart = $temp[1] ?? '';

// Special handling for numbers < 1
$leadingZeroPairs = 0;
$skipIntegerPart = false;
if ($integerPart === '0' && $decimalPart !== '') {
$skipIntegerPart = true;
// Count leading zeros in decimal part
$leadingZeros = strspn($decimalPart, '0');
// For decimals < 1, each pair of leading zeros in the input produces one leading zero in the result.
$leadingZeroPairs = (int) floor($leadingZeros / 2);

// Now we need to create proper pairs from the decimal part
// If odd number of leading zeros, the last zero pairs with first non-zero digit
if ($leadingZeros % 2 === 1) {
// Skip the paired zeros, keep the odd zero with remaining digits
$decimalPart = substr($decimalPart, $leadingZeros - 1);
} else {
// Skip all the leading zeros as they're all paired
$decimalPart = substr($decimalPart, $leadingZeros);
}

// Now pad the decimal part if needed
if (strlen($decimalPart) % 2 !== 0) {
$decimalPart .= '0';
}

// For numbers < 1, we only process the decimal part
$numStr = $decimalPart;
} else {
// For numbers >= 1, process normally
// Pad integer part on the left if odd length
if (strlen($integerPart) % 2 !== 0) {
$integerPart = '0'.$integerPart;
}

// Pad decimal part on the right if odd length
if (strlen($decimalPart) % 2 !== 0) {
$decimalPart .= '0';
}

// Create combined string
$numStr = $integerPart.$decimalPart;
}

// Calculate how many digits the integer part of the result should have
// For numbers >= 1: ceil(n/2) where n is the number of integer digits
// For numbers < 1: 0 (the result will also be < 1)
$integerResultDigits = ($temp[0] === '0') ? 0 : (int) ceil(strlen($temp[0]) / 2);

// Create array of digit pairs
$parts = str_split($numStr, 2);
$parts = array_map('intval', $parts);

$i = 0;
$p = 0; // for the first step, p = 0
$c = $parts[$i];
$result = '';
$digitCount = 0; // Track how many result digits we've generated

while (true) {
// determine the greatest digit x such that x(20p+x) <= c
for ($x = 1; $x <= 10; $x++) {
Expand All @@ -732,17 +836,40 @@ public static function sqrt(string $num, ?int $scale = null): string
}
}
$result .= $x;
$digitCount++;

// Add decimal point after we've generated all integer digits
if ($digitCount === $integerResultDigits && $scale > 0) {
$result .= '.';
}

$y = $x * (20 * $p + $x);
$p = 10 * $p + $x;
$c = 100 * ($c - $y);
if (isset($parts[++$i])) {
$c += $parts[$i];
}
if ((!$c && $i >= $decStart) || $i - $decStart === $scale) {

// Check if we should stop
$decimalDigits = $digitCount - $integerResultDigits;
if ((!$c && $digitCount >= $integerResultDigits) || ($decimalDigits >= $scale && $scale >= 0)) {
break;
}
if ($decStart === $i) {
$result .= '.';
}

// For numbers < 1, format the result properly
if ($integerResultDigits === 0) {
// If scale is 0 and result would be < 1, return '0'
if ($scale === 0) {
return '0';
}

if ($leadingZeroPairs > 0) {
// Result should be 0.{leadingZeroPairs zeros}{result}
$result = '0.'.str_repeat('0', $leadingZeroPairs).$result;
} else {
// No leading zeros, but still < 1, so add '0.' prefix
$result = '0.'.$result;
}
}

Expand All @@ -758,9 +885,13 @@ public static function sqrt(string $num, ?int $scale = null): string

/**
* Round down to the nearest integer.
*
* @throws \ValueError if inputs are not well-formed
*/
public static function floor(string $num): string
{
self::validateNumberString($num, 'bcfloor', 1, 'num');

if (!is_numeric($num)) {
if (version_compare(PHP_VERSION, '8.4', '>=')) {
throw new \ValueError('bcfloor(): Argument #1 ($num) is not well-formed');
Expand All @@ -777,7 +908,7 @@ public static function floor(string $num): string
$fractionalPart = substr($num, $dotPos + 1);

// For negative numbers with fractional parts, we need to subtract 1
if ($num[0] === '-' && ltrim($fractionalPart, '0') !== '') {
if (self::startsWithNegativeSign($num) && ltrim($fractionalPart, '0') !== '') {
return self::sub($integerPart, '1', 0);
}

Expand All @@ -789,9 +920,13 @@ public static function floor(string $num): string

/**
* Round up to the nearest integer.
*
* @throws \ValueError if inputs are not well-formed
*/
public static function ceil(string $num): string
{
self::validateNumberString($num, 'bcceil', 1, 'num');

if (!is_numeric($num)) {
if (version_compare(PHP_VERSION, '8.4', '>=')) {
throw new \ValueError('bcceil(): Argument #1 ($num) is not well-formed');
Expand All @@ -808,7 +943,7 @@ public static function ceil(string $num): string
$fractionalPart = substr($num, $dotPos + 1);

// For positive numbers with fractional parts, we need to add 1
if ($num[0] !== '-' && ltrim($fractionalPart, '0') !== '') {
if (!self::startsWithNegativeSign($num) && ltrim($fractionalPart, '0') !== '') {
$integerPart = $integerPart === '' ? '0' : $integerPart;

return self::add($integerPart, '1', 0);
Expand All @@ -822,9 +957,13 @@ public static function ceil(string $num): string

/**
* Round to a given decimal place.
*
* @throws \ValueError if inputs are not well-formed
*/
public static function round(string $num, int $precision = 0, int $mode = PHP_ROUND_HALF_UP): string
{
self::validateNumberString($num, 'bcround', 1, 'num');

if (!is_numeric($num)) {
if (version_compare(PHP_VERSION, '8.4', '>=')) {
throw new \ValueError('bcround(): Argument #1 ($num) is not well-formed');
Expand Down Expand Up @@ -862,9 +1001,12 @@ public static function bcroundHelper(string $number, int $precision, int $mode =

// Extract sign
$sign = '';
if ($number[0] === '-') {
if (self::startsWithNegativeSign($number)) {
$sign = '-';
$number = substr($number, 1);
$trimmedNumber = ltrim($number);
$number = substr($trimmedNumber, 1);
} else {
$number = ltrim($number);
}

// Add 0.5 * 10^(-$precision) for rounding (for HALF_UP mode)
Expand Down
Loading
Loading