Skip to content
Closed
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 @@ -260,3 +260,18 @@ even if these machines do not share the same set of PHP extensions.

For example, serializing on a machine with GMP support and unserializing on a machine that does not have this extension
installed will still work as expected.

### PHPStan

This library ships with a PHPStan extension that narrows throw types based on argument types.
For example, PHPStan will know that `BigInteger::of(BigInteger)` cannot throw, or that
`toScale()` with a rounding mode other than `Unnecessary` cannot throw `RoundingNecessaryException`.

If you use [`phpstan/extension-installer`](https://github.com/phpstan/extension-installer), the extension is registered automatically.

Otherwise, include `phpstan/extension.neon` in your PHPStan configuration:

```neon
includes:
- vendor/brick/math/phpstan/extension.neon
```
10 changes: 9 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,17 @@
"phpunit/phpunit": "^11.5",
"phpstan/phpstan": "2.1.22"
},
"extra": {
"phpstan": {
"includes": [
"phpstan/extension.neon"
]
}
},
"autoload": {
"psr-4": {
"Brick\\Math\\": "src/"
"Brick\\Math\\": "src/",
"Brick\\Math\\PHPStan\\": "phpstan/"
}
},
"autoload-dev": {
Expand Down
3 changes: 3 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
includes:
- phpstan/extension.neon

parameters:
level: 10
checkUninitializedProperties: true
Expand Down
74 changes: 74 additions & 0 deletions phpstan/BigNumberConversionThrowTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace Brick\Math\PHPStan;

use Brick\Math\BigDecimal;
use Brick\Math\BigInteger;
use Brick\Math\BigNumber;
use Brick\Math\BigRational;
use Brick\Math\Exception\IntegerOverflowException;
use Brick\Math\Exception\RoundingNecessaryException;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\DynamicMethodThrowTypeExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;

/**
* Narrows the throw type of toBigInteger(), toBigDecimal(), toBigRational(), and toInt().
*
* When the caller is already the target type, toBigInteger/toBigDecimal/toBigRational are no-ops and cannot throw.
* When toInt() is called on {@see BigInteger}, only {@see IntegerOverflowException} can be thrown
* (no {@see RoundingNecessaryException}).
*/
final class BigNumberConversionThrowTypeExtension implements DynamicMethodThrowTypeExtension
{
private const METHOD_TO_CLASS = [
'toBigInteger' => BigInteger::class,
'toBigDecimal' => BigDecimal::class,
'toBigRational' => BigRational::class,
];

public function isMethodSupported(MethodReflection $methodReflection): bool
{
$className = $methodReflection->getDeclaringClass()->getName();
$isBigNumber = $className === BigNumber::class
|| $methodReflection->getDeclaringClass()->isSubclassOf(BigNumber::class);

if (! $isBigNumber) {
return false;
}

return isset(self::METHOD_TO_CLASS[$methodReflection->getName()])
|| $methodReflection->getName() === 'toInt';
}

public function getThrowTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope,
): ?Type {
$callerType = $scope->getType($methodCall->var);
$methodName = $methodReflection->getName();

if ($methodName === 'toInt') {
// BigInteger::toInt() can only throw IntegerOverflowException (no RoundingNecessaryException).
if ((new ObjectType(BigInteger::class))->isSuperTypeOf($callerType)->yes()) {
return new ObjectType(IntegerOverflowException::class);
}

return $methodReflection->getThrowType();
}

$targetClass = self::METHOD_TO_CLASS[$methodName];

if ((new ObjectType($targetClass))->isSuperTypeOf($callerType)->yes()) {
return null;
}

return $methodReflection->getThrowType();
}
}
222 changes: 222 additions & 0 deletions phpstan/BigNumberOfThrowTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
<?php

declare(strict_types=1);

namespace Brick\Math\PHPStan;

use Brick\Math\BigDecimal;
use Brick\Math\BigInteger;
use Brick\Math\BigNumber;
use Brick\Math\BigRational;
use Brick\Math\Exception\DivisionByZeroException;
use Brick\Math\Exception\InvalidArgumentException;
use Brick\Math\Exception\RandomSourceException;
use PhpParser\Node\Expr\StaticCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\Accessory\AccessoryNumericStringType;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\DynamicStaticMethodThrowTypeExtension;
use PHPStan\Type\IntegerType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\UnionType;

use function count;
use function in_array;

