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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,21 @@ 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
> ```
>
> `parse()` counts every digit as written, so no amount of leading zeros can pad an input into being accepted:
> an accepted value is never longer than `maxDigits` plus a few punctuation characters, and the input length does
> not need to be validated separately.

#### Immutability & chaining

The `BigInteger`, `BigDecimal` and `BigRational` classes are immutable: their value never changes,
Expand Down Expand Up @@ -148,6 +163,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.');
}
}
130 changes: 118 additions & 12 deletions src/Exception/NumberFormatException.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,32 @@

namespace Brick\Math\Exception;

use Brick\Math\NumberSyntax;
use RuntimeException;

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

/**
* Exception thrown when attempting to create a number from a string with an invalid format.
*/
final class NumberFormatException extends RuntimeException implements MathException
{
/**
* Invisible characters commonly found in copy-pasted numbers, escaped even in valid UTF-8:
* kept as-is, they would misleadingly look valid in the message.
*/
private const INVISIBLE_CHAR_ESCAPES = [
"\u{00A0}" => '\u{00A0}', // no-break space
"\u{202F}" => '\u{202F}', // narrow no-break space
"\u{200B}" => '\u{200B}', // zero-width space
"\u{FEFF}" => '\u{FEFF}', // zero-width no-break space (BOM)
];

/**
* @internal
*
Expand All @@ -34,8 +48,8 @@ public function __construct(string $message)
public static function invalidFormat(string $value): self
{
return new self(sprintf(
'Value "%s" does not represent a valid number.',
$value,
'Value %s does not represent a valid number.',
self::valueToString($value),
));
}

Expand Down Expand Up @@ -99,22 +113,114 @@ 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,
));
}

/**
* @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 < 32 || $ord > 126) {
$char = strtoupper(dechex($ord));
/**
* @internal
*
* @pure
*/
public static function zeroDenominator(): self
{
return new self('The denominator of a rational number must not be zero.');
}

/**
* Renders a value in a form safe to embed in an exception message: the value is truncated, printable
* ASCII and valid UTF-8 text are kept, and everything else is escaped: `\t`, `\n` and `\r` for the
* common whitespace controls, `\\` and `\"` for the backslash and the double quote, `\xHH` for other
* bytes, and `\u{XXXX}` for a few invisible characters commonly found in copy-pasted numbers, which
* would misleadingly look valid if kept. When the value is not valid UTF-8, every non-ASCII byte is
* escaped.
*
* @pure
*/
private static function valueToString(string $value): string
{
if (strlen($value) > 40) {
$value = substr($value, 0, 40);

if ($ord < 16) {
$char = '0' . $char;
// If the cut falls inside a multibyte sequence, drop that sequence's leading bytes as well.
for ($i = 0; $i < 3 && ! self::isUtf8($value); $i++) {
$value = substr($value, 0, -1);
}

return '0x' . $char;
$value .= '...';
}

$isUtf8 = self::isUtf8($value);

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

for ($i = 0; $i < $length; $i++) {
$char = $value[$i];

$escaped .= $isUtf8 && ord($char) >= 0x80 ? $char : self::escapeChar($char);
}

return '"' . $char . '"';
return '"' . strtr($escaped, self::INVISIBLE_CHAR_ESCAPES) . '"';
}

/**
* @param string $char The failing character.
*
* @pure
*/
private static function charToString(string $char): string
{
return '"' . self::escapeChar($char) . '"';
}

/**
* @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,
};
}

/**
* An empty pattern with the /u modifier matches if, and only if, the subject is valid UTF-8.
*
* @pure
*/
private static function isUtf8(string $value): bool
{
return preg_match('//u', $value) === 1;
}
}
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,
];
}
Loading
Loading