diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f5225c3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +/.github/ export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore + +/math/phpcs.xml.dist export-ignore +/math/phpstan.neon export-ignore + +/money/phpcs.xml.dist export-ignore +/money/phpstan.neon export-ignore diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml new file mode 100644 index 0000000..e3435ef --- /dev/null +++ b/.github/workflows/coding-standards.yml @@ -0,0 +1,42 @@ +name: "Coding Standards" + +on: + pull_request: + push: + branches: + - "main" + +jobs: + coding-standards: + name: "Coding Standards - ${{ matrix.package }}" + runs-on: "ubuntu-24.04" + + strategy: + fail-fast: false + matrix: + package: + - "math" + - "money" + + defaults: + run: + working-directory: "${{ matrix.package }}" + + steps: + - name: "Checkout" + uses: "actions/checkout@v6" + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "none" + php-version: "8.4" + tools: "cs2pr" + + - name: "Install dependencies with Composer" + uses: "ramsey/composer-install@v3" + with: + working-directory: "${{ matrix.package }}" + + - name: "Run PHP_CodeSniffer" + run: "vendor/bin/phpcs -q --no-colors --report=checkstyle | cs2pr" diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..c7d488f --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,42 @@ +name: "Static Analysis" + +on: + pull_request: + push: + branches: + - "main" + +jobs: + static-analysis-phpstan: + name: "PHPStan - ${{ matrix.package }}" + runs-on: "ubuntu-24.04" + + strategy: + fail-fast: false + matrix: + package: + - "math" + - "money" + + defaults: + run: + working-directory: "${{ matrix.package }}" + + steps: + - name: "Checkout code" + uses: "actions/checkout@v6" + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "none" + php-version: "8.4" + tools: "cs2pr" + + - name: "Install dependencies with Composer" + uses: "ramsey/composer-install@v3" + with: + working-directory: "${{ matrix.package }}" + + - name: "Run a static analysis with phpstan/phpstan" + run: "vendor/bin/phpstan analyse --error-format=checkstyle | cs2pr" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6898c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# math +/math/vendor/ +/math/composer.lock +/math/.phpcs-cache + +# money +/money/vendor/ +/money/composer.lock +/money/.phpcs-cache diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e3ff77 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# PHPStan Brick Extensions + +PHPStan extensions that narrow throw types for [brick/math](https://github.com/brick/math) and [brick/money](https://github.com/brick/money). + +## Packages + +### `simpod/phpstan-brick-math` + +``` +composer require --dev simpod/phpstan-brick-math +``` + +If you use [phpstan/extension-installer](https://github.com/phpstan/extension-installer), you're all set. + +Otherwise, include in your `phpstan.neon`: + +```neon +includes: + - vendor/simpod/phpstan-brick-math/extension.neon +``` + +### `simpod/phpstan-brick-money` + +``` +composer require --dev simpod/phpstan-brick-money +``` + +If you use [phpstan/extension-installer](https://github.com/phpstan/extension-installer), you're all set. + +Otherwise, include in your `phpstan.neon`: + +```neon +includes: + - vendor/simpod/phpstan-brick-money/extension.neon +``` diff --git a/math/composer.json b/math/composer.json new file mode 100644 index 0000000..4419c46 --- /dev/null +++ b/math/composer.json @@ -0,0 +1,31 @@ +{ + "name": "simpod/phpstan-brick-math", + "description": "PHPStan dynamic throw type extensions for brick/math", + "license": "MIT", + "type": "phpstan-extension", + "require": { + "php": "^8.4", + "brick/math": "^0.15", + "phpstan/phpstan": "^2.1" + }, + "require-dev": { + "cdn77/coding-standard": "^7.4" + }, + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "autoload": { + "psr-4": { + "Brick\\Math\\PHPStan\\": "src/" + } + } +} diff --git a/math/extension.neon b/math/extension.neon new file mode 100644 index 0000000..9732266 --- /dev/null +++ b/math/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 diff --git a/math/phpcs.xml.dist b/math/phpcs.xml.dist new file mode 100644 index 0000000..b4a84c0 --- /dev/null +++ b/math/phpcs.xml.dist @@ -0,0 +1,16 @@ + + + + + + + + + + + + + src + + + diff --git a/math/phpstan.neon b/math/phpstan.neon new file mode 100644 index 0000000..38b3a3f --- /dev/null +++ b/math/phpstan.neon @@ -0,0 +1,7 @@ +includes: + - extension.neon + +parameters: + level: max + paths: + - src diff --git a/math/src/BigNumberConversionThrowTypeExtension.php b/math/src/BigNumberConversionThrowTypeExtension.php new file mode 100644 index 0000000..8d65842 --- /dev/null +++ b/math/src/BigNumberConversionThrowTypeExtension.php @@ -0,0 +1,73 @@ + 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::MethodToClass[$methodReflection->getName()]) + || $methodReflection->getName() === 'toInt'; + } + + public function getThrowTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): Type|null { + $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::MethodToClass[$methodName]; + + if ((new ObjectType($targetClass))->isSuperTypeOf($callerType)->yes()) { + return null; + } + + return $methodReflection->getThrowType(); + } +} diff --git a/math/src/BigNumberOfThrowTypeExtension.php b/math/src/BigNumberOfThrowTypeExtension.php new file mode 100644 index 0000000..cc36a2e --- /dev/null +++ b/math/src/BigNumberOfThrowTypeExtension.php @@ -0,0 +1,225 @@ + ['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::SupportedMethods[$className]) + && in_array($methodName, self::SupportedMethods[$className], true); + } + + public function getThrowTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): Type|null { + 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|null { + 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|null { + 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|null { + $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|null + { + $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/math/src/BigNumberOperationThrowTypeExtension.php b/math/src/BigNumberOperationThrowTypeExtension.php new file mode 100644 index 0000000..d674f97 --- /dev/null +++ b/math/src/BigNumberOperationThrowTypeExtension.php @@ -0,0 +1,317 @@ + [ + '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]] + */ + private const array ResidualThrowMethods = [ + 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::NoThrowMethods[$className]) + && in_array($methodName, self::NoThrowMethods[$className], true) + ) { + return true; + } + + // Check class-specific residual-throw methods. + if (isset(self::ResidualThrowMethods[$className][$methodName])) { + return true; + } + + // BigNumber comparison/clamp methods match any BigNumber subclass. + if ($methodReflection->getDeclaringClass()->isSubclassOf(BigNumber::class)) { + if (in_array($methodName, self::NoThrowMethods[BigNumber::class], true)) { + return true; + } + + if (isset(self::ResidualThrowMethods[BigNumber::class][$methodName])) { + return true; + } + } + + return false; + } + + public function getThrowTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): Type|null { + if ($methodCall->getArgs() === []) { + return $methodReflection->getThrowType(); + } + + $className = $methodReflection->getDeclaringClass()->getName(); + $methodName = $methodReflection->getName(); + + // Check residual-throw methods first (O(1) lookup). + if (isset(self::ResidualThrowMethods[$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|null { + $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::NoThrowMethods[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|null { + $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::ResidualThrowMethods[$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|null { + $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|null + { + // 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|null + { + 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/math/src/RoundingModeThrowTypeExtension.php b/math/src/RoundingModeThrowTypeExtension.php new file mode 100644 index 0000000..d5d5730 --- /dev/null +++ b/math/src/RoundingModeThrowTypeExtension.php @@ -0,0 +1,224 @@ + method => [rounding mode arg index, residual exception classes]. + */ + private const array 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|null { + $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|null { + $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|null { + $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/money/composer.json b/money/composer.json new file mode 100644 index 0000000..9efffee --- /dev/null +++ b/money/composer.json @@ -0,0 +1,31 @@ +{ + "name": "simpod/phpstan-brick-money", + "description": "PHPStan dynamic throw type extensions for brick/money", + "license": "MIT", + "type": "phpstan-extension", + "require": { + "php": "^8.4", + "brick/money": "^0.12", + "phpstan/phpstan": "^2.1" + }, + "require-dev": { + "cdn77/coding-standard": "^7.4" + }, + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "autoload": { + "psr-4": { + "Brick\\Money\\PHPStan\\": "src/" + } + } +} diff --git a/money/extension.neon b/money/extension.neon new file mode 100644 index 0000000..eb50626 --- /dev/null +++ b/money/extension.neon @@ -0,0 +1,25 @@ +services: + - + class: Brick\Money\PHPStan\MoneyFactoryThrowTypeExtension + tags: + - phpstan.dynamicStaticMethodThrowTypeExtension + + - + class: Brick\Money\PHPStan\MoneyFactoryRoundingModeThrowTypeExtension + tags: + - phpstan.dynamicStaticMethodThrowTypeExtension + + - + class: Brick\Money\PHPStan\MoneyOperationThrowTypeExtension + tags: + - phpstan.dynamicMethodThrowTypeExtension + + - + class: Brick\Money\PHPStan\MoneyRoundingModeThrowTypeExtension + tags: + - phpstan.dynamicMethodThrowTypeExtension + + - + class: Brick\Money\PHPStan\CurrencyConverterThrowTypeExtension + tags: + - phpstan.dynamicMethodThrowTypeExtension diff --git a/money/phpcs.xml.dist b/money/phpcs.xml.dist new file mode 100644 index 0000000..b4a84c0 --- /dev/null +++ b/money/phpcs.xml.dist @@ -0,0 +1,16 @@ + + + + + + + + + + + + + src + + + diff --git a/money/phpstan.neon b/money/phpstan.neon new file mode 100644 index 0000000..38b3a3f --- /dev/null +++ b/money/phpstan.neon @@ -0,0 +1,7 @@ +includes: + - extension.neon + +parameters: + level: max + paths: + - src diff --git a/money/src/CurrencyConverterThrowTypeExtension.php b/money/src/CurrencyConverterThrowTypeExtension.php new file mode 100644 index 0000000..dbba78d --- /dev/null +++ b/money/src/CurrencyConverterThrowTypeExtension.php @@ -0,0 +1,74 @@ +getDeclaringClass()->getName(); + $methodName = $methodReflection->getName(); + + return $className === CurrencyConverter::class + && ($methodName === 'convert' || $methodName === 'convertToRational'); + } + + public function getThrowTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): Type|null { + $args = $methodCall->getArgs(); + + if (! isset($args[1])) { + return $methodReflection->getThrowType(); + } + + $methodName = $methodReflection->getName(); + $currencyType = $scope->getType($args[1]->value); + $currencyIsSafe = SafeType::isSafeCurrency($currencyType); + + // Check rounding mode for convert() — arg index 3. + $roundingModeIsSafe = false; + + if ($methodName === 'convert' && isset($args[3])) { + $roundingModeIsSafe = SafeType::isSafeRoundingMode($scope->getType($args[3]->value)); + } + + if (! $currencyIsSafe && ! $roundingModeIsSafe) { + return $methodReflection->getThrowType(); + } + + $residualTypes = [new ObjectType(CurrencyConversionException::class)]; + + if (! $currencyIsSafe) { + $residualTypes[] = new ObjectType(UnknownCurrencyException::class); + } + + if ($methodName === 'convert' && ! $roundingModeIsSafe) { + $residualTypes[] = new ObjectType(RoundingNecessaryException::class); + } + + return TypeCombinator::union(...$residualTypes); + } +} diff --git a/money/src/MoneyFactoryRoundingModeThrowTypeExtension.php b/money/src/MoneyFactoryRoundingModeThrowTypeExtension.php new file mode 100644 index 0000000..ec038d0 --- /dev/null +++ b/money/src/MoneyFactoryRoundingModeThrowTypeExtension.php @@ -0,0 +1,63 @@ + rounding mode arg index */ + private const array METHODS = [ + 'of' => 3, + 'ofMinor' => 3, + ]; + + public function isStaticMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getDeclaringClass()->getName() === Money::class + && isset(self::METHODS[$methodReflection->getName()]); + } + + public function getThrowTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): Type|null { + $methodName = $methodReflection->getName(); + $roundingModeArgIndex = self::METHODS[$methodName]; + + $args = $methodCall->getArgs(); + + if (! isset($args[$roundingModeArgIndex])) { + return $methodReflection->getThrowType(); + } + + $roundingModeType = $scope->getType($args[$roundingModeArgIndex]->value); + + if (! SafeType::isSafeRoundingMode($roundingModeType)) { + return $methodReflection->getThrowType(); + } + + // Rounding mode is not Unnecessary — RoundingNecessaryException cannot occur. + return TypeCombinator::union( + new ObjectType(NumberFormatException::class), + new ObjectType(UnknownCurrencyException::class), + ); + } +} diff --git a/money/src/MoneyFactoryThrowTypeExtension.php b/money/src/MoneyFactoryThrowTypeExtension.php new file mode 100644 index 0000000..b53ac71 --- /dev/null +++ b/money/src/MoneyFactoryThrowTypeExtension.php @@ -0,0 +1,116 @@ + ['of', 'ofMinor', 'zero'], + RationalMoney::class => ['of', 'zero'], + ]; + + public function isStaticMethodSupported(MethodReflection $methodReflection): bool + { + $className = $methodReflection->getDeclaringClass()->getName(); + $methodName = $methodReflection->getName(); + + return isset(self::SupportedMethods[$className]) + && in_array($methodName, self::SupportedMethods[$className], true); + } + + public function getThrowTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): Type|null { + $args = $methodCall->getArgs(); + $methodName = $methodReflection->getName(); + + // zero() only has a currency parameter. + if ($methodName === 'zero') { + return $this->narrowZero($methodCall, $scope, $methodReflection); + } + + if (count($args) < 2) { + return $methodReflection->getThrowType(); + } + + $className = $methodReflection->getDeclaringClass()->getName(); + $amountType = $scope->getType($args[0]->value); + $currencyType = $scope->getType($args[1]->value); + + $amountIsSafe = SafeType::isSafeNumber($amountType); + $currencyIsSafe = SafeType::isSafeCurrency($currencyType); + + if (! $amountIsSafe && ! $currencyIsSafe) { + return $methodReflection->getThrowType(); + } + + $residualTypes = []; + + if (! $amountIsSafe) { + $residualTypes[] = new ObjectType(NumberFormatException::class); + } + + if (! $currencyIsSafe) { + $residualTypes[] = new ObjectType(UnknownCurrencyException::class); + } + + // Money::of()/ofMinor() can still throw RoundingNecessaryException. + if ($className === Money::class) { + $residualTypes[] = new ObjectType(RoundingNecessaryException::class); + } + + if ($residualTypes === []) { + return null; + } + + return TypeCombinator::union(...$residualTypes); + } + + private function narrowZero( + StaticCall $methodCall, + Scope $scope, + MethodReflection $methodReflection, + ): Type|null { + $args = $methodCall->getArgs(); + + if (count($args) < 1) { + return $methodReflection->getThrowType(); + } + + $currencyType = $scope->getType($args[0]->value); + + if (SafeType::isSafeCurrency($currencyType)) { + return null; + } + + return $methodReflection->getThrowType(); + } +} diff --git a/money/src/MoneyOperationThrowTypeExtension.php b/money/src/MoneyOperationThrowTypeExtension.php new file mode 100644 index 0000000..b5beed4 --- /dev/null +++ b/money/src/MoneyOperationThrowTypeExtension.php @@ -0,0 +1,269 @@ +getName(); + $className = $methodReflection->getDeclaringClass()->getName(); + + if (in_array($methodName, self::ComparisonMethods, true)) { + return $className === AbstractMoney::class + || $methodReflection->getDeclaringClass()->isSubclassOf(AbstractMoney::class); + } + + $isAbstractMoney = $className === AbstractMoney::class + || $methodReflection->getDeclaringClass()->isSubclassOf(AbstractMoney::class); + + if (in_array($methodName, self::ArithmeticMethods, true)) { + return $isAbstractMoney; + } + + if ($methodName === 'convertedTo') { + return $className === RationalMoney::class; + } + + if ($methodName === 'getMoney') { + return $className === MoneyBag::class; + } + + return false; + } + + public function getThrowTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): Type|null { + if ($methodCall->getArgs() === []) { + return $methodReflection->getThrowType(); + } + + $methodName = $methodReflection->getName(); + + if ($methodName === 'convertedTo') { + return $this->narrowConvertedTo($methodCall, $scope, $methodReflection); + } + + if ($methodName === 'getMoney') { + return $this->narrowGetMoney($methodCall, $scope, $methodReflection); + } + + if (in_array($methodName, self::ComparisonMethods, true)) { + return $this->narrowComparison($methodCall, $scope, $methodReflection); + } + + return $this->narrowArithmetic($methodName, $methodCall, $scope, $methodReflection); + } + + /** + * Narrows comparison methods on {@see AbstractMoney}. + * + * AbstractMoney arg -> only {@see MoneyMismatchException}; safe number -> no throw. + */ + private function narrowComparison( + MethodCall $methodCall, + Scope $scope, + MethodReflection $methodReflection, + ): Type|null { + $argType = $scope->getType($methodCall->getArgs()[0]->value); + + if ((new ObjectType(AbstractMoney::class))->isSuperTypeOf($argType)->yes()) { + return new ObjectType(MoneyMismatchException::class); + } + + if (SafeType::isSafeNumber($argType)) { + return null; + } + + return $methodReflection->getThrowType(); + } + + /** + * Narrows arithmetic methods (`plus`, `minus`, `multipliedBy`, `dividedBy`) + * on {@see Money} and {@see RationalMoney}. + * + * Considers both argument type (safe number / {@see AbstractMoney}) and rounding mode. + */ + private function narrowArithmetic( + string $methodName, + MethodCall $methodCall, + Scope $scope, + MethodReflection $methodReflection, + ): Type|null { + $callerType = $scope->getType($methodCall->var); + $isRational = (new ObjectType(RationalMoney::class))->isSuperTypeOf($callerType)->yes(); + + $args = $methodCall->getArgs(); + $argType = $scope->getType($args[0]->value); + + $argIsSafeNumber = SafeType::isSafeNumber($argType); + $argIsAbstractMoney = (new ObjectType(AbstractMoney::class))->isSuperTypeOf($argType)->yes(); + $argIsSafe = $argIsSafeNumber || $argIsAbstractMoney; + + $roundingModeIsSafe = false; + + if (! $isRational && isset($args[1])) { + $roundingModeIsSafe = SafeType::isSafeRoundingMode($scope->getType($args[1]->value)); + } + + if (! $argIsSafe && ! $roundingModeIsSafe) { + return $methodReflection->getThrowType(); + } + + // RationalMoney: no rounding concern. Safe arg -> no throw (except dividedBy zero). + if ($isRational) { + if ($argIsAbstractMoney) { + $residual = [new ObjectType(MoneyMismatchException::class)]; + + if ($methodName === 'dividedBy' && ! SafeType::isNonZero($argType)) { + $residual[] = new ObjectType(DivisionByZeroException::class); + } + + return TypeCombinator::union(...$residual); + } + + if ($argIsSafeNumber) { + if ($methodName === 'dividedBy' && ! SafeType::isNonZero($argType)) { + return new ObjectType(DivisionByZeroException::class); + } + + return null; + } + + return $methodReflection->getThrowType(); + } + + // Money: build residual throw types. + $residualTypes = []; + + // MoneyMismatchException when arg is AbstractMoney (currency/context mismatch). + if ($argIsAbstractMoney) { + $residualTypes[] = new ObjectType(MoneyMismatchException::class); + } + + // NumberFormatException when arg is not safe (parsing risk). + if (! $argIsSafe) { + $residualTypes[] = new ObjectType(NumberFormatException::class); + } + + // RoundingNecessaryException when rounding mode is not safe. + if (! $roundingModeIsSafe) { + $residualTypes[] = new ObjectType(RoundingNecessaryException::class); + } + + // DivisionByZeroException when divisor could be zero. + if ($methodName === 'dividedBy' && ! SafeType::isNonZero($argType)) { + $residualTypes[] = new ObjectType(DivisionByZeroException::class); + } + + if ($residualTypes === []) { + return null; + } + + return TypeCombinator::union(...$residualTypes); + } + + /** + * Narrows {@see MoneyBag::getMoney()} — safe currency eliminates {@see UnknownCurrencyException}. + */ + private function narrowGetMoney( + MethodCall $methodCall, + Scope $scope, + MethodReflection $methodReflection, + ): Type|null { + $currencyType = $scope->getType($methodCall->getArgs()[0]->value); + + if (SafeType::isSafeCurrency($currencyType)) { + return null; + } + + return $methodReflection->getThrowType(); + } + + /** + * Narrows {@see RationalMoney::convertedTo()} based on currency and exchange rate argument types. + */ + private function narrowConvertedTo( + MethodCall $methodCall, + Scope $scope, + MethodReflection $methodReflection, + ): Type|null { + $args = $methodCall->getArgs(); + + if (count($args) < 2) { + return $methodReflection->getThrowType(); + } + + $currencyIsSafe = SafeType::isSafeCurrency($scope->getType($args[0]->value)); + $rateIsSafe = SafeType::isSafeNumber($scope->getType($args[1]->value)); + + if (! $currencyIsSafe && ! $rateIsSafe) { + return $methodReflection->getThrowType(); + } + + $residualTypes = []; + + if (! $currencyIsSafe) { + $residualTypes[] = new ObjectType(UnknownCurrencyException::class); + } + + if (! $rateIsSafe) { + $residualTypes[] = new ObjectType(NumberFormatException::class); + } + + if ($residualTypes === []) { + return null; + } + + return TypeCombinator::union(...$residualTypes); + } +} diff --git a/money/src/MoneyRoundingModeThrowTypeExtension.php b/money/src/MoneyRoundingModeThrowTypeExtension.php new file mode 100644 index 0000000..5399586 --- /dev/null +++ b/money/src/MoneyRoundingModeThrowTypeExtension.php @@ -0,0 +1,80 @@ + rounding mode arg index. + */ + private const array METHODS = [ + 'toContext' => 1, + 'convertedTo' => 3, + ]; + + public function isMethodSupported(MethodReflection $methodReflection): bool + { + $methodName = $methodReflection->getName(); + $className = $methodReflection->getDeclaringClass()->getName(); + + if (! isset(self::METHODS[$methodName])) { + return false; + } + + return $className === Money::class + || $className === AbstractMoney::class + || $methodReflection->getDeclaringClass()->isSubclassOf(AbstractMoney::class); + } + + public function getThrowTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope, + ): Type|null { + $methodName = $methodReflection->getName(); + $roundingModeArgIndex = self::METHODS[$methodName]; + + $args = $methodCall->getArgs(); + + if (! isset($args[$roundingModeArgIndex])) { + return $methodReflection->getThrowType(); + } + + $roundingModeType = $scope->getType($args[$roundingModeArgIndex]->value); + + if (! SafeType::isSafeRoundingMode($roundingModeType)) { + return $methodReflection->getThrowType(); + } + + // Rounding mode is not Unnecessary — RoundingNecessaryException cannot occur. + if ($methodName === 'toContext') { + return null; + } + + // convertedTo: UnknownCurrencyException + NumberFormatException remain. + return TypeCombinator::union( + new ObjectType(UnknownCurrencyException::class), + new ObjectType(NumberFormatException::class), + ); + } +} diff --git a/money/src/SafeType.php b/money/src/SafeType.php new file mode 100644 index 0000000..b718cce --- /dev/null +++ b/money/src/SafeType.php @@ -0,0 +1,127 @@ +|null */ + private static array|null $knownCurrencies = null; + + /** + * Returns whether the given type can be safely passed to {@see BigNumber::of()} without risk of parsing exceptions. + * + * Safe types: `BigNumber` (and subclasses), `int`, `numeric-string`. + */ + public static function isSafeNumber(Type $type): bool + { + return self::getSafeNumberType()->isSuperTypeOf($type)->yes(); + } + + /** + * Returns whether the given type is a known currency that cannot throw {@see UnknownCurrencyException}. + * + * This is true when the type is a {@see Currency} instance, or a constant string matching a known ISO currency code. + */ + public static function isSafeCurrency(Type $type): bool + { + if ((new ObjectType(Currency::class))->isSuperTypeOf($type)->yes()) { + return true; + } + + $constantStrings = $type->getConstantStrings(); + + if ($constantStrings === []) { + return false; + } + + $knownCurrencies = self::getKnownCurrencies(); + + foreach ($constantStrings as $constantString) { + if (! isset($knownCurrencies[$constantString->getValue()])) { + return false; + } + } + + return true; + } + + /** + * Returns whether the given rounding mode type is known to NOT be `Unnecessary`, + * meaning {@see RoundingNecessaryException} cannot occur. + */ + public static function isSafeRoundingMode(Type $type): bool + { + $unnecessaryType = new EnumCaseObjectType(RoundingMode::class, 'Unnecessary'); + + return $unnecessaryType->isSuperTypeOf($type)->no(); + } + + /** + * Returns whether the given type is guaranteed to be non-zero. + * + * This is used to eliminate {@see DivisionByZeroException} from throw types. + * Only proven for integer types (e.g. int<1, max>, literal 5). + */ + public static function isNonZero(Type $type): bool + { + if (! (new IntegerType())->isSuperTypeOf($type)->yes()) { + return false; + } + + $zeroType = new ConstantIntegerType(0); + + return $zeroType->isSuperTypeOf($type)->no(); + } + + private static function getSafeNumberType(): Type + { + return self::$safeNumberType ??= new UnionType([ + new ObjectType(BigNumber::class), + new IntegerType(), + new IntersectionType([new StringType(), new AccessoryNumericStringType()]), + ]); + } + + /** @return array */ + private static function getKnownCurrencies(): array + { + if (self::$knownCurrencies === null) { + $currencyClassFile = (new ReflectionClass(Currency::class))->getFileName(); + assert($currencyClassFile !== false); + + /** @var array $data */ + $data = require dirname($currencyClassFile, 2) . '/data/iso-currencies.php'; + self::$knownCurrencies = []; + + foreach ($data as $code => $_) { + self::$knownCurrencies[$code] = true; + } + } + + return self::$knownCurrencies; + } +}