Inital commit after splitting from main project
PHP Composer / build (push) Successful in 32s

This commit is contained in:
2026-07-26 17:11:40 +02:00
commit f0eea523ec
15 changed files with 6155 additions and 0 deletions
+389
View File
@@ -0,0 +1,389 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Num;
use Tez\Utils\Validate\Guard;
/**
* Arbitrary-precision decimal arithmetic helpers wrapping PHP's bcmath extension.
*
* All methods default to 8 decimal places, which is appropriate for financial
* calculations. Every input must be a numeric string (e.g. "1.50000000").
* Passing a non-numeric string to any method throws \InvalidArgumentException.
*/
final class Bc
{
/**
* Add two arbitrary-precision decimals.
*
* @param numeric-string $a
* @param numeric-string $b
* @throws \Throwable
* @return numeric-string
*/
public static function add(string $a, string $b, int $scale = 8): string
{
return bcadd(
Guard::isNumeric($a),
Guard::isNumeric($b),
$scale,
);
}
/**
* Sum an array of arbitrary-precision decimals.
*
* Returns a correctly scaled zero ('0.00000000') for an empty array.
*
* @param numeric-string[] $operands
* @throws \Throwable
* @return numeric-string
*/
public static function sum(array $operands, int $scale = 8): string
{
if ($operands === []) {
return self::add('0', '0', $scale);
}
return array_reduce(
$operands,
static fn(string $carry, string $item) => self::add($carry, (string) $item, $scale),
'0',
);
}
/**
* Subtract $b from $a.
*
* @param numeric-string $a
* @param numeric-string $b
* @throws \Throwable
* @return numeric-string
*/
public static function sub(string $a, string $b, int $scale = 8): string
{
return bcsub(
Guard::isNumeric($a),
Guard::isNumeric($b),
$scale,
);
}
/**
* Multiply two arbitrary-precision decimals.
*
* @param numeric-string $a
* @param numeric-string $b
* @throws \Throwable
* @return numeric-string
*/
public static function mul(string $a, string $b, int $scale = 8): string
{
return bcmul(
Guard::isNumeric($a),
Guard::isNumeric($b),
$scale,
);
}
/**
* Divide $a by $b.
*
* @param numeric-string $a
* @param numeric-string $b
* @throws \Throwable
* @throws \DivisionByZeroError if $b is zero
* @return numeric-string
*/
public static function div(string $a, string $b, int $scale = 8): string
{
Guard::that(
!self::isZero($b, $scale + 1),
static fn() => new \DivisionByZeroError('Division by zero.'),
);
return bcdiv(Guard::isNumeric($a), $b, $scale);
}
/**
* Normalize a numeric string to exactly $scale decimal places (truncates, does not round).
*
* Example: Bc::scale('1.5', 8) → '1.50000000'
*
* @param numeric-string $value
* @throws \Throwable
* @return numeric-string
*/
public static function scale(string $value, int $scale = 8): string
{
return self::add(
$value,
'0',
$scale,
);
}
/**
* Return the absolute value.
*
* @param numeric-string $value
* @throws \Throwable
* @return numeric-string
*/
public static function abs(string $value, int $scale = 8): string
{
return self::isNegative($value, $scale)
? self::sub('0', $value, $scale)
: self::add($value, '0', $scale);
}
/**
* Return the larger of two values.
*
* @param numeric-string $a
* @param numeric-string $b
* @throws \Throwable
* @return numeric-string
*/
public static function max(string $a, string $b, int $scale = 8): string
{
return self::gte($a, $b, $scale)
? self::add($a, '0', $scale)
: self::add($b, '0', $scale);
}
/**
* Return the smaller of two values.
*
* @param numeric-string $a
* @param numeric-string $b
* @throws \Throwable
* @return numeric-string
*/
public static function min(string $a, string $b, int $scale = 8): string
{
return self::lte($a, $b, $scale)
? self::add($a, '0', $scale)
: self::add($b, '0', $scale);
}
/**
* Compare two values. Returns -1, 0, or 1.
*
* @param numeric-string $a
* @param numeric-string $b
* @throws \Throwable
*/
public static function compare(string $a, string $b, int $scale = 8): int
{
return bccomp(
Guard::isNumeric($a),
Guard::isNumeric($b),
$scale,
);
}
/**
* Return true when the value is exactly zero at the given scale.
*
* @param numeric-string $value
* @throws \Throwable
*/
public static function isZero(string $value, int $scale = 8): bool
{
return 0 === self::compare($value, '0', $scale);
}
/**
* Return true when $a is greater than $b.
*
* @param numeric-string $a
* @param numeric-string $b
* @throws \Throwable
*/
public static function gt(string $a, string $b, int $scale = 8): bool
{
return 1 === self::compare($a, $b, $scale);
}
/**
* Return true when $a is less than $b.
*
* @param numeric-string $a
* @param numeric-string $b
* @throws \Throwable
*/
public static function lt(string $a, string $b, int $scale = 8): bool
{
return -1 === self::compare($a, $b, $scale);
}
/**
* Return true when $a is greater than or equal to $b.
*
* @param numeric-string $a
* @param numeric-string $b
* @throws \Throwable
*/
public static function gte(string $a, string $b, int $scale = 8): bool
{
return self::compare($a, $b, $scale) >= 0;
}
/**
* Return true when $a is less than or equal to $b.
*
* @param numeric-string $a
* @param numeric-string $b
* @throws \Throwable
*/
public static function lte(string $a, string $b, int $scale = 8): bool
{
return self::compare($a, $b, $scale) <= 0;
}
/**
* Return true when $a equals $b at the given scale.
*
* @param numeric-string $a
* @param numeric-string $b
* @throws \Throwable
*/
public static function eq(string $a, string $b, int $scale = 8): bool
{
return 0 === self::compare($a, $b, $scale);
}
/**
* Return true when $value is strictly greater than zero.
*
* @param numeric-string $value
* @throws \Throwable
*/
public static function isPositive(string $value, int $scale = 8): bool
{
return 1 === self::compare($value, '0', $scale);
}
/**
* Return true when $value is strictly less than zero.
*
* @param numeric-string $value
* @throws \Throwable
*/
public static function isNegative(string $value, int $scale = 8): bool
{
return -1 === self::compare($value, '0', $scale);
}
/**
* Negate a value (equivalent to multiplying by -1).
*
* @param numeric-string $value
* @throws \Throwable
* @return numeric-string
*/
public static function negate(string $value, int $scale = 8): string
{
return self::sub(
'0',
$value,
$scale,
);
}
/**
* Calculate $percent % of $value (i.e. value × percent ÷ 100).
*
* Example: Bc::percent('200', '15') → '30.00000000'
*
* @param numeric-string $value
* @param numeric-string $percent
* @throws \Throwable
* @return numeric-string
*/
public static function percent(string $value, string $percent, int $scale = 8): string
{
return self::div(
self::mul(
$value,
$percent,
$scale + 2,
),
'100',
$scale,
);
}
/**
* Multiply an array of arbitrary-precision decimals together.
*
* Returns '1.00000000' for an empty array (neutral element of multiplication).
*
* @param numeric-string[] $operands
* @throws \Throwable
* @return numeric-string
*/
public static function product(array $operands, int $scale = 8): string
{
if ($operands === []) {
return self::add('1', '0', $scale);
}
return array_reduce(
$operands,
static fn(string $carry, string $item) => self::mul($carry, (string) $item, $scale),
'1',
);
}
/**
* Subtract all subsequent operands from the first.
*
* Returns '0.00000000' for an empty array.
*
* @param numeric-string[] $operands
* @throws \Throwable
* @return numeric-string
*/
public static function subAll(array $operands, int $scale = 8): string
{
if ($operands === []) {
return self::add('0', '0', $scale);
}
$first = (string) array_shift($operands);
return array_reduce(
$operands,
static fn(string $carry, string $item) => self::sub($carry, (string) $item, $scale),
$first,
);
}
/**
* Divide the first operand by each subsequent operand in turn.
*
* Returns '1.00000000' for an empty array (neutral element of division).
*
* @param numeric-string[] $operands
* @throws \Throwable
* @return numeric-string
*/
public static function divAll(array $operands, int $scale = 8): string
{
if ($operands === []) {
return self::add('1', '0', $scale);
}
$first = (string) array_shift($operands);
return array_reduce(
$operands,
static fn(string $carry, string $item) => self::div($carry, (string) $item, $scale),
$first,
);
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Tez\Utils\Num;
/**
* Number formatting helpers: decimal, byte sizes, percentages, and rounding.
*/
final class Num
{
private const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
/**
* Format a number with grouped thousands and a configurable decimal separator.
*/
public static function format(
int|float $value,
int $decimals = 2,
string $dec = '.',
string $thousands = ',',
): string {
return number_format((float) $value, $decimals, $dec, $thousands);
}
/**
* Format a byte count as a human-readable string using binary units (1 KB = 1024 B).
*
* @param int<0, max> $bytes
*/
public static function bytes(int $bytes, int $precision = 2): string
{
if ($bytes < 1024) {
return self::format($bytes, 0) . ' B';
}
$exp = (int) floor(log($bytes, 1024));
$exp = min($exp, count(self::BYTE_UNITS) - 1);
$value = $bytes / (1024 ** $exp);
$unit = self::BYTE_UNITS[$exp];
return self::format($value, $precision) . ' ' . $unit;
}
/**
* Format a ratio (0.0–1.0) as a percentage string.
*
* Example: 0.1234 → '12.3%'
*/
public static function percent(int|float $ratio, int $decimals = 1): string
{
return self::format((float) $ratio * 100, $decimals) . '%';
}
/**
* Clamp a value to the inclusive range [$min, $max].
*/
public static function clamp(int|float $value, int|float $min, int|float $max): int|float
{
if ($value < $min) {
return $min;
}
if ($value > $max) {
return $max;
}
return $value;
}
/**
* Round a value to the nearest multiple of $multiple.
*
* Examples: roundTo(17, 5) → 15, roundTo(18, 5) → 20, roundTo(1.7, 0.5) → 1.5
*/
public static function roundTo(int|float $value, int|float $multiple): int|float
{
if ($multiple === 0 || $multiple === 0.0) {
return $value;
}
$rounded = round((float) $value / (float) $multiple) * (float) $multiple;
if (is_int($value) && is_int($multiple)) {
return (int) $rounded;
}
return $rounded;
}
}