Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable changes to this project are documented in this file.

## 8.2.2 - Unreleased

### Fixed

- `$decimals` above 100 throws `InvalidArgumentException` in `Number::format()`, `Number::compact()`, `Number::percentage()`, `Bytes::format()`, `Money::format()` and `Money::compact()`. Very large values used to exhaust memory with a fatal error, or return wrong digits with a locale: `Number::format(1.5, 100000000, false, 'en_US')` returned `"0"`.
- Exception messages quote at most 50 bytes of the value they reject, so a huge input no longer ends up whole in a message or a log.

### Compatibility

- Decimals from 0 to 100 format exactly as before.

## 8.2.1 - 2026-10-11

### Changed
Expand Down
2 changes: 1 addition & 1 deletion MAINTAINING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ One branch per PHP minor, each requiring exactly that minor (`"php": ">=8.N <8.N
- Without a `$locale`, output comes from the built-in English formatter and is identical on every machine, with or without `ext-intl`.
- With a `$locale`, `ext-intl` is used; without it, `MissingIntlExtension` is thrown. Invalid locales never leak `ValueError` or `IntlException`.
- Rounding is half away from zero everywhere, a rounded value carries to the next unit (`999 999 B` is `1 MB`), and no formatter prints negative zero.
- Invalid input throws the native `InvalidArgumentException`.
- Invalid input throws the native `InvalidArgumentException`. `$decimals` accepts 0 to 100, and exception messages quote at most 50 bytes of the rejected value.
- Output changes are behavior changes: they need a changelog entry and must not land in a patch on an existing line unless they fix a bug.

## Package-specific constraints
Expand Down
2 changes: 1 addition & 1 deletion docs/sections/03-basic-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,5 @@ Money::format(1234.5, 'USD', decimals: 0); // "USD 1,235"

- **Rounding** is half away from zero, like `number_format()`: `Number::format(2.5)` is `"3"` and `Number::format(-2.5)` is `"-3"`.
- **No negative zero.** A negative value that rounds to zero prints as zero: `Number::format(-0.001, 2)` is `"0.00"`.
- **Invalid input throws `InvalidArgumentException`:** `INF`, `NAN`, negative decimals, a malformed currency code, an unparsable size, or a date string PHP cannot read.
- **Invalid input throws `InvalidArgumentException`:** `INF`, `NAN`, decimals below 0 or above 100, a malformed currency code, an unparsable size, or a date string PHP cannot read.
- **No state.** There is no global configuration; everything a call needs is in its arguments.
2 changes: 1 addition & 1 deletion docs/sections/11-api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,4 @@ Every method is `public static`. Methods marked *throws* raise `InvalidArgumentE
| Class | Extends | Thrown when |
| --- | --- | --- |
| `Pharaonic\Readable\Exceptions\MissingIntlExtension` | `RuntimeException` | A `$locale` is passed but `ext-intl` isn't loaded. |
| `InvalidArgumentException` (PHP) | `LogicException` | `INF`/`NAN`, negative decimals, `parts` or `limit` below 1, a malformed currency code, an unparsable size or date, invalid UTF-8, or a locale Intl rejects. |
| `InvalidArgumentException` (PHP) | `LogicException` | `INF`/`NAN`, decimals below 0 or above 100, `parts` or `limit` below 1, a malformed currency code, an unparsable size or date, invalid UTF-8, or a locale Intl rejects. |
7 changes: 4 additions & 3 deletions src/Bytes.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use InvalidArgumentException;
use Pharaonic\Readable\Support\Assert;
use Pharaonic\Readable\Support\Excerpt;

/**
* Readable byte sizes.
Expand Down Expand Up @@ -67,7 +68,7 @@ public static function parse(string $size): int
$pattern = '/^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*(?:([kmgtpezy])(i)?b|b|bytes?)?\s*$/i';

if (preg_match($pattern, $size, $match) !== 1) {
throw new InvalidArgumentException(sprintf('Cannot parse "%s" as a byte size.', $size));
throw new InvalidArgumentException(sprintf('Cannot parse %s as a byte size.', Excerpt::quote($size)));
}

$multiplier = 1;
Expand All @@ -85,7 +86,7 @@ public static function parse(string $size): int

// 2^63 is the first float above PHP_INT_MAX.
if ($bytes >= 9.2233720368547758E18 || $bytes < -9.2233720368547758E18) {
throw new InvalidArgumentException(sprintf('"%s" is too large to be represented in bytes.', $size));
throw new InvalidArgumentException(sprintf('%s is too large to be represented in bytes.', Excerpt::quote($size)));
}

return (int) $bytes;
Expand Down Expand Up @@ -117,6 +118,6 @@ private static function multiply(string $number, int|float $multiplier, string $
}
}

throw new InvalidArgumentException(sprintf('"%s" is too large to be represented in bytes.', $size));
throw new InvalidArgumentException(sprintf('%s is too large to be represented in bytes.', Excerpt::quote($size)));
}
}
3 changes: 2 additions & 1 deletion src/Duration.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use DateTimeInterface;
use Exception;
use InvalidArgumentException;
use Pharaonic\Readable\Support\Excerpt;

/**
* Readable durations in English: "1 hour 1 minute", or "1h 1m" in short form.
Expand Down Expand Up @@ -141,7 +142,7 @@ private static function date(DateTimeInterface|int|string $value): DateTimeInter
try {
return new DateTimeImmutable($value);
} catch (Exception $exception) {
throw new InvalidArgumentException(sprintf('"%s" is not a valid date.', $value), 0, $exception);
throw new InvalidArgumentException(sprintf('%s is not a valid date.', Excerpt::quote($value)), 0, $exception);
}
}
}
5 changes: 3 additions & 2 deletions src/Exceptions/MissingIntlExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Pharaonic\Readable\Exceptions;

use Pharaonic\Readable\Support\Excerpt;
use RuntimeException;

/**
Expand All @@ -14,9 +15,9 @@ final class MissingIntlExtension extends RuntimeException
public static function forLocale(string $locale): self
{
return new self(sprintf(
'Formatting for locale "%s" requires the intl extension. '
'Formatting for locale %s requires the intl extension. '
. 'Install ext-intl or omit the locale to use the built-in English formatter.',
$locale,
Excerpt::quote($locale),
));
}
}
3 changes: 2 additions & 1 deletion src/Money.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use InvalidArgumentException;
use Pharaonic\Readable\Support\Assert;
use Pharaonic\Readable\Support\Excerpt;
use Pharaonic\Readable\Support\Intl;

/**
Expand Down Expand Up @@ -89,7 +90,7 @@ private static function currency(string $currency): string

if (preg_match('/^[A-Z]{3}$/', $code) !== 1) {
throw new InvalidArgumentException(
sprintf('"%s" is not a three-letter ISO 4217 currency code.', $currency),
sprintf('%s is not a three-letter ISO 4217 currency code.', Excerpt::quote($currency)),
);
}

Expand Down
11 changes: 9 additions & 2 deletions src/Support/Assert.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
*/
final class Assert
{
/**
* Far beyond any display need. Larger values exhaust memory in number_format() or make Intl return wrong digits.
*/
private const MAX_DECIMALS = 100;

public static function finite(int|float $number): void
{
if (is_float($number) && !is_finite($number)) {
Expand All @@ -20,8 +25,10 @@ public static function finite(int|float $number): void

public static function decimals(int $decimals): void
{
if ($decimals < 0) {
throw new InvalidArgumentException(sprintf('Decimals must be zero or greater, %d given.', $decimals));
if ($decimals < 0 || $decimals > self::MAX_DECIMALS) {
throw new InvalidArgumentException(
sprintf('Decimals must be between 0 and %d, %d given.', self::MAX_DECIMALS, $decimals),
);
}
}
}
26 changes: 26 additions & 0 deletions src/Support/Excerpt.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Pharaonic\Readable\Support;

/**
* @internal
*/
final class Excerpt
{
private const MAX_BYTES = 50;

/**
* Quote a caller-supplied value for an exception message, cut to a few dozen bytes so a huge input
* never ends up whole in a message or a log.
*/
public static function quote(string $value): string
{
if (strlen($value) <= self::MAX_BYTES) {
return '"' . $value . '"';
}

return '"' . mb_strcut($value, 0, self::MAX_BYTES, 'UTF-8') . '…"';
}
}
2 changes: 1 addition & 1 deletion src/Support/Intl.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public static function formatter(string $locale, string $style): NumberFormatter
try {
$formatter = new NumberFormatter($locale, $style);
} catch (ValueError|IntlException $exception) {
throw new InvalidArgumentException(sprintf('"%s" is not a valid locale.', $locale), 0, $exception);
throw new InvalidArgumentException(sprintf('%s is not a valid locale.', Excerpt::quote($locale)), 0, $exception);
}
$formatter->setAttribute(NumberFormatter::ROUNDING_MODE, NumberFormatter::ROUND_HALFUP);

Expand Down
11 changes: 11 additions & 0 deletions tests/Pest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,14 @@ function withoutIntl(): bool
{
return ! extension_loaded('intl');
}

function thrownBy(callable $callback): Throwable
{
try {
$callback();
} catch (Throwable $exception) {
return $exception;
}

throw new LogicException('Nothing was thrown.');
}
25 changes: 25 additions & 0 deletions tests/Unit/BytesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,28 @@
'one yotta overflows' => ['1 YB'],
'huge' => ['1000 YB'],
])->throws(InvalidArgumentException::class);

it('rejects more than 100 decimals', function () {
Bytes::format(1500, 101);
})->throws(InvalidArgumentException::class, 'Decimals must be between 0 and 100, 101 given.');

it('quotes a short unparsable size whole in the error message', function () {
expect(thrownBy(fn() => Bytes::parse('10 K'))->getMessage())->toBe('Cannot parse "10 K" as a byte size.');
});

it('cuts a long unparsable size in the error message', function () {
expect(thrownBy(fn() => Bytes::parse(str_repeat('x', 1000000)))->getMessage())
->toBe('Cannot parse "' . str_repeat('x', 50) . '…" as a byte size.');
});

it('cuts a long oversized size in the error message', function () {
expect(thrownBy(fn() => Bytes::parse(str_repeat('9', 1000)))->getMessage())
->toBe('"' . str_repeat('9', 50) . '…" is too large to be represented in bytes.');
});

it('never cuts a multibyte character in the error message', function () {
$message = thrownBy(fn() => Bytes::parse('x' . str_repeat('é', 100)))->getMessage();

expect(mb_check_encoding($message, 'UTF-8'))->toBeTrue()
->and($message)->toBe('Cannot parse "x' . str_repeat('é', 24) . '…" as a byte size.');
});
5 changes: 5 additions & 0 deletions tests/Unit/DurationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,8 @@
it('rejects an invalid date', function () {
Duration::between('not a date', 'now');
})->throws(InvalidArgumentException::class);

it('cuts a long invalid date in the error message', function () {
expect(thrownBy(fn() => Duration::between(str_repeat('x', 1000000), 'now'))->getMessage())
->toBe('"' . str_repeat('x', 50) . '…" is not a valid date.');
});
12 changes: 12 additions & 0 deletions tests/Unit/MoneyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,15 @@
'CLF' => ['CLF', 4],
'lowercase' => ['jpy', 0],
]);

