diff --git a/conf/config.neon b/conf/config.neon index 4679d5f31c..df01edccb8 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -1045,6 +1045,10 @@ services: - class: PHPStan\Rules\TooWideTypehints\TooWideParameterOutTypeCheck + - + class: PHPStan\Type\BcMathNumberOperatorTypeSpecifyingExtension + tags: + - phpstan.broker.operatorTypeSpecifyingExtension - class: PHPStan\Type\FileTypeMapper arguments: diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 8520f6488d..5af1722c0d 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -375,4 +375,9 @@ public function substrReturnFalseInsteadOfEmptyString(): bool return $this->versionId < 80000; } + public function supportsBcMathNumberOperatorOverloading(): bool + { + return $this->versionId >= 80400; + } + } diff --git a/src/Rules/Operators/InvalidBinaryOperationRule.php b/src/Rules/Operators/InvalidBinaryOperationRule.php index e44b2178d5..6d71387783 100644 --- a/src/Rules/Operators/InvalidBinaryOperationRule.php +++ b/src/Rules/Operators/InvalidBinaryOperationRule.php @@ -6,12 +6,14 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; use PHPStan\Node\Printer\ExprPrinter; +use PHPStan\Php\PhpVersion; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Rules\RuleLevelHelper; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\ErrorType; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\VerbosityLevel; use function sprintf; @@ -26,6 +28,7 @@ final class InvalidBinaryOperationRule implements Rule public function __construct( private ExprPrinter $exprPrinter, + private PhpVersion $phpVersion, private RuleLevelHelper $ruleLevelHelper, ) { @@ -70,9 +73,13 @@ public function processNode(Node $node, Scope $scope): array if ($node instanceof Node\Expr\AssignOp\Concat || $node instanceof Node\Expr\BinaryOp\Concat) { $callback = static fn (Type $type): bool => !$type->toString() instanceof ErrorType; } elseif ($node instanceof Node\Expr\AssignOp\Plus || $node instanceof Node\Expr\BinaryOp\Plus) { - $callback = static fn (Type $type): bool => !$type->toNumber() instanceof ErrorType || $type->isArray()->yes(); + $callback = $this->phpVersion->supportsBcMathNumberOperatorOverloading() + ? static fn (Type $type): bool => !$type->toNumber() instanceof ErrorType || $type->isArray()->yes() || $type->isSuperTypeOf(new ObjectType('BcMath\Number'))->yes() + : static fn (Type $type): bool => !$type->toNumber() instanceof ErrorType || $type->isArray()->yes(); } else { - $callback = static fn (Type $type): bool => !$type->toNumber() instanceof ErrorType; + $callback = $this->phpVersion->supportsBcMathNumberOperatorOverloading() + ? static fn (Type $type): bool => !$type->toNumber() instanceof ErrorType || $type->isSuperTypeOf(new ObjectType('BcMath\Number'))->yes() + : static fn (Type $type): bool => !$type->toNumber() instanceof ErrorType; } $leftType = $this->ruleLevelHelper->findTypeToCheck( diff --git a/src/Type/BcMathNumberOperatorTypeSpecifyingExtension.php b/src/Type/BcMathNumberOperatorTypeSpecifyingExtension.php new file mode 100644 index 0000000000..81b29ae416 --- /dev/null +++ b/src/Type/BcMathNumberOperatorTypeSpecifyingExtension.php @@ -0,0 +1,88 @@ + BinaryOp\Minus::class, + '+' => BinaryOp\Plus::class, + '*' => BinaryOp\Mul::class, + '/' => BinaryOp\Div::class, + '**' => BinaryOp\Pow::class, + '%' => BinaryOp\Mod::class, + ]; + + public function __construct( + private PhpVersion $phpVersion, + private InitializerExprTypeResolver $InitializerExprTypeResolver, + ) { + } + + public function isOperatorSupported(string $operatorSigil, Type $leftSide, Type $rightSide): bool + { + if (!$this->phpVersion->supportsBcMathNumberOperatorOverloading()) { + return false; + } + + return in_array($operatorSigil, ['-', '+', '*', '/', '**', '%'], true) + && ( + $leftSide->isSuperTypeOf(new ObjectType('BcMath\Number'))->yes() + || $rightSide->isSuperTypeOf(new ObjectType('BcMath\Number'))->yes() + ); + } + + public function specifyType(string $operatorSigil, Type $leftSide, Type $rightSide): Type + { + if (count($leftSide->getConstantArrays()) > 0 || count($rightSide->getConstantArrays()) > 0) { + return new ErrorType(); + } + + $possibleTypes = [ + new ObjectType('BcMath\Number'), + ]; + + $leftTypes = TypeUtils::flattenTypes(TypeCombinator::remove($leftSide, new ObjectType('BcMath\Number'))); + $rightTypes = TypeUtils::flattenTypes(TypeCombinator::remove($rightSide, new ObjectType('BcMath\Number'))); + $operator = self::OPERATORS[$operatorSigil]; + $contest = InitializerExprContext::createEmpty(); + + if (count($leftTypes) === 0 xor count($rightTypes) === 0) { + $otherType = count($leftTypes) === 0 ? $rightSide : $leftSide; + if ($otherType->isSuperTypeOf(new ObjectType('BcMath\Number'))->no() + && ( + !$otherType->isInteger()->no() || !$otherType->isFloat()->no() || !$otherType->isNumericString()->no() + ) + ) { + return new ErrorType(); + } + } + + foreach ($leftTypes as $leftType) { + foreach ($rightTypes as $rightType) { + $node = new $operator(new TypeExpr($leftType), new TypeExpr($rightType)); + $possibleTypes[] = $this->InitializerExprTypeResolver->getType($node, $contest); + } + } + + return TypeCombinator::union(...$possibleTypes); + } + +} diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index 3290fb97c7..964bd4c56d 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -30,6 +30,8 @@ use PHPStan\Reflection\Type\UnresolvedPropertyPrototypeReflection; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\AccessoryLowercaseStringType; +use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantStringType; @@ -542,6 +544,10 @@ private function describeCache(): string public function toNumber(): Type { + // if ($this->isInstanceOf('BcMath\Number')->yes()) { + // return $this; + // } + if ($this->isInstanceOf('SimpleXMLElement')->yes()) { return new UnionType([ new FloatType(), @@ -580,6 +586,14 @@ public function toFloat(): Type public function toString(): Type { + if ($this->isInstanceOf('BcMath\Number')->yes()) { + return new IntersectionType([ + new StringType(), + new AccessoryLowercaseStringType(), + new AccessoryNumericStringType(), + ]); + } + $classReflection = $this->getClassReflection(); if ($classReflection === null) { return new ErrorType(); diff --git a/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php b/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php index 135194070b..3f9d446df3 100644 --- a/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php +++ b/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php @@ -4,6 +4,7 @@ use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Node\Printer\Printer; +use PHPStan\Php\PhpVersion; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleLevelHelper; use PHPStan\Testing\RuleTestCase; @@ -23,6 +24,7 @@ protected function getRule(): Rule { return new InvalidBinaryOperationRule( new ExprPrinter(new Printer()), + new PhpVersion(PHP_VERSION_ID), new RuleLevelHelper($this->createReflectionProvider(), true, false, true, $this->checkExplicitMixed, $this->checkImplicitMixed, false), ); } diff --git a/tests/PHPStan/Type/BcMathNumberOperatorTypeSpecifyingExtensionTest.php b/tests/PHPStan/Type/BcMathNumberOperatorTypeSpecifyingExtensionTest.php new file mode 100644 index 0000000000..c8f0a87925 --- /dev/null +++ b/tests/PHPStan/Type/BcMathNumberOperatorTypeSpecifyingExtensionTest.php @@ -0,0 +1,140 @@ +getByType(BcMathNumberOperatorTypeSpecifyingExtension::class); + + $this->assertTrue($extension->isOperatorSupported($sigil, $left, $right)); + + $actualType = $extension->specifyType($sigil, $left, $right)->describe(VerbosityLevel::precise()); + $this->assertSame($expected, $actualType); + } + + public static function dataSigilAndSidesProvider(): iterable + { + $phpVersion = self::getContainer()->getByType(PhpVersion::class); + if (!$phpVersion->supportsBcMathNumberOperatorOverloading()) { + return; + } + + $supportedOperators = Closure::bind( + static fn () => BcMathNumberOperatorTypeSpecifyingExtension::OPERATORS, + null, + BcMathNumberOperatorTypeSpecifyingExtension::class, + )(); + foreach ($supportedOperators as $operator => $_) { + yield sprintf('BcMath\Number %s BcMath\Number', $operator) => [ + 'sigil' => $operator, + 'left' => new ObjectType('BcMath\Number'), + 'right' => new ObjectType('BcMath\Number'), + 'expected' => 'BcMath\Number', + ]; + } + + $oneType = new ConstantIntegerType(1); + + + yield 'BcMath\Number + 1' => [ + 'sigil' => '+', + 'left' => new ObjectType('BcMath\Number'), + 'right' => new ConstantIntegerType(1), + 'expected' => 'BcMath\Number', + ]; + + yield 'BcMath\Number + 1' => [ + 'sigil' => '+', + 'left' => new ObjectType('BcMath\Number'), + 'right' => TypeCombinator::union( + new ObjectType('BcMath\Number'), + new StringType(), + ), + 'expected' => 'BcMath\Number', + ]; + + yield '1|2|BcMath\Number + 3|4|BcMath\Number' => [ + 'sigil' => '+', + 'left' => TypeCombinator::union( + new ObjectType('BcMath\Number'), + new ConstantIntegerType(1), + new ConstantIntegerType(2), + ), + 'right' => TypeCombinator::union( + new ObjectType('BcMath\Number'), + new ConstantIntegerType(3), + new ConstantIntegerType(4), + ), + 'expected' => '4|5|6|BcMath\Number', + ]; + + yield 'int|BcMath\Number + float|BcMath\Number' => [ + 'sigil' => '+', + 'left' => TypeCombinator::union( + new ObjectType('BcMath\Number'), + new IntegerType(), + ), + 'right' => TypeCombinator::union( + new ObjectType('BcMath\Number'), + new IntegerType(), + new FloatType(), + ), + 'expected' => 'BcMath\Number|float|int', + ]; + } + + /** + * @dataProvider dataNotMatchingSidesProvider + */ + public function testNotSupportsNotMatchingSides(string $sigil, Type $left, Type $right): void + { + $extension = self::getContainer()->getByType(BcMathNumberOperatorTypeSpecifyingExtension::class); + + $this->assertFalse($extension->isOperatorSupported($sigil, $left, $right)); + } + + public static function dataNotMatchingSidesProvider(): iterable + { + $phpVersion = self::getContainer()->getByType(PhpVersion::class); + if (!$phpVersion->supportsBcMathNumberOperatorOverloading()) { + $desc = sprintf("BcMath\Number is supported in PHP 8.4.0 and above. %s was given.", $phpVersion->getVersionString()); + yield $desc => [ + 'sigil' => '+', + 'left' => new ObjectType('BcMath\Number'), + 'right' => new ObjectType('BcMath\Number'), + ]; + return; + } + + $notSupportedOperators = ['&', '|', '^', '||', '&&']; + foreach ($notSupportedOperators as $notSupportedOperator) { + yield sprintf('Do not support %s operator', $notSupportedOperator) => [ + $notSupportedOperator, + new ObjectType('BcMath\Number'), + new ObjectType('BcMath\Number'), + ]; + } + + yield 'Do not support int + int' => [ + 'sigil' => '+', + 'right' => new IntegerType, + 'left' => new IntegerType, + ]; + } + +}