diff --git a/src/Type/Php/GmpOperatorTypeSpecifyingExtension.php b/src/Type/Php/GmpOperatorTypeSpecifyingExtension.php new file mode 100644 index 0000000000..ef0a45a956 --- /dev/null +++ b/src/Type/Php/GmpOperatorTypeSpecifyingExtension.php @@ -0,0 +1,62 @@ +>', '<', '<=', '>', '>=', '==', '!=', '<=>'], true) + && ( + $gmpType->isSuperTypeOf($leftSide)->yes() + || $gmpType->isSuperTypeOf($rightSide)->yes() + ); + } + + public function specifyType(string $operatorSigil, Type $leftSide, Type $rightSide): Type + { + $gmpType = new ObjectType('GMP'); + $otherSide = $gmpType->isSuperTypeOf($leftSide)->yes() + ? $rightSide + : $leftSide; + + // Comparison operators return bool or int (for spaceship) + if (in_array($operatorSigil, ['<', '<=', '>', '>=', '==', '!='], true)) { + return new BooleanType(); + } + + if ($operatorSigil === '<=>') { + return IntegerRangeType::fromInterval(-1, 1); + } + + // GMP can operate with: GMP, int, or numeric-string + if ( + $otherSide->isInteger()->yes() + || $otherSide->isNumericString()->yes() + || $gmpType->isSuperTypeOf($otherSide)->yes() + ) { + return $gmpType; + } + + return new ErrorType(); + } + +} diff --git a/src/Type/Php/GmpUnaryOperatorTypeSpecifyingExtension.php b/src/Type/Php/GmpUnaryOperatorTypeSpecifyingExtension.php new file mode 100644 index 0000000000..4aaa3de4d8 --- /dev/null +++ b/src/Type/Php/GmpUnaryOperatorTypeSpecifyingExtension.php @@ -0,0 +1,35 @@ +isSuperTypeOf($operand)->yes(); + } + + public function specifyType(string $operatorSigil, Type $operand): Type + { + return new ObjectType('GMP'); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/gmp-operators.php b/tests/PHPStan/Analyser/nsrt/gmp-operators.php new file mode 100644 index 0000000000..f9897520fb --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/gmp-operators.php @@ -0,0 +1,237 @@ +> $b); + + // GMP on left, int on right + assertType('GMP', $a & $i); + assertType('GMP', $a | $i); + assertType('GMP', $a ^ $i); + assertType('GMP', $a << $i); + assertType('GMP', $a >> $i); + + // int on left, GMP on right + assertType('GMP', $i & $a); + assertType('GMP', $i | $a); + assertType('GMP', $i ^ $a); +} + +function gmpComparisonOperators(\GMP $a, \GMP $b, int $i): void +{ + // GMP compared with GMP + assertType('bool', $a < $b); + assertType('bool', $a <= $b); + assertType('bool', $a > $b); + assertType('bool', $a >= $b); + assertType('bool', $a == $b); + assertType('bool', $a != $b); + assertType('int<-1, 1>', $a <=> $b); + + // GMP on left, int on right + assertType('bool', $a < $i); + assertType('bool', $a <= $i); + assertType('bool', $a > $i); + assertType('bool', $a >= $i); + assertType('bool', $a == $i); + assertType('bool', $a != $i); + assertType('int<-1, 1>', $a <=> $i); + + // int on left, GMP on right + assertType('bool', $i < $a); + assertType('bool', $i <= $a); + assertType('bool', $i > $a); + assertType('bool', $i >= $a); + assertType('bool', $i == $a); + assertType('bool', $i != $a); + assertType('int<-1, 1>', $i <=> $a); +} + +function gmpAssignmentOperators(\GMP $a, int $i): void +{ + $x = $a; + $x += $i; + assertType('GMP', $x); + + $y = $a; + $y -= $i; + assertType('GMP', $y); + + $z = $a; + $z *= $i; + assertType('GMP', $z); +} + +// ============================================================================= +// gmp_* functions (corresponding to operator overloads) +// ============================================================================= + +function gmpArithmeticFunctions(\GMP $a, \GMP $b, int $i): void +{ + // gmp_add corresponds to + + assertType('GMP', gmp_add($a, $b)); + assertType('GMP', gmp_add($a, $i)); + assertType('GMP', gmp_add($i, $a)); + + // gmp_sub corresponds to - + assertType('GMP', gmp_sub($a, $b)); + assertType('GMP', gmp_sub($a, $i)); + assertType('GMP', gmp_sub($i, $a)); + + // gmp_mul corresponds to * + assertType('GMP', gmp_mul($a, $b)); + assertType('GMP', gmp_mul($a, $i)); + assertType('GMP', gmp_mul($i, $a)); + + // gmp_div_q corresponds to / + assertType('GMP', gmp_div_q($a, $b)); + assertType('GMP', gmp_div_q($a, $i)); + + // gmp_div is alias of gmp_div_q + assertType('GMP', gmp_div($a, $b)); + + // gmp_mod corresponds to % + assertType('GMP', gmp_mod($a, $b)); + assertType('GMP', gmp_mod($a, $i)); + + // gmp_pow corresponds to ** + assertType('GMP', gmp_pow($a, 2)); + assertType('GMP', gmp_pow($a, $i)); + + // gmp_neg corresponds to unary - + assertType('GMP', gmp_neg($a)); + + // gmp_abs (no direct operator) + assertType('GMP', gmp_abs($a)); +} + +function gmpBitwiseFunctions(\GMP $a, \GMP $b): void +{ + // gmp_and corresponds to & + assertType('GMP', gmp_and($a, $b)); + + // gmp_or corresponds to | + assertType('GMP', gmp_or($a, $b)); + + // gmp_xor corresponds to ^ + assertType('GMP', gmp_xor($a, $b)); + + // gmp_com corresponds to ~ + assertType('GMP', gmp_com($a)); +} + +function gmpComparisonFunctions(\GMP $a, \GMP $b, int $i): void +{ + // gmp_cmp returns -1, 0, or 1 in practice, but stubs say int + // TODO: Could be improved to int<-1, 1> like the <=> operator + assertType('int', gmp_cmp($a, $b)); + assertType('int', gmp_cmp($a, $i)); +} + +function gmpFromInit(): void +{ + $x = gmp_init('1'); + assertType('GMP', $x); + + // Operator with gmp_init result + $y = $x * 2; + assertType('GMP', $y); + + $z = $x + gmp_init('5'); + assertType('GMP', $z); +} + +function gmpWithNumericString(\GMP $a, string $s): void +{ + // GMP functions accept numeric strings + assertType('GMP', gmp_add($a, '123')); + assertType('GMP', gmp_mul($a, '456')); + + // General string (not numeric-string) has isNumericString()=Maybe + // This catches TrinaryLogicMutator on line 53: isNumericString()->yes() → !isNumericString()->no() + // Without mutation: Maybe.yes()=false → ErrorType + // With mutation: !Maybe.no()=true → GMP (incorrect!) + /** @phpstan-ignore binaryOp.invalid */ + assertType('*ERROR*', $a + $s); + /** @phpstan-ignore binaryOp.invalid */ + assertType('*ERROR*', $s + $a); +} + +/** + * @param object $obj + */ +function nonGmpObjectsDoNotGetGmpTreatment($obj, int $i): void +{ + // Generic object should NOT be treated as GMP - the extension should not activate + // (object is a supertype of GMP, but GMP is not a supertype of object) + /** @phpstan-ignore binaryOp.invalid */ + assertType('*ERROR*', $obj + $i); + /** @phpstan-ignore binaryOp.invalid */ + assertType('*ERROR*', $i + $obj); +} + +/** + * Tests for unary operators on non-GMP objects. + * This catches IsSuperTypeOfCalleeAndArgumentMutator and TrinaryLogicMutator + * on GmpUnaryOperatorTypeSpecifyingExtension line 27. + * + * When mutation swaps $gmpType->isSuperTypeOf($operand) to $operand->isSuperTypeOf($gmpType), + * `object` would incorrectly activate the extension and return GMP. + * + * @param object $obj + */ +function unaryOperatorsOnObjectShouldError($obj): void +{ + // Without mutation: extension doesn't activate (GMP not supertype of object) + // With mutation: extension activates (object IS supertype of GMP), returns GMP! + /** @phpstan-ignore unaryOp.invalid */ + assertType('*ERROR*', -$obj); + /** @phpstan-ignore unaryOp.invalid */ + assertType('*ERROR*', +$obj); + /** @phpstan-ignore unaryOp.invalid */ + assertType('*ERROR*', ~$obj); +} diff --git a/tests/PHPStan/Analyser/nsrt/pow.php b/tests/PHPStan/Analyser/nsrt/pow.php index 3ca27690db..69a7a3c222 100644 --- a/tests/PHPStan/Analyser/nsrt/pow.php +++ b/tests/PHPStan/Analyser/nsrt/pow.php @@ -20,11 +20,12 @@ function (\GMP $a, \GMP $b): void { }; function (\stdClass $a, \GMP $b): void { - assertType('GMP|stdClass', pow($a, $b)); - assertType('GMP|stdClass', $a ** $b); + // stdClass is not a valid GMP operand, these should error + assertType('*ERROR*', pow($a, $b)); + assertType('*ERROR*', $a ** $b); - assertType('GMP|stdClass', pow($b, $a)); - assertType('GMP|stdClass', $b ** $a); + assertType('*ERROR*', pow($b, $a)); + assertType('*ERROR*', $b ** $a); }; function (): void { diff --git a/tests/PHPStan/Type/Php/GmpOperatorTypeSpecifyingExtensionTest.php b/tests/PHPStan/Type/Php/GmpOperatorTypeSpecifyingExtensionTest.php new file mode 100644 index 0000000000..97c50184e0 --- /dev/null +++ b/tests/PHPStan/Type/Php/GmpOperatorTypeSpecifyingExtensionTest.php @@ -0,0 +1,165 @@ +extension = new GmpOperatorTypeSpecifyingExtension(); + } + + #[DataProvider('dataSupportedOperations')] + public function testSupportsValidGmpOperations(string $sigil, string $leftType, string $rightType): void + { + $left = $this->createType($leftType); + $right = $this->createType($rightType); + + self::assertTrue($this->extension->isOperatorSupported($sigil, $left, $right)); + } + + public static function dataSupportedOperations(): iterable + { + // GMP + GMP + yield 'GMP + GMP' => ['+', 'GMP', 'GMP']; + yield 'GMP - GMP' => ['-', 'GMP', 'GMP']; + yield 'GMP * GMP' => ['*', 'GMP', 'GMP']; + + // GMP + int (activates, specifyType handles compatibility) + yield 'GMP + int' => ['+', 'GMP', 'int']; + yield 'int + GMP' => ['+', 'int', 'GMP']; + + // GMP + incompatible (activates, specifyType returns ErrorType) + yield 'GMP + stdClass' => ['+', 'GMP', 'stdClass']; + yield 'stdClass + GMP' => ['+', 'stdClass', 'GMP']; + + // Comparison + yield 'GMP < GMP' => ['<', 'GMP', 'GMP']; + yield 'GMP <=> int' => ['<=>', 'GMP', 'int']; + } + + #[DataProvider('dataUnsupportedOperations')] + public function testDoesNotSupportInvalidOperations(string $sigil, string $leftType, string $rightType): void + { + $left = $this->createType($leftType); + $right = $this->createType($rightType); + + self::assertFalse($this->extension->isOperatorSupported($sigil, $left, $right)); + } + + public static function dataUnsupportedOperations(): iterable + { + // Neither side is GMP + yield 'int + int' => ['+', 'int', 'int']; + + // object is a supertype of GMP, but is not GMP itself + // This catches mutations that swap isSuperTypeOf callee/argument + yield 'object + int' => ['+', 'object', 'int']; + yield 'int + object' => ['+', 'int', 'object']; + + // GMP|int union should not be treated as definitely GMP + // This catches mutations that change .yes() to !.no() + yield 'GMP|int + int' => ['+', 'GMP|int', 'int']; + yield 'int + GMP|int' => ['+', 'int', 'GMP|int']; + } + + #[DataProvider('dataSpecifyTypeReturnsError')] + public function testSpecifyTypeReturnsErrorForIncompatibleTypes(string $sigil, string $leftType, string $rightType): void + { + $left = $this->createType($leftType); + $right = $this->createType($rightType); + + self::assertInstanceOf(ErrorType::class, $this->extension->specifyType($sigil, $left, $right)); + } + + public static function dataSpecifyTypeReturnsError(): iterable + { + yield 'GMP + stdClass' => ['+', 'GMP', 'stdClass']; + yield 'stdClass + GMP' => ['+', 'stdClass', 'GMP']; + yield 'GMP + float' => ['+', 'GMP', 'float']; + + // object is a supertype of GMP - these catch line 37 IsSuperTypeOfCalleeAndArgumentMutator + // When mutation swaps callee/argument, $otherSide incorrectly becomes GMP instead of object + yield 'object + GMP' => ['+', 'object', 'GMP']; + yield 'GMP + object' => ['+', 'GMP', 'object']; + + // GMP|int is Maybe-GMP - catches line 37 TrinaryLogicMutator + // When mutation changes .yes() to !.no(), $otherSide incorrectly becomes int instead of GMP|int + // Note: int + GMP|int returns GMP (other=int which is valid), only GMP|int + int returns error + yield 'GMP|int + int (specifyType)' => ['+', 'GMP|int', 'int']; + + // int|stdClass has isInteger()=Maybe - catches line 52 TrinaryLogicMutator + // When mutation changes .yes() to !.no(), isInteger() incorrectly returns true + yield 'GMP + int|stdClass' => ['+', 'GMP', 'int|stdClass']; + yield 'int|stdClass + GMP' => ['+', 'int|stdClass', 'GMP']; + + // string has isNumericString()=Maybe - catches line 53 TrinaryLogicMutator + // When mutation changes .yes() to !.no(), isNumericString() incorrectly returns true + yield 'GMP + string' => ['+', 'GMP', 'string']; + yield 'string + GMP' => ['+', 'string', 'GMP']; + } + + #[DataProvider('dataSpecifyTypeReturnsGmp')] + public function testSpecifyTypeReturnsGmpForCompatibleTypes(string $sigil, string $leftType, string $rightType): void + { + $left = $this->createType($leftType); + $right = $this->createType($rightType); + + $result = $this->extension->specifyType($sigil, $left, $right); + self::assertInstanceOf(ObjectType::class, $result); + self::assertSame('GMP', $result->getClassName()); + } + + public static function dataSpecifyTypeReturnsGmp(): iterable + { + yield 'GMP + GMP' => ['+', 'GMP', 'GMP']; + yield 'GMP + int' => ['+', 'GMP', 'int']; + yield 'int + GMP' => ['+', 'int', 'GMP']; + + // When left is int and right is GMP|int, other=int which is valid + yield 'int + GMP|int' => ['+', 'int', 'GMP|int']; + } + + private function createType(string $type): Type + { + switch ($type) { + case 'GMP': + return new ObjectType('GMP'); + case 'int': + return new IntegerType(); + case 'float': + return new FloatType(); + case 'object': + return new ObjectWithoutClassType(); + case 'stdClass': + return new ObjectType('stdClass'); + case 'GMP|int': + return new UnionType([new ObjectType('GMP'), new IntegerType()]); + case 'int|stdClass': + return new UnionType([new IntegerType(), new ObjectType('stdClass')]); + case 'string': + return new StringType(); + default: + throw new InvalidArgumentException(sprintf('Unknown type: %s', $type)); + } + } + +}