it('rejects more than 100 decimals', function (string $method, array $arguments) {
Money::$method(...$arguments);
})->with([
'format' => ['format', [1, 'USD', 101]],
'compact' => ['compact', [1, 'USD', 101]],
])->throws(InvalidArgumentException::class, 'Decimals must be between 0 and 100, 101 given.');

it('cuts a long currency code in the error message', function () {
expect(thrownBy(fn() => Money::format(1, str_repeat('X', 1000)))->getMessage())
->toBe('"' . str_repeat('X', 50) . '…" is not a three-letter ISO 4217 currency code.');
});
37 changes: 37 additions & 0 deletions tests/Unit/NumberTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,40 @@
it('needs ext-intl to spell a number with a locale', function () {
Number::spell(1, 'en');
})->skip(! withoutIntl(), 'ext-intl is installed.')->throws(MissingIntlExtension::class);

it('accepts up to 100 decimals', function () {
expect(Number::format(1.5, 100))->toBe('1.5' . str_repeat('0', 99))
->and(Number::percentage(0.5, 100))->toBe('50.' . str_repeat('0', 100) . '%')
->and(Number::compact(1500, 100))->toBe('1.5K');
});

it('rejects more than 100 decimals', function (string $method, array $arguments) {
Number::$method(...$arguments);
})->with([
'format' => ['format', [1.5, 101]],
'compact' => ['compact', [1.5, 101]],
'percentage' => ['percentage', [0.5, 101]],
'int max' => ['format', [1.5, PHP_INT_MAX]],
])->throws(InvalidArgumentException::class, 'Decimals must be between 0 and 100');

it('rejects more than 100 decimals with a locale', function () {
Number::format(1.5, 101, false, 'en_US');
})->skip(withoutIntl(), 'ext-intl is not installed.')->throws(InvalidArgumentException::class);

it('cuts a long invalid locale in the error message', function () {
$exception = thrownBy(fn() => Number::format(1, 0, false, str_repeat('x', 10000)));

// Older ICU builds accept any locale and fall back to the root locale.
if ($exception instanceof LogicException && $exception->getMessage() === 'Nothing was thrown.') {
expect(true)->toBeTrue();

return;
}

expect($exception->getMessage())->toBe('"' . str_repeat('x', 50) . '…" is not a valid locale.');
})->skip(withoutIntl(), 'ext-intl is not installed.');

it('cuts a long locale in the missing intl message', function () {
expect(thrownBy(fn() => Number::format(1, 0, false, str_repeat('x', 10000)))->getMessage())
->toStartWith('Formatting for locale "' . str_repeat('x', 50) . '…" requires the intl extension.');
})->skip(! withoutIntl(), 'ext-intl is installed.');
Loading