diff --git a/README.md b/README.md index 860685a..1c6fee3 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/composer.json b/composer.json index bcacf9b..1a6c032 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/phpstan.neon b/phpstan.neon index 1b476c8..4c3d810 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,3 +1,6 @@ +includes: + - phpstan/extension.neon + parameters: level: 10 checkUninitializedProperties: true diff --git a/phpstan/BigNumberConversionThrowTypeExtension.php b/phpstan/BigNumberConversionThrowTypeExtension.php new file mode 100644 index 0000000..aead28c --- /dev/null +++ b/phpstan/BigNumberConversionThrowTypeExtension.php @@ -0,0 +1,74 @@ + 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(); + } +} diff --git a/phpstan/BigNumberOfThrowTypeExtension.php b/phpstan/BigNumberOfThrowTypeExtension.php new file mode 100644 index 0000000..a4458ae --- /dev/null +++ b/phpstan/BigNumberOfThrowTypeExtension.php @@ -0,0 +1,222 @@ + ['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, + }; + } +} diff --git a/phpstan/BigNumberOperationThrowTypeExtension.php b/phpstan/BigNumberOperationThrowTypeExtension.php new file mode 100644 index 0000000..63350e5 --- /dev/null +++ b/phpstan/BigNumberOperationThrowTypeExtension.php @@ -0,0 +1,306 @@ + [ + 'plus', + 'minus', + 'multipliedBy', + 'gcd', + 'lcm', + 'and', + 'or', + 'xor', + ], + BigDecimal::class => [ + 'plus', + 'minus', + 'multipliedBy', + ], + BigRational::class => [ + 'plus', + 'minus', + 'multipliedBy', + ], + BigNumber::class => [ + 'isEqualTo', + 'isLessThan', + 'isLessThanOrEqualTo', + 'isGreaterThan', + 'isGreaterThanOrEqualTo', + 'compareTo', + ], + ]; + + /** + * Methods that throw because of argument parsing AND have inherent exceptions. + * When the argument is the right type, only the inherent exceptions remain. + * + * class => [method => [residual exception classes]] + * + * @var array>>> + */ + private const RESIDUAL_THROW_METHODS = [ + BigInteger::class => [ + 'quotient' => [DivisionByZeroException::class], + 'remainder' => [DivisionByZeroException::class], + 'quotientAndRemainder' => [DivisionByZeroException::class], + 'mod' => [DivisionByZeroException::class, InvalidArgumentException::class], + 'modInverse' => [DivisionByZeroException::class, InvalidArgumentException::class, NoInverseException::class], + 'modPow' => [DivisionByZeroException::class, InvalidArgumentException::class], + ], + BigDecimal::class => [ + 'quotient' => [DivisionByZeroException::class], + 'remainder' => [DivisionByZeroException::class], + 'quotientAndRemainder' => [DivisionByZeroException::class], + 'dividedByExact' => [DivisionByZeroException::class, RoundingNecessaryException::class], + ], + BigRational::class => [ + 'dividedBy' => [DivisionByZeroException::class], + ], + BigNumber::class => [ + 'clamp' => [InvalidArgumentException::class], + ], + ]; + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + $className = $methodReflection->getDeclaringClass()->getName(); + $methodName = $methodReflection->getName(); + + // Check class-specific no-throw methods. + if (isset(self::NO_THROW_METHODS[$className]) && in_array($methodName, self::NO_THROW_METHODS[$className], true)) { + return true; + } + + // Check class-specific residual-throw methods. + if (isset(self::RESIDUAL_THROW_METHODS[$className][$methodName])) { + return true; + } + + // BigNumber comparison/clamp methods match any BigNumber subclass. + if ($methodReflection->getDeclaringClass()->isSubclassOf(BigNumber::class)) { + if (in_array($methodName, self::NO_THROW_METHODS[BigNumber::class], true)) { + return true; + } + + if (isset(self::RESIDUAL_THROW_METHODS[BigNumber::class][$methodName])) { + return true; + } + } + + return false; + } + + public function getThrowTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + if ($methodCall->getArgs() === []) { + return $methodReflection->getThrowType(); + } + + $className = $methodReflection->getDeclaringClass()->getName(); + $methodName = $methodReflection->getName(); + + // Check residual-throw methods first (O(1) lookup). + if (isset(self::RESIDUAL_THROW_METHODS[$className][$methodName])) { + return $this->narrowResidual($methodCall, $scope, $methodReflection); + } + + // Everything else is a no-throw method. + return $this->narrowNoThrow($methodCall, $scope, $methodReflection); + } + + private function narrowNoThrow( + MethodCall $methodCall, + Scope $scope, + MethodReflection $methodReflection, + ): ?Type { + $argType = $scope->getType($methodCall->getArgs()[0]->value); + $methodName = $methodReflection->getName(); + + // For methods declared on BigNumber (compareTo, isEqualTo, etc.), + // the argument just needs to be any BigNumber or int to avoid parsing exceptions. + if (in_array($methodName, self::NO_THROW_METHODS[BigNumber::class], true)) { + $safeType = TypeCombinator::union(new IntegerType(), new ObjectType(BigNumber::class)); + + if ($safeType->isSuperTypeOf($argType)->yes()) { + return null; + } + + return $methodReflection->getThrowType(); + } + + // For subclass-specific methods (plus, minus, etc.), + // the argument must be the same type (or convertible without loss). + $className = $methodReflection->getDeclaringClass()->getName(); + $requiredType = self::getRequiredArgType($className); + + if ($requiredType !== null && $requiredType->isSuperTypeOf($argType)->yes()) { + return null; + } + + return $methodReflection->getThrowType(); + } + + private function narrowResidual( + MethodCall $methodCall, + Scope $scope, + MethodReflection $methodReflection, + ): ?Type { + $className = $methodReflection->getDeclaringClass()->getName(); + $methodName = $methodReflection->getName(); + + // For clamp(), check both $min and $max args. + if ($methodName === 'clamp') { + return $this->narrowClamp($methodCall, $scope, $methodReflection); + } + + $argType = $scope->getType($methodCall->getArgs()[0]->value); + $requiredType = self::getRequiredArgType($className); + + if ($requiredType === null || ! $requiredType->isSuperTypeOf($argType)->yes()) { + return $methodReflection->getThrowType(); + } + + // modPow has two BigNumber|int|string args. + if ($methodName === 'modPow' && isset($methodCall->getArgs()[1])) { + $arg2Type = $scope->getType($methodCall->getArgs()[1]->value); + + if (! $requiredType->isSuperTypeOf($arg2Type)->yes()) { + return $methodReflection->getThrowType(); + } + } + + $residual = self::RESIDUAL_THROW_METHODS[$className][$methodName]; + + // DivisionByZeroException is impossible when the divisor is guaranteed non-zero. + if (self::isNonZero($argType)) { + $residual = array_values(array_filter( + $residual, + static fn (string $e): bool => $e !== DivisionByZeroException::class, + )); + } + + if ($residual === []) { + return null; + } + + return self::buildExceptionUnion($residual); + } + + private function narrowClamp( + MethodCall $methodCall, + Scope $scope, + MethodReflection $methodReflection, + ): ?Type { + $args = $methodCall->getArgs(); + + if (! isset($args[0], $args[1])) { + return $methodReflection->getThrowType(); + } + + // clamp() is on BigNumber, so we need the caller's type to determine conversion requirements. + $callerType = $scope->getType($methodCall->var); + $requiredType = self::getRequiredArgTypeForCallerType($callerType); + + if ($requiredType === null) { + return $methodReflection->getThrowType(); + } + + $minType = $scope->getType($args[0]->value); + $maxType = $scope->getType($args[1]->value); + + if ($requiredType->isSuperTypeOf($minType)->yes() && $requiredType->isSuperTypeOf($maxType)->yes()) { + return new ObjectType(InvalidArgumentException::class); + } + + return $methodReflection->getThrowType(); + } + + private static function getRequiredArgType(string $className): ?Type + { + // int is always safe: it converts losslessly to any BigNumber subtype. + // BigInteger methods require int|BigInteger. + // BigDecimal methods accept int|BigInteger|BigDecimal (BigInteger converts losslessly). + // BigRational methods accept int|BigNumber (all convert losslessly). + $intType = new IntegerType(); + + return match ($className) { + 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, + }; + } + + private static function getRequiredArgTypeForCallerType(Type $callerType): ?Type + { + foreach ([BigInteger::class, BigDecimal::class, BigRational::class] as $class) { + if ((new ObjectType($class))->isSuperTypeOf($callerType)->yes()) { + return self::getRequiredArgType($class); + } + } + + return null; + } + + private static function isNonZero(Type $type): bool + { + return (new ConstantIntegerType(0))->isSuperTypeOf($type)->no(); + } + + /** + * @param list> $exceptionClasses + */ + private static function buildExceptionUnion(array $exceptionClasses): Type + { + $types = []; + + foreach ($exceptionClasses as $exceptionClass) { + $types[] = new ObjectType($exceptionClass); + } + + return TypeCombinator::union(...$types); + } +} diff --git a/phpstan/RoundingModeThrowTypeExtension.php b/phpstan/RoundingModeThrowTypeExtension.php new file mode 100644 index 0000000..b8db070 --- /dev/null +++ b/phpstan/RoundingModeThrowTypeExtension.php @@ -0,0 +1,223 @@ + method => [rounding mode arg index, residual exception classes]. + * + * @var array>}>> + */ + private const METHODS = [ + BigInteger::class => [ + 'dividedBy' => [1, [MathException::class, DivisionByZeroException::class]], + 'sqrt' => [0, [NegativeNumberException::class]], + ], + BigDecimal::class => [ + 'dividedBy' => [2, [MathException::class, DivisionByZeroException::class]], + 'sqrt' => [1, [NegativeNumberException::class]], + ], + ]; + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + $className = $methodReflection->getDeclaringClass()->getName(); + $methodName = $methodReflection->getName(); + + if ($methodName === 'toScale') { + return $className === BigNumber::class + || $className === BigInteger::class + || $className === BigDecimal::class + || $className === BigRational::class; + } + + return isset(self::METHODS[$className][$methodName]); + } + + public function getThrowTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): ?Type { + $methodName = $methodReflection->getName(); + + if ($methodName === 'toScale') { + return $this->narrowToScale($methodCall, $scope, $methodReflection); + } + + $className = $methodReflection->getDeclaringClass()->getName(); + $config = self::METHODS[$className][$methodName] ?? null; + + if ($config === null) { + return $methodReflection->getThrowType(); + } + + [$roundingModeArgIndex, $residualExceptions] = $config; + + return $this->narrowByRoundingMode($methodCall, $scope, $methodReflection, $roundingModeArgIndex, $residualExceptions); + } + + /** + * toScale() has two independent narrowing axes: + * - scale is non-negative → removes {@see InvalidArgumentException} + * - rounding mode is not Unnecessary → removes {@see RoundingNecessaryException} + */ + private function narrowToScale( + MethodCall $methodCall, + Scope $scope, + MethodReflection $methodReflection, + ): ?Type { + $args = $methodCall->getArgs(); + + $scaleIsNonNegative = isset($args[0]) + && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($scope->getType($args[0]->value))->yes(); + + $roundingIsNotUnnecessary = isset($args[1]) + && self::isNotUnnecessary($scope->getType($args[1]->value)); + + if ($scaleIsNonNegative && $roundingIsNotUnnecessary) { + return null; + } + + $residual = []; + + if (! $scaleIsNonNegative) { + $residual[] = new ObjectType(InvalidArgumentException::class); + } + + if (! $roundingIsNotUnnecessary) { + $residual[] = new ObjectType(RoundingNecessaryException::class); + } + + return TypeCombinator::union(...$residual); + } + + /** + * @param list> $residualExceptions + */ + private function narrowByRoundingMode( + MethodCall $methodCall, + Scope $scope, + MethodReflection $methodReflection, + int $roundingModeArgIndex, + array $residualExceptions, + ): ?Type { + $args = $methodCall->getArgs(); + + $roundingIsNotUnnecessary = isset($args[$roundingModeArgIndex]) + && self::isNotUnnecessary($scope->getType($args[$roundingModeArgIndex]->value)); + + // For dividedBy(), MathException is only thrown when parsing the divisor argument. + // When the divisor is already the right type (or int), MathException is impossible. + $argType = isset($args[0]) ? $scope->getType($args[0]->value) : null; + $argIsSafe = $argType !== null && $this->isDivisorArgSafe($methodReflection, $argType); + + // DivisionByZeroException is impossible when the divisor is a non-zero int. + $divisorIsNonZero = $argType !== null && self::isNonZero($argType); + + if (! $roundingIsNotUnnecessary && ! $argIsSafe && ! $divisorIsNonZero) { + return $methodReflection->getThrowType(); + } + + $types = []; + + foreach ($residualExceptions as $exceptionClass) { + if ($argIsSafe && $exceptionClass === MathException::class) { + continue; + } + + if ($roundingIsNotUnnecessary && $exceptionClass === RoundingNecessaryException::class) { + continue; + } + + if ($divisorIsNonZero && $exceptionClass === DivisionByZeroException::class) { + continue; + } + + $types[] = new ObjectType($exceptionClass); + } + + if ($types === []) { + return null; + } + + return TypeCombinator::union(...$types); + } + + /** + * Checks if the divisor argument is already the correct type (no parsing needed). + */ + private function isDivisorArgSafe(MethodReflection $methodReflection, Type $argType): bool + { + $className = $methodReflection->getDeclaringClass()->getName(); + + $safeType = match ($className) { + BigInteger::class => TypeCombinator::union(new IntegerType(), new ObjectType(BigInteger::class)), + BigDecimal::class => TypeCombinator::union( + new IntegerType(), + new ObjectType(BigInteger::class), + new ObjectType(BigDecimal::class), + ), + default => null, + }; + + return $safeType !== null && $safeType->isSuperTypeOf($argType)->yes(); + } + + /** + * Checks if the type is guaranteed to be non-zero (excludes {@see DivisionByZeroException}). + * + * Non-zero int ranges and {@see BigNumber} instances (whose zeroness can't be statically proven) are considered non-zero. + * Only pure int types that include zero are treated as potentially zero. + */ + private static function isNonZero(Type $type): bool + { + $zeroType = new ConstantIntegerType(0); + + return $zeroType->isSuperTypeOf($type)->no(); + } + + private static function isNotUnnecessary(Type $roundingModeType): bool + { + $unnecessaryType = new EnumCaseObjectType(RoundingMode::class, 'Unnecessary'); + + return $unnecessaryType->isSuperTypeOf($roundingModeType)->no(); + } +} diff --git a/phpstan/extension.neon b/phpstan/extension.neon new file mode 100644 index 0000000..9732266 --- /dev/null +++ b/phpstan/extension.neon @@ -0,0 +1,20 @@ +services: + - + class: Brick\Math\PHPStan\BigNumberOfThrowTypeExtension + tags: + - phpstan.dynamicStaticMethodThrowTypeExtension + + - + class: Brick\Math\PHPStan\BigNumberConversionThrowTypeExtension + tags: + - phpstan.dynamicMethodThrowTypeExtension + + - + class: Brick\Math\PHPStan\BigNumberOperationThrowTypeExtension + tags: + - phpstan.dynamicMethodThrowTypeExtension + + - + class: Brick\Math\PHPStan\RoundingModeThrowTypeExtension + tags: + - phpstan.dynamicMethodThrowTypeExtension