Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ BigRational::of('1.15'); // 23/20 (reduced to lowest terms)
> BigDecimal::fromFloatShortest(0.1); // 0.1
> ```

> [!CAUTION]
> The `of()` factory method is for trusted input: a string as short as `1e1000000000` can expand to gigabytes of
> memory and exceed PHP's memory limit.
>
> For untrusted user input, use `parse()` instead:
>
> ```php
> BigDecimal::parse('1000000000000000000000', allowedSyntax: NumberSyntax::SCIENTIFIC, maxDigits: 100); // OK
> BigDecimal::parse('1e1000000000', allowedSyntax: NumberSyntax::SCIENTIFIC, maxDigits: 100); // NumberFormatException
> ```

#### Immutability & chaining

The `BigInteger`, `BigDecimal` and `BigRational` classes are immutable: their value never changes,
Expand Down Expand Up @@ -148,6 +159,10 @@ echo BigInteger::of(2)->multipliedBy(BigDecimal::of('2.5')); // RoundingNecessar
echo BigDecimal::of(2.5)->multipliedBy(BigInteger::of(2)); // 5.0
```

> [!CAUTION]
> These parameters are converted with `of()`, so the same caution applies: never pass an untrusted string
> directly to an arithmetic or comparison method — `parse()` it first, and pass the resulting number.

#### Division & rounding

##### BigInteger
Expand Down
265 changes: 209 additions & 56 deletions src/BigNumber.php

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions src/Exception/InvalidArgumentException.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,14 @@ public static function nonPositiveNthRootDegree(): self
{
return new self('The degree of an nth root must be a positive integer.');
}

/**
* @internal
*
* @pure
*/
public static function nonPositiveMaxDigits(): self
{
return new self('The maximum number of digits must be a positive integer.');
}
}
87 changes: 71 additions & 16 deletions src/Exception/NumberFormatException.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@

namespace Brick\Math\Exception;

use Brick\Math\NumberSyntax;
use RuntimeException;

use function dechex;
use function ord;
use function sprintf;
use function strtoupper;
use function strlen;
use function substr;

/**
* Exception thrown when attempting to create a number from a string with an invalid format.
Expand All @@ -35,7 +36,7 @@ public static function invalidFormat(string $value): self
{
return new self(sprintf(
'Value "%s" does not represent a valid number.',
$value,
self::truncateAndEscape($value),
));
}

Expand All @@ -49,8 +50,8 @@ public static function invalidFormat(string $value): self
public static function charNotInAlphabet(string $char): self
{
return new self(sprintf(
'Character %s is not valid in the given alphabet.',
self::charToString($char),
'Character "%s" is not valid in the given alphabet.',
self::escapeChar($char),
));
}

Expand All @@ -62,8 +63,8 @@ public static function charNotInAlphabet(string $char): self
public static function charNotValidInBase(string $char, int $base): self
{
return new self(sprintf(
'Character %s is not valid in base %d.',
self::charToString($char),
'Character "%s" is not valid in base %d.',
self::escapeChar($char),
$base,
));
}
Expand Down Expand Up @@ -99,22 +100,76 @@ public static function exponentTooLarge(): self
}

/**
* @internal
*
* @pure
*/
private static function charToString(string $char): string
public static function tooManyDigits(int $maxDigits): self
{
$ord = ord($char);
return new self(sprintf(
'The number exceeds the maximum number of %d digits.',
$maxDigits,
));
}

if ($ord < 32 || $ord > 126) {
$char = strtoupper(dechex($ord));
/**
* @internal
*
* @pure
*/
public static function syntaxNotAllowed(NumberSyntax $syntax): self
{
return new self(sprintf('The %s syntax is not allowed.', match ($syntax) {
NumberSyntax::DecimalPoint => 'decimal point',
NumberSyntax::Exponent => 'exponent',
NumberSyntax::Fraction => 'fraction',
}));
}

if ($ord < 16) {
$char = '0' . $char;
}
/**
* @internal
*
* @pure
*/
public static function zeroDenominator(): self
{
return new self('The denominator of a rational number must not be zero.');
}

/**
* @pure
*/
private static function truncateAndEscape(string $value): string
{
if (strlen($value) > 40) {
$value = substr($value, 0, 40) . '...';
}

$escaped = '';
$length = strlen($value);

return '0x' . $char;
for ($i = 0; $i < $length; $i++) {
$escaped .= self::escapeChar($value[$i]);
}

return '"' . $char . '"';
return $escaped;
}

/**
* @pure
*/
private static function escapeChar(string $char): string
{
$ord = ord($char);

return match (true) {
$char === "\t" => '\t',
$char === "\n" => '\n',
$char === "\r" => '\r',
$char === '\\' => '\\\\',
$char === '"' => '\"',
$ord < 32 || $ord > 126 => sprintf('\x%02X', $ord),
default => $char,
};
}
}
80 changes: 80 additions & 0 deletions src/NumberSyntax.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace Brick\Math;

