Skip to content

Commit 91c8cff

Browse files
authored
Merge pull request #32 from nanasess/fix/zero-base
Fix BCMath::sqrt negative validation and algorithm precision issues
2 parents 2dbd1ba + ca08de6 commit 91c8cff

File tree

3 files changed

+494
-32
lines changed

3 files changed

+494
-32
lines changed

.github/workflows/php-src-bcmath-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,4 @@ jobs:
3636
cp php-src/ext/bcmath/tests/*.inc tests/php-src/
3737
3838
- name: Run php-src BCMath tests
39-
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
39+
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

src/BCMath.php

Lines changed: 173 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -238,18 +238,57 @@ private static function checkEarlyZero(string $num1, string $num2, int $scale):
238238
}
239239

240240
/**
241-
* Check for division by zero and throw exception.
241+
* Check if a numeric string represents zero.
242242
*
243-
* @throws \DivisionByZeroError If divisor is zero
243+
* Handles various zero formats: '0', '0.00', '-0.00', '+0.000', etc.
244+
* Uses consistent normalization logic for reliable zero detection.
245+
*
246+
* Examples of inputs that return true:
247+
* - '0', '0.0', '0.00', '0.000'
248+
* - '-0', '-0.0', '-0.00', '-0.000'
249+
* - '+0', '+0.0', '+0.00', '+0.000'
250+
* - '00', '00.00', '000.000'
251+
*
252+
* Examples of inputs that return false:
253+
* - '1', '0.1', '0.001', '-0.001'
254+
* - 'abc', '', '.'
255+
*
256+
* @param string $number The numeric string to check
257+
*
258+
* @return bool True if the number is zero, false otherwise
244259
*/
245-
private static function checkDivisionByZero(string $divisor): void
260+
private static function isZero(string $number): bool
246261
{
247-
// Normalize and check for zero - handle '0', '0.00', '-0.00', etc.
248-
$normalized = ltrim($divisor, '+-');
262+
$normalized = ltrim($number, '+-');
249263
$normalized = ltrim($normalized, '0');
250264
$normalized = ltrim($normalized, '.');
251265
$normalized = rtrim($normalized, '0');
252-
if ($normalized === '' || $normalized === '.') {
266+
267+
return $normalized === '' || $normalized === '.';
268+
}
269+
270+
/**
271+
* Check if a string starts with a negative sign after trimming whitespace.
272+
*
273+
* @param string $num The string to check
274+
*
275+
* @return bool True if the string starts with '-' after trimming, false otherwise
276+
*/
277+
private static function startsWithNegativeSign(string $num): bool
278+
{
279+
$trimmed = ltrim($num);
280+
281+
return $trimmed !== '' && $trimmed[0] === '-';
282+
}
283+
284+
/**
285+
* Check for division by zero and throw exception.
286+
*
287+
* @throws \DivisionByZeroError If divisor is zero
288+
*/
289+
private static function checkDivisionByZero(string $divisor): void
290+
{
291+
if (self::isZero($divisor)) {
253292
throw new \DivisionByZeroError(self::DIVISION_BY_ZERO_MESSAGE);
254293
}
255294
}
@@ -292,14 +331,15 @@ protected static function validateIntegerInputs(array $numbers, array $names = [
292331
}
293332

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

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

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

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

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

444488
/**
445489
* Multiply two arbitrary precision numbers.
490+
*
491+
* @throws \ValueError if inputs are not well-formed
446492
*/
447493
public static function mul(string $num1, string $num2, ?int $scale = null): string
448494
{
@@ -474,6 +520,7 @@ public static function mul(string $num1, string $num2, ?int $scale = null): stri
474520
* Divide two arbitrary precision numbers.
475521
*
476522
* @throws \DivisionByZeroError When divisor is zero
523+
* @throws \ValueError if inputs are not well-formed
477524
*/
478525
public static function div(string $num1, string $num2, ?int $scale = null): string
479526
{
@@ -505,6 +552,7 @@ public static function div(string $num1, string $num2, ?int $scale = null): stri
505552
* Uses the PHP 7.2+ behavior
506553
*
507554
* @throws \DivisionByZeroError When divisor is zero
555+
* @throws \ValueError if inputs are not well-formed
508556
*/
509557
public static function mod(string $num1, string $num2, ?int $scale = null): string
510558
{
@@ -530,6 +578,8 @@ public static function mod(string $num1, string $num2, ?int $scale = null): stri
530578

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

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

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

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

757+
// Check for negative numbers (except negative zero)
758+
if (self::startsWithNegativeSign($num) && !self::isZero($num)) {
759+
throw new \ValueError('bcsqrt(): Argument #1 ($num) must be greater than or equal to 0');
760+
}
761+
762+
// Handle zero case early (including negative zero)
763+
if (self::isZero($num)) {
764+
return $scale !== 0 ? '0.'.str_repeat('0', $scale) : '0';
765+
}
766+
710767
$temp = explode('.', $num);
711-
$numStr = implode('', $temp);
712-
$wasPadded = strlen($numStr) % 2 !== 0;
713-
if ($wasPadded) {
714-
$numStr = "0{$numStr}";
715-
}
716-
// Calculate decimal start position: original integer length + padding, divided by 2
717-
$integerLength = strlen($temp[0]) + ($wasPadded ? 1 : 0);
718-
$decStart = $integerLength / 2;
768+
$integerPart = $temp[0];
769+
$decimalPart = $temp[1] ?? '';
770+
771+
// Special handling for numbers < 1
772+
$leadingZeroPairs = 0;
773+
$skipIntegerPart = false;
774+
if ($integerPart === '0' && $decimalPart !== '') {
775+
$skipIntegerPart = true;
776+
// Count leading zeros in decimal part
777+
$leadingZeros = strspn($decimalPart, '0');
778+
// For decimals < 1, each pair of leading zeros in the input produces one leading zero in the result.
779+
$leadingZeroPairs = (int) floor($leadingZeros / 2);
780+
781+
// Now we need to create proper pairs from the decimal part
782+
// If odd number of leading zeros, the last zero pairs with first non-zero digit
783+
if ($leadingZeros % 2 === 1) {
784+
// Skip the paired zeros, keep the odd zero with remaining digits
785+
$decimalPart = substr($decimalPart, $leadingZeros - 1);
786+
} else {
787+
// Skip all the leading zeros as they're all paired
788+
$decimalPart = substr($decimalPart, $leadingZeros);
789+
}
790+
791+
// Now pad the decimal part if needed
792+
if (strlen($decimalPart) % 2 !== 0) {
793+
$decimalPart .= '0';
794+
}
795+
796+
// For numbers < 1, we only process the decimal part
797+
$numStr = $decimalPart;
798+
} else {
799+
// For numbers >= 1, process normally
800+
// Pad integer part on the left if odd length
801+
if (strlen($integerPart) % 2 !== 0) {
802+
$integerPart = '0'.$integerPart;
803+
}
804+
805+
// Pad decimal part on the right if odd length
806+
if (strlen($decimalPart) % 2 !== 0) {
807+
$decimalPart .= '0';
808+
}
809+
810+
// Create combined string
811+
$numStr = $integerPart.$decimalPart;
812+
}
813+
814+
// Calculate how many digits the integer part of the result should have
815+
// For numbers >= 1: ceil(n/2) where n is the number of integer digits
816+
// For numbers < 1: 0 (the result will also be < 1)
817+
$integerResultDigits = ($temp[0] === '0') ? 0 : (int) ceil(strlen($temp[0]) / 2);
818+
819+
// Create array of digit pairs
719820
$parts = str_split($numStr, 2);
720821
$parts = array_map('intval', $parts);
822+
721823
$i = 0;
722824
$p = 0; // for the first step, p = 0
723825
$c = $parts[$i];
724826
$result = '';
827+
$digitCount = 0; // Track how many result digits we've generated
828+
725829
while (true) {
726830
// determine the greatest digit x such that x(20p+x) <= c
727831
for ($x = 1; $x <= 10; $x++) {
@@ -732,17 +836,40 @@ public static function sqrt(string $num, ?int $scale = null): string
732836
}
733837
}
734838
$result .= $x;
839+
$digitCount++;
840+
841+
// Add decimal point after we've generated all integer digits
842+
if ($digitCount === $integerResultDigits && $scale > 0) {
843+
$result .= '.';
844+
}
845+
735846
$y = $x * (20 * $p + $x);
736847
$p = 10 * $p + $x;
737848
$c = 100 * ($c - $y);
738849
if (isset($parts[++$i])) {
739850
$c += $parts[$i];
740851
}
741-
if ((!$c && $i >= $decStart) || $i - $decStart === $scale) {
852+
853+
// Check if we should stop
854+
$decimalDigits = $digitCount - $integerResultDigits;
855+
if ((!$c && $digitCount >= $integerResultDigits) || ($decimalDigits >= $scale && $scale >= 0)) {
742856
break;
743857
}
744-
if ($decStart === $i) {
745-
$result .= '.';
858+
}
859+
860+
// For numbers < 1, format the result properly
861+
if ($integerResultDigits === 0) {
862+
// If scale is 0 and result would be < 1, return '0'
863+
if ($scale === 0) {
864+
return '0';
865+
}
866+
867+
if ($leadingZeroPairs > 0) {
868+
// Result should be 0.{leadingZeroPairs zeros}{result}
869+
$result = '0.'.str_repeat('0', $leadingZeroPairs).$result;
870+
} else {
871+
// No leading zeros, but still < 1, so add '0.' prefix
872+
$result = '0.'.$result;
746873
}
747874
}
748875

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

759886
/**
760887
* Round down to the nearest integer.
888+
*
889+
* @throws \ValueError if inputs are not well-formed
761890
*/
762891
public static function floor(string $num): string
763892
{
893+
self::validateNumberString($num, 'bcfloor', 1, 'num');
894+
764895
if (!is_numeric($num)) {
765896
if (version_compare(PHP_VERSION, '8.4', '>=')) {
766897
throw new \ValueError('bcfloor(): Argument #1 ($num) is not well-formed');
@@ -777,7 +908,7 @@ public static function floor(string $num): string
777908
$fractionalPart = substr($num, $dotPos + 1);
778909

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

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

790921
/**
791922
* Round up to the nearest integer.
923+
*
924+
* @throws \ValueError if inputs are not well-formed
792925
*/
793926
public static function ceil(string $num): string
794927
{
928+
self::validateNumberString($num, 'bcceil', 1, 'num');
929+
795930
if (!is_numeric($num)) {
796931
if (version_compare(PHP_VERSION, '8.4', '>=')) {
797932
throw new \ValueError('bcceil(): Argument #1 ($num) is not well-formed');
@@ -808,7 +943,7 @@ public static function ceil(string $num): string
808943
$fractionalPart = substr($num, $dotPos + 1);
809944

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

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

823958
/**
824959
* Round to a given decimal place.
960+
*
961+
* @throws \ValueError if inputs are not well-formed
825962
*/
826963
public static function round(string $num, int $precision = 0, int $mode = PHP_ROUND_HALF_UP): string
827964
{
965+
self::validateNumberString($num, 'bcround', 1, 'num');
966+
828967
if (!is_numeric($num)) {
829968
if (version_compare(PHP_VERSION, '8.4', '>=')) {
830969
throw new \ValueError('bcround(): Argument #1 ($num) is not well-formed');
@@ -862,9 +1001,12 @@ public static function bcroundHelper(string $number, int $precision, int $mode =
8621001

8631002
// Extract sign
8641003
$sign = '';
865-
if ($number[0] === '-') {
1004+
if (self::startsWithNegativeSign($number)) {
8661005
$sign = '-';
867-
$number = substr($number, 1);
1006+
$trimmedNumber = ltrim($number);
1007+
$number = substr($trimmedNumber, 1);
1008+
} else {
1009+
$number = ltrim($number);
8681010
}
8691011

8701012
// Add 0.5 * 10^(-$precision) for rounding (for HALF_UP mode)

0 commit comments

Comments
 (0)