/**
* Narrows the throw type of {@see BigNumber} static factory methods.
*
* When arguments are already instances of the expected {@see BigNumber} subtype,
* parsing and conversion exceptions cannot occur.
*/
final class BigNumberOfThrowTypeExtension implements DynamicStaticMethodThrowTypeExtension
{
private const SUPPORTED_METHODS = [
BigNumber::class => ['of', 'ofNullable', 'min', 'max', 'sum'],
BigInteger::class => ['of', 'ofNullable', 'gcdAll', 'lcmAll', 'randomRange'],
BigDecimal::class => ['of', 'ofNullable', 'ofUnscaledValue'],
BigRational::class => ['of', 'ofNullable', 'ofFraction'],
];

public function isStaticMethodSupported(MethodReflection $methodReflection): bool
{
$className = $methodReflection->getDeclaringClass()->getName();
$methodName = $methodReflection->getName();

return isset(self::SUPPORTED_METHODS[$className])
&& in_array($methodName, self::SUPPORTED_METHODS[$className], true);
}

public function getThrowTypeFromStaticMethodCall(
MethodReflection $methodReflection,
StaticCall $methodCall,
Scope $scope,
): ?Type {
if (count($methodCall->getArgs()) < 1) {
return $methodReflection->getThrowType();
}

$calledOnClass = $methodReflection->getDeclaringClass()->getName();
$methodName = $methodReflection->getName();

// Methods with residual exceptions after parsing is eliminated.
if ($methodName === 'ofFraction') {
return $this->narrowOfFraction($methodCall, $scope, $methodReflection);
}

if ($methodName === 'randomRange') {
return $this->narrowRandomRange($methodCall, $scope, $methodReflection);
}

$noThrowType = self::getNoThrowType($calledOnClass);

if ($noThrowType === null) {
return $methodReflection->getThrowType();
}

// Check ALL arguments (including variadic) match the required type.
$allMatch = true;

foreach ($methodCall->getArgs() as $arg) {
$argType = $scope->getType($arg->value);

if (! $noThrowType->isSuperTypeOf($argType)->yes()) {
$allMatch = false;

break;
}
}

if ($allMatch) {
return null;
}

// int and numeric-string arguments cannot contain "/", so DivisionByZeroException is impossible.
// This applies to of() and ofNullable() on all classes.
if (in_array($methodName, ['of', 'ofNullable'], true)) {
return $this->narrowNonRationalInput($methodCall, $scope, $methodReflection);
}

return $methodReflection->getThrowType();
}

private function narrowOfFraction(
StaticCall $methodCall,
Scope $scope,
MethodReflection $methodReflection,
): ?Type {
if (count($methodCall->getArgs()) < 2) {
return $methodReflection->getThrowType();
}

// int|BigInteger args cannot cause parsing exceptions in BigInteger::of().
$safeType = TypeCombinator::union(new IntegerType(), new ObjectType(BigInteger::class));

foreach ($methodCall->getArgs() as $arg) {
$argType = $scope->getType($arg->value);

if (! $safeType->isSuperTypeOf($argType)->yes()) {
return $methodReflection->getThrowType();
}
}

// DivisionByZeroException is impossible when the denominator is guaranteed non-zero.
$denominatorType = $scope->getType($methodCall->getArgs()[1]->value);

if (self::isNonZero($denominatorType)) {
return null;
}

return new ObjectType(DivisionByZeroException::class);
}

private function narrowRandomRange(
StaticCall $methodCall,
Scope $scope,
MethodReflection $methodReflection,
): ?Type {
if (count($methodCall->getArgs()) < 2) {
return $methodReflection->getThrowType();
}

$bigIntegerType = new ObjectType(BigInteger::class);

// Only check first two args ($min, $max) — third is the callable.
for ($i = 0; $i < 2; $i++) {
$argType = $scope->getType($methodCall->getArgs()[$i]->value);

if (! $bigIntegerType->isSuperTypeOf($argType)->yes()) {
return $methodReflection->getThrowType();
}
}

// Both args are BigInteger — only InvalidArgumentException + RandomSourceException remain.
return TypeCombinator::union(
new ObjectType(InvalidArgumentException::class),
new ObjectType(RandomSourceException::class),
);
}

/**
* When all arguments are int or numeric-string, the input cannot contain "/",
* so {@see DivisionByZeroException} is impossible.
*/
private function narrowNonRationalInput(
StaticCall $methodCall,
Scope $scope,
MethodReflection $methodReflection,
): ?Type {
$nonRationalType = TypeCombinator::union(
new IntegerType(),
new AccessoryNumericStringType(),
new ObjectType(BigNumber::class),
);

foreach ($methodCall->getArgs() as $arg) {
$argType = $scope->getType($arg->value);

if (! $nonRationalType->isSuperTypeOf($argType)->yes()) {
return $methodReflection->getThrowType();
}
}

$defaultThrowType = $methodReflection->getThrowType();

if ($defaultThrowType === null) {
return null;
}

return TypeCombinator::remove($defaultThrowType, new ObjectType(DivisionByZeroException::class));
}

/**
* Returns the argument type for which the method cannot throw.
*
* int arguments are always safe: _of(int) produces a {@see BigInteger} which converts losslessly to all subtypes.
*
* - {@see BigNumber}::of/min/max/sum(int|{@see BigNumber}) — no parsing, returned as-is.
* - {@see BigInteger}::of/gcdAll/lcmAll(int|{@see BigInteger}) — no parsing, no conversion.
* - {@see BigDecimal}::of/ofUnscaledValue(int|{@see BigInteger}|{@see BigDecimal}) — BigInteger converts losslessly to BigDecimal.
* - {@see BigRational}::of(int|{@see BigNumber}) — all BigNumber types convert losslessly to BigRational.
*/
private static function isNonZero(Type $type): bool
{
return (new ConstantIntegerType(0))->isSuperTypeOf($type)->no();
}

private static function getNoThrowType(string $calledOnClass): ?Type
{
$intType = new IntegerType();

return match ($calledOnClass) {
BigNumber::class => TypeCombinator::union($intType, new ObjectType(BigNumber::class)),
BigInteger::class => TypeCombinator::union($intType, new ObjectType(BigInteger::class)),
BigDecimal::class => TypeCombinator::union($intType, new ObjectType(BigInteger::class), new ObjectType(BigDecimal::class)),
BigRational::class => TypeCombinator::union($intType, new ObjectType(BigNumber::class)),
default => null,
};
}
}
Loading