/**
* A syntax feature that {@see BigNumber::parse()} can accept.
*
* Plain signed integers such as `123` and `-7` are the base language: they are always accepted.
* Each case allows one additional feature:
*
* - DecimalPoint: `.`
* - Exponent: `e` or `E`
* - Fraction: `/`
*
* In addition to its cases, this enum provides list constants for the most common combinations:
*
* - INTEGER
* - DECIMAL
* - SCIENTIFIC
* - etc.
*/
enum NumberSyntax
{
/**
* Allows the decimal point: `1.5`, `.5`, `1.`.
*/
case DecimalPoint;

/**
* Allows the exponent: `5e3`, `15E-2`.
*/
case Exponent;

/**
* Allows the fraction form: `2/4`. The numerator and denominator are unsigned integers; an optional sign
* precedes the whole fraction.
*/
case Fraction;

/**
* Integers only: `123`.
* The base language, with no additional notation.
*/
public const INTEGER = [];

/**
* Integers and decimal numbers: `123`, `123.45`.
* Typical for monetary input.
*/
public const DECIMAL = [
self::DecimalPoint,
];

/**
* Integers and decimal numbers, with exponents: `123`, `123.45`, `1.5e-3`.
* Accepts every JSON number.
*/
public const SCIENTIFIC = [
self::DecimalPoint,
self::Exponent,
];

/**
* Integers and fractions: `123`, `22/7`.
*/
public const RATIONAL = [
self::Fraction,
];

/**
* The full syntax accepted by {@see BigNumber::of()}: `123`, `123.45`, `1.5e-3`, `22/7`.
*/
public const ALL = [
self::DecimalPoint,
self::Exponent,
self::Fraction,
];
}
45 changes: 39 additions & 6 deletions tests/BigDecimalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Brick\Math\Exception\NegativeNumberException;
use Brick\Math\Exception\NumberFormatException;
use Brick\Math\Exception\RoundingNecessaryException;
use Brick\Math\NumberSyntax;
use Brick\Math\RoundingMode;
use Generator;
use LogicException;
Expand Down Expand Up @@ -55,7 +56,7 @@ public function testOf(int|string $value, string $expected): void
* @param string $expected The expected decimal value.
*/
#[DataProvider('providerOf')]
public function testOfNullableWithValidInputBehavesLikeOf(int|string $value, string $expected): void
public function testOfNullableWithNonNullInput(int|string $value, string $expected): void
{
$result = BigDecimal::ofNullable($value);

Expand Down Expand Up @@ -226,11 +227,15 @@ public function testOfEmptyStringThrowsException(): void
BigDecimal::of('');
}

/**
* @param string $value The invalid value.
* @param string|null $expectedValueInMessage The value as rendered in the message, if it differs from $value.
*/
#[DataProvider('providerOfInvalidFormatThrowsException')]
public function testOfInvalidFormatThrowsException(string $value): void
public function testOfInvalidFormatThrowsException(string $value, ?string $expectedValueInMessage = null): void
{
$this->expectException(NumberFormatException::class);
$this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', $value));
$this->expectExceptionMessageExact(sprintf('Value "%s" does not represent a valid number.', $expectedValueInMessage ?? $value));

BigDecimal::of($value);
}
Expand All @@ -241,9 +246,9 @@ public static function providerOfInvalidFormatThrowsException(): array
['a'],
[' 1'],
['1 '],
["\n1.2"],
["1.2\n"],
["1e2\n"],
["\n1.2", '\n1.2'],
["1.2\n", '1.2\n'],
["1e2\n", '1e2\n'],
['..1'],
['1..'],
['.1.'],
Expand Down Expand Up @@ -293,6 +298,34 @@ public function testOfBigDecimalReturnsThis(): void
self::assertSame($decimal, BigDecimal::of($decimal));
}

public function testParseConvertibleValue(): void
{
// 2 digits as parsed, although the converted result has 3
self::assertBigDecimalEquals('0.25', BigDecimal::parse('1/4', NumberSyntax::RATIONAL, 2));
}

public function testParseNonConvertibleValueThrowsException(): void
{
$this->expectException(RoundingNecessaryException::class);
$this->expectExceptionMessageExact('This rational number has a non-terminating decimal expansion and cannot be represented as a decimal without rounding.');

BigDecimal::parse('1/3', allowedSyntax: NumberSyntax::RATIONAL, maxDigits: 2);
}

public function testParseNullableConvertibleValue(): void
{
// 2 digits as parsed, although the converted result has 4
self::assertBigDecimalEquals('0.125', BigDecimal::parseNullable('1/8', NumberSyntax::RATIONAL, 2));
}

public function testParseNullableNonConvertibleValueThrowsException(): void
{
$this->expectException(RoundingNecessaryException::class);
$this->expectExceptionMessageExact('This rational number has a non-terminating decimal expansion and cannot be represented as a decimal without rounding.');

BigDecimal::parseNullable('1/7', allowedSyntax: NumberSyntax::RATIONAL, maxDigits: 2);
}

/**
* @param int|string $unscaledValue The unscaled value of the BigDecimal to create.
* @param int $scale The scale of the BigDecimal to create.
Expand Down
Loading
Loading