diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 70e28b85895..b7eb0e843e5 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -13,6 +13,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -22,6 +23,7 @@ use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; +use function array_merge; use function get_class; use function sprintf; @@ -35,6 +37,7 @@ final class AssignOpHandler implements ExprHandler public function __construct( private AssignHandler $assignHandler, private InitializerExprTypeResolver $initializerExprTypeResolver, + private ImplicitToStringCallHelper $implicitToStringCallHelper, ) { } @@ -85,19 +88,25 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex } $scope = $assignResult->getScope(); $throwPoints = $assignResult->getThrowPoints(); + $impurePoints = $assignResult->getImpurePoints(); if ( ($expr instanceof Expr\AssignOp\Div || $expr instanceof Expr\AssignOp\Mod) && !$scope->getType($expr->expr)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() ) { $throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(DivisionByZeroError::class), $expr, false); } + if ($expr instanceof Expr\AssignOp\Concat) { + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); + } return new ExpressionResult( $scope, hasYield: $assignResult->hasYield(), isAlwaysTerminating: $assignResult->isAlwaysTerminating(), throwPoints: $throwPoints, - impurePoints: $assignResult->getImpurePoints(), + impurePoints: $impurePoints, truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index b9d0908f78f..6f9fbbb71cb 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -13,6 +13,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -42,6 +43,7 @@ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private RicherScopeGetTypeHelper $richerScopeGetTypeHelper, private PhpVersion $phpVersion, + private ImplicitToStringCallHelper $implicitToStringCallHelper, ) { } @@ -62,12 +64,19 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $leftResult->getScope(), $storage, $nodeCallback, $context->enterDeep()); $throwPoints = array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()); + $impurePoints = array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()); if ( ($expr instanceof BinaryOp\Div || $expr instanceof BinaryOp\Mod) && !$leftResult->getScope()->getType($expr->right)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() ) { $throwPoints[] = InternalThrowPoint::createExplicit($leftResult->getScope(), new ObjectType(DivisionByZeroError::class), $expr, false); } + if ($expr instanceof BinaryOp\Concat) { + $leftToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->left, $scope); + $rightToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->right, $leftResult->getScope()); + $throwPoints = array_merge($throwPoints, $leftToStringResult->getThrowPoints(), $rightToStringResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $leftToStringResult->getImpurePoints(), $rightToStringResult->getImpurePoints()); + } $scope = $rightResult->getScope(); return new ExpressionResult( @@ -75,7 +84,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex hasYield: $leftResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $leftResult->isAlwaysTerminating() || $rightResult->isAlwaysTerminating(), throwPoints: $throwPoints, - impurePoints: array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), + impurePoints: $impurePoints, truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), ); diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index 336e14dd921..b4e119e01f5 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -4,21 +4,18 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\Cast; -use PhpParser\Node\Identifier; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; -use PHPStan\Analyser\ImpurePoint; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Php\PhpVersion; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\Type; -use function sprintf; +use function array_merge; /** * @implements ExprHandler @@ -29,8 +26,7 @@ final class CastStringHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, - private PhpVersion $phpVersion, - private MethodThrowPointHelper $methodThrowPointHelper, + private ImplicitToStringCallHelper $implicitToStringCallHelper, ) { } @@ -46,31 +42,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $exprResult->getImpurePoints(); $throwPoints = $exprResult->getThrowPoints(); - $exprType = $scope->getType($expr->expr); - $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); - if ($toStringMethod !== null) { - if (!$toStringMethod->hasSideEffects()->no()) { - $impurePoints[] = new ImpurePoint( - $scope, - $expr, - 'methodCall', - sprintf('call to method %s::%s()', $toStringMethod->getDeclaringClass()->getDisplayName(), $toStringMethod->getName()), - $toStringMethod->isPure()->no(), - ); - } - - if ($this->phpVersion->throwsOnStringCast()) { - $throwPoint = $this->methodThrowPointHelper->getThrowPoint( - $toStringMethod, - $toStringMethod->getOnlyVariant(), - new Expr\MethodCall($expr->expr, new Identifier('__toString')), - $scope, - ); - if ($throwPoint !== null) { - $throwPoints[] = $throwPoint; - } - } - } + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); $scope = $exprResult->getScope(); diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php new file mode 100644 index 00000000000..1b66bc7ba38 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -0,0 +1,73 @@ +getType($expr); + $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); + if ($toStringMethod === null) { + return new ExpressionResult( + $scope, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + ); + } + + if (!$toStringMethod->hasSideEffects()->no()) { + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'methodCall', + sprintf('call to method %s::%s()', $toStringMethod->getDeclaringClass()->getDisplayName(), $toStringMethod->getName()), + $toStringMethod->isPure()->no(), + ); + } + + if ($this->phpVersion->throwsOnStringCast()) { + $throwPoint = $this->methodThrowPointHelper->getThrowPoint( + $toStringMethod, + $toStringMethod->getOnlyVariant(), + new Expr\MethodCall($expr, new Identifier('__toString')), + $scope, + ); + if ($throwPoint !== null) { + $throwPoints[] = $throwPoint; + } + } + + return new ExpressionResult( + $scope, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: $throwPoints, + impurePoints: $impurePoints, + ); + } + +} diff --git a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php index 0e9bc9149ef..da565b78d8d 100644 --- a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php @@ -9,6 +9,7 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Type\DynamicReturnTypeExtensionRegistryProvider; use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function count; @@ -52,7 +53,9 @@ public function methodCallReturnType( } $resolvedTypes = []; - foreach ($typeWithMethod->getObjectClassNames() as $className) { + $allClassNames = $typeWithMethod->getObjectClassNames(); + $handledClassNames = []; + foreach ($allClassNames as $className) { if ($normalizedMethodCall instanceof MethodCall) { foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicMethodReturnTypeExtensionsForClass($className) as $dynamicMethodReturnTypeExtension) { if (!$dynamicMethodReturnTypeExtension->isMethodSupported($methodReflection)) { @@ -65,6 +68,7 @@ public function methodCallReturnType( } $resolvedTypes[] = $resolvedType; + $handledClassNames[] = $className; } } else { foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicStaticMethodReturnTypeExtensionsForClass($className) as $dynamicStaticMethodReturnTypeExtension) { @@ -82,11 +86,29 @@ public function methodCallReturnType( } $resolvedTypes[] = $resolvedType; + $handledClassNames[] = $className; } } } if (count($resolvedTypes) > 0) { + if (count($allClassNames) !== count($handledClassNames)) { + $remainingType = $typeWithMethod; + foreach ($handledClassNames as $handledClassName) { + $remainingType = TypeCombinator::remove($remainingType, new ObjectType($handledClassName)); + } + if ($remainingType->hasMethod($methodName)->yes()) { + $remainingMethod = $remainingType->getMethod($methodName, $scope); + $remainingParametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $remainingMethod->getVariants(), + $remainingMethod->getNamedArgumentsVariants(), + ); + $resolvedTypes[] = $remainingParametersAcceptor->getReturnType(); + } + } + return VoidToNullTypeTransformer::transform(TypeCombinator::union(...$resolvedTypes), $methodCall); } diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index 24e4dcab0ec..bb18cff3e09 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\DependencyInjection\AutowiredService; @@ -27,6 +28,7 @@ final class InterpolatedStringHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ImplicitToStringCallHelper $implicitToStringCallHelper, ) { } @@ -50,6 +52,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $hasYield = $hasYield || $partResult->hasYield(); $throwPoints = array_merge($throwPoints, $partResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $partResult->getImpurePoints()); + + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($part, $scope); + $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); + $isAlwaysTerminating = $isAlwaysTerminating || $partResult->isAlwaysTerminating(); $scope = $partResult->getScope(); } diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index 71f0e2d8c13..18ab04d8d29 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -24,6 +25,12 @@ final class PrintHandler implements ExprHandler { + public function __construct( + private ImplicitToStringCallHelper $implicitToStringCallHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Print_; @@ -37,14 +44,21 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); + $throwPoints = $exprResult->getThrowPoints(); + $impurePoints = $exprResult->getImpurePoints(); + + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); + $scope = $exprResult->getScope(); return new ExpressionResult( $scope, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), - throwPoints: $exprResult->getThrowPoints(), - impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, 'print', 'print', true)]), + throwPoints: $throwPoints, + impurePoints: array_merge($impurePoints, [new ImpurePoint($scope, $expr, 'print', 'print', true)]), ); } diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index 52e2c7cd68e..00aeae92468 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -33,6 +33,14 @@ final class PropertyFetchHandler implements ExprHandler { + /** + * Representative property name used when resolving dynamic property access ($obj->{$expr}). + * The actual name doesn't matter — it just needs to be non-empty so that + * PropertiesClassReflectionExtensions (e.g. SimpleXMLElement) that accept + * any property name can return the correct type. + */ + private const DYNAMIC_PROPERTY_NAME = '__phpstan_dynamic_property'; + public function __construct( private PhpVersion $phpVersion, private PropertyReflectionFinder $propertyReflectionFinder, @@ -130,6 +138,14 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type ); } + if ($nameType->isString()->yes()) { + $fetchedOnType = $scope->getType($expr->var); + $returnType = $this->propertyFetchType($scope, $fetchedOnType, self::DYNAMIC_PROPERTY_NAME, $expr); + if ($returnType !== null) { + return NullsafeShortCircuitingHelper::getType($scope, $expr->var, $returnType); + } + } + return new MixedType(); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 001c8b5b368..f616b644ab3 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -49,6 +49,7 @@ use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; use PHPStan\Analyser\ExprHandler\AssignHandler; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; use PHPStan\BetterReflection\Reflection\ReflectionEnum; use PHPStan\BetterReflection\Reflector\Reflector; @@ -236,6 +237,7 @@ public function __construct( private readonly bool $implicitThrows, #[AutowiredParameter] private readonly bool $treatPhpDocTypesAsCertain, + private readonly ImplicitToStringCallHelper $implicitToStringCallHelper, ) { $earlyTerminatingMethodNames = []; @@ -861,19 +863,22 @@ public function processStmtNode( } elseif ($stmt instanceof Echo_) { $hasYield = false; $throwPoints = []; + $impurePoints = []; $isAlwaysTerminating = false; foreach ($stmt->exprs as $echoExpr) { $result = $this->processExprNode($stmt, $echoExpr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($echoExpr, $scope); + $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); $scope = $result->getScope(); $hasYield = $hasYield || $result->hasYield(); $isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating(); } $throwPoints = $overridingThrowPoints ?? $throwPoints; - $impurePoints = [ - new ImpurePoint($scope, $stmt, 'echo', 'echo', true), - ]; + $impurePoints[] = new ImpurePoint($scope, $stmt, 'echo', 'echo', true); return new InternalStatementResult($scope, $hasYield, $isAlwaysTerminating, [], $throwPoints, $impurePoints); } elseif ($stmt instanceof Return_) { if ($stmt->expr !== null) { diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 4f3e7f9c433..126f0579532 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -280,6 +280,7 @@ public function specifyTypesInCondition( ) { $argType = $scope->getType($expr->right->getArgs()[0]->value); + $sizeType = null; if ($leftType instanceof ConstantIntegerType) { if ($orEqual) { $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getValue()); @@ -287,14 +288,28 @@ public function specifyTypesInCondition( $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getValue()); } } elseif ($leftType instanceof IntegerRangeType) { - $sizeType = $leftType->shift($offset); + if ($context->falsey() && $leftType->getMax() !== null) { + if ($orEqual) { + $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMax()); + } else { + $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMax()); + } + } elseif ($context->truthy() && $leftType->getMin() !== null) { + if ($orEqual) { + $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMin()); + } else { + $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMin()); + } + } } else { $sizeType = $leftType; } - $specifiedTypes = $this->specifyTypesForCountFuncCall($expr->right, $argType, $sizeType, $context, $scope, $expr); - if ($specifiedTypes !== null) { - $result = $result->unionWith($specifiedTypes); + if ($sizeType !== null) { + $specifiedTypes = $this->specifyTypesForCountFuncCall($expr->right, $argType, $sizeType, $context, $scope, $expr); + if ($specifiedTypes !== null) { + $result = $result->unionWith($specifiedTypes); + } } if ( diff --git a/src/Reflection/Type/IntersectionTypeMethodReflection.php b/src/Reflection/Type/IntersectionTypeMethodReflection.php index 76ab3d1afcc..456818b0cf8 100644 --- a/src/Reflection/Type/IntersectionTypeMethodReflection.php +++ b/src/Reflection/Type/IntersectionTypeMethodReflection.php @@ -22,6 +22,8 @@ final class IntersectionTypeMethodReflection implements ExtendedMethodReflection { + private ?ExtendedMethodReflection $methodWithMostParameters = null; + /** * @param ExtendedMethodReflection[] $methods */ @@ -31,7 +33,7 @@ public function __construct(private string $methodName, private array $methods) public function getDeclaringClass(): ClassReflection { - return $this->methods[0]->getDeclaringClass(); + return $this->getMethodWithMostParameters()->getDeclaringClass(); } public function isStatic(): bool @@ -104,7 +106,7 @@ public function getVariants(): array $phpDocReturnType, $nativeReturnType, $acceptor->getCallSiteVarianceMap(), - ), $this->methods[0]->getVariants()); + ), $this->getMethodWithMostParameters()->getVariants()); } public function getOnlyVariant(): ExtendedParametersAcceptor @@ -237,7 +239,7 @@ public function isAbstract(): TrinaryLogic public function getAttributes(): array { - return $this->methods[0]->getAttributes(); + return $this->getMethodWithMostParameters()->getAttributes(); } public function mustUseReturnValue(): TrinaryLogic @@ -247,7 +249,36 @@ public function mustUseReturnValue(): TrinaryLogic public function getResolvedPhpDoc(): ?ResolvedPhpDocBlock { - return $this->methods[0]->getResolvedPhpDoc(); + return $this->getMethodWithMostParameters()->getResolvedPhpDoc(); + } + + /** + * Since every intersected method should be compatible, + * selects the method whose variant has the widest parameter list, + * so intersection ordering does not affect call validation. + */ + private function getMethodWithMostParameters(): ExtendedMethodReflection + { + if ($this->methodWithMostParameters !== null) { + return $this->methodWithMostParameters; + } + + $methodWithMostParameters = $this->methods[0]; + $maxParameters = 0; + foreach ($this->methods as $method) { + foreach ($method->getVariants() as $variant) { + if (count($variant->getParameters()) <= $maxParameters) { + continue; + } + + $maxParameters = count($variant->getParameters()); + $methodWithMostParameters = $method; + } + } + + $this->methodWithMostParameters = $methodWithMostParameters; + + return $methodWithMostParameters; } } diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index 0565407424e..d8d0ba20e1c 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -6,6 +6,7 @@ use PHPStan\Analyser\Analyser; use PHPStan\Analyser\AnalyserResultFinalizer; use PHPStan\Analyser\Error; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\Fiber\FiberNodeScopeResolver; use PHPStan\Analyser\FileAnalyser; use PHPStan\Analyser\IgnoreErrorExtensionProvider; @@ -116,6 +117,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver [], self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), + self::getContainer()->getByType(ImplicitToStringCallHelper::class), ); } diff --git a/src/Testing/TypeInferenceTestCase.php b/src/Testing/TypeInferenceTestCase.php index fb793c45c91..7723560fd06 100644 --- a/src/Testing/TypeInferenceTestCase.php +++ b/src/Testing/TypeInferenceTestCase.php @@ -6,6 +6,7 @@ use PhpParser\Node; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Name; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\Fiber\FiberNodeScopeResolver; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; @@ -91,6 +92,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver static::getEarlyTerminatingFunctionCalls(), $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), + $container->getByType(ImplicitToStringCallHelper::class), ); } diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 538e8575216..aa711029a8d 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -494,7 +494,8 @@ public function equals(Type $type): bool public function isCallable(): TrinaryLogic { - $typeAndMethods = $this->findTypeAndMethodNames(); + $hasNonExistentMethod = false; + $typeAndMethods = $this->doFindTypeAndMethodNames($hasNonExistentMethod); if ($typeAndMethods === []) { return TrinaryLogic::createNo(); } @@ -504,7 +505,13 @@ public function isCallable(): TrinaryLogic $typeAndMethods, ); - return TrinaryLogic::createYes()->and(...$results); + $result = TrinaryLogic::createYes()->and(...$results); + + if ($hasNonExistentMethod) { + $result = $result->and(TrinaryLogic::createMaybe()); + } + + return $result; } public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array @@ -537,6 +544,12 @@ public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope) /** @return ConstantArrayTypeAndMethod[] */ public function findTypeAndMethodNames(): array + { + return $this->doFindTypeAndMethodNames(); + } + + /** @return ConstantArrayTypeAndMethod[] */ + private function doFindTypeAndMethodNames(bool &$hasNonExistentMethod = false): array { if (count($this->keyTypes) !== 2) { return []; @@ -578,6 +591,7 @@ public function findTypeAndMethodNames(): array foreach ($methods->getConstantStrings() as $methodName) { $has = $type->hasMethod($methodName->getValue()); if ($has->no()) { + $hasNonExistentMethod = true; continue; } diff --git a/src/Type/Php/DateTimeModifyReturnTypeExtension.php b/src/Type/Php/DateTimeModifyReturnTypeExtension.php index 0ed2933856d..9b927a19780 100644 --- a/src/Type/Php/DateTimeModifyReturnTypeExtension.php +++ b/src/Type/Php/DateTimeModifyReturnTypeExtension.php @@ -11,8 +11,11 @@ use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\DynamicMethodReturnTypeExtension; use PHPStan\Type\NeverType; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeTraverser; +use PHPStan\Type\UnionType; use Throwable; use function count; @@ -39,11 +42,12 @@ public function isMethodSupported(MethodReflection $methodReflection): bool public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type { - if (count($methodCall->getArgs()) < 1) { + $args = $methodCall->getArgs(); + if (count($args) < 1) { return null; } - $valueType = $scope->getType($methodCall->getArgs()[0]->value); + $valueType = $scope->getType($args[0]->value); $constantStrings = $valueType->getConstantStrings(); $hasFalse = false; @@ -77,7 +81,25 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } elseif ($hasDateTime) { - return $scope->getType($methodCall->var); + $callerType = $scope->getType($methodCall->var); + + $dateTimeInterfaceType = new ObjectType(DateTimeInterface::class); + if ($dateTimeInterfaceType->isSuperTypeOf($callerType)->yes()) { + return $callerType; + } + + return TypeTraverser::map( + $callerType, + static function (Type $type, callable $traverse) use ($dateTimeInterfaceType): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + if ($dateTimeInterfaceType->isSuperTypeOf($type)->yes()) { + return $type; + } + return new NeverType(); + }, + ); } if ($this->phpVersion->hasDateTimeExceptions()) { diff --git a/src/Type/Php/GmpOperatorTypeSpecifyingExtension.php b/src/Type/Php/GmpOperatorTypeSpecifyingExtension.php new file mode 100644 index 00000000000..ef0a45a956d --- /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 00000000000..4aaa3de4d85 --- /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/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index 27f9daddb07..39da6a5be9e 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -6,6 +6,7 @@ use PhpParser\Lexer; use PhpParser\NodeVisitor\NameResolver; use PhpParser\Parser\Php7; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\Ignore\IgnoredErrorHelper; use PHPStan\Analyser\Ignore\IgnoreLexer; use PHPStan\Collectors\Registry as CollectorRegistry; @@ -832,6 +833,7 @@ private function createAnalyser(): Analyser [], true, $this->shouldTreatPhpDocTypesAsCertain(), + $container->getByType(ImplicitToStringCallHelper::class), ); $lexer = new Lexer(); $fileAnalyser = new FileAnalyser( diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php index e963de6bc98..85ee19ddd05 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser\Fiber; use PhpParser\Node; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\Type\ParameterClosureThisExtensionProvider; @@ -136,6 +137,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver [], self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), + self::getContainer()->getByType(ImplicitToStringCallHelper::class), ); } diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php index 5bea8cce792..952730be751 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\Fiber; +use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\DependencyInjection\Type\ParameterClosureThisExtensionProvider; use PHPStan\DependencyInjection\Type\ParameterClosureTypeExtensionProvider; @@ -69,6 +70,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver static::getEarlyTerminatingFunctionCalls(), $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), + $container->getByType(ImplicitToStringCallHelper::class), ); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-11073.php b/tests/PHPStan/Analyser/nsrt/bug-11073.php new file mode 100644 index 00000000000..b61f9f03ea8 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11073.php @@ -0,0 +1,34 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug11073Nsrt; + +use DateTimeImmutable; +use function PHPStan\Testing\assertType; + +class HelloWorld +{ + public function sayHello(?DateTimeImmutable $date): void + { + assertType('DateTimeImmutable|null', $date?->modify('+1 year')->setTime(23, 59, 59)); + } +} + +class Foo +{ + public function getCode(): bool { return false; } +} + +class HelloWorld2 +{ + public function sayHello(\Throwable|Foo $foo): void + { + assertType('bool|int|string', $foo->getCode()); + } + + public function sayHello2(\LogicException|Foo $foo): void + { + assertType('bool|int', $foo->getCode()); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-13705.php b/tests/PHPStan/Analyser/nsrt/bug-13705.php new file mode 100644 index 00000000000..905984b8163 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13705.php @@ -0,0 +1,158 @@ +', $codes); + $code = random_bytes(16); + if (!in_array($code, $codes, true)) { + $codes[] = $code; + } + } +} + +/** + * @param list $arr + * @param int<2, 5> $boundedRange + * @param int<2, max> $unboundedMaxRange + * @param int $unboundedMinRange + */ +function countLessThanRange(array $arr, int $boundedRange, int $unboundedMaxRange, int $unboundedMinRange): void +{ + // count($arr) < $range → inverted to NOT($range <= count($arr)) + // Inner: orEqual=true, false context → falsey + max !== null + orEqual (branch 1) + // Else: orEqual=true, true context → truthy + min !== null + orEqual (branch 3) + if (count($arr) < $boundedRange) { + assertType('list', $arr); + } else { + assertType('non-empty-list&hasOffsetValue(1, string)', $arr); + } + + // count($arr) < unbounded max range → falsey + max is null → fallback via min (branch 3/4) + if (count($arr) < $unboundedMaxRange) { + assertType('list', $arr); + } else { + assertType('non-empty-list&hasOffsetValue(1, string)', $arr); + } + + // count($arr) < unbounded min range → fallback branch (min is null) + if (count($arr) < $unboundedMinRange) { + assertType('list', $arr); + } else { + assertType('list', $arr); + } +} + +/** + * @param list $arr + * @param int<2, 5> $boundedRange + */ +function countLessThanOrEqualRange(array $arr, int $boundedRange): void +{ + // count($arr) <= $range → inverted to NOT($range < count($arr)) + // Inner: orEqual=false, false context → falsey + max !== null + !orEqual (branch 2) + // Else: orEqual=false, true context → truthy + min !== null + !orEqual (branch 4) + if (count($arr) <= $boundedRange) { + assertType('list', $arr); + } else { + assertType('non-empty-list&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $arr); + } +} + +/** + * @param list $arr + * @param int<2, 5> $boundedRange + */ +function rangeGreaterThanOrEqualCount(array $arr, int $boundedRange): void +{ + // $range >= count($arr) → same as count($arr) <= $range + if ($boundedRange >= count($arr)) { + assertType('list', $arr); + } else { + assertType('non-empty-list&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $arr); + } +} + +/** + * @param list $arr + * @param int<2, 5> $boundedRange + */ +function rangeLessThanOrEqualCount(array $arr, int $boundedRange): void +{ + // $range <= count($arr) → direct, orEqual=true + // True context: truthy + orEqual + min !== null (branch 3) + // False context: falsey + orEqual + max !== null (branch 1) + if ($boundedRange <= count($arr)) { + assertType('non-empty-list&hasOffsetValue(1, string)', $arr); + } else { + assertType('list', $arr); + } +} + +/** + * @param list $arr + * @param int<2, 5> $boundedRange + */ +function rangeLessThanCount(array $arr, int $boundedRange): void +{ + // $range < count($arr) → direct, orEqual=false + // True context: truthy + !orEqual + min !== null (branch 4) + // False context: falsey + !orEqual + max !== null (branch 2) + if ($boundedRange < count($arr)) { + assertType('non-empty-list&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $arr); + } else { + assertType('list', $arr); + } +} + +function whileLoopOriginal(int $length, int $quantity): void +{ + if ($length < 8) { + throw new \InvalidArgumentException(); + } + $codes = []; + while ($quantity >= 1 && count($codes) < $quantity) { + $code = ''; + for ($i = 0; $i < $length; $i++) { + $code .= 'x'; + } + if (!in_array($code, $codes, true)) { + $codes[] = $code; + } + } +} + +class HelloWorld +{ + private const MIN_LENGTH = 8; + + /** + * @return list + */ + public function generatePlainRecoveryCodes(int $length = 8, int $quantity = 8): array + { + if ($length < self::MIN_LENGTH) { + throw new \InvalidArgumentException( + $length . ' is not allowed as length for recovery codes. Must be at least ' . self::MIN_LENGTH, + 1613666803 + ); + } + $codes = []; + while ($quantity >= 1 && count($codes) < $quantity) { + $code = ''; + for ($i = 0; $i < $length; $i++) { + $code .= 'x'; + } + if (!in_array($code, $codes, true)) { + $codes[] = $code; + } + } + return $codes; + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-4700.php b/tests/PHPStan/Analyser/nsrt/bug-4700.php index 24a680e387f..49cea6c59dc 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4700.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4700.php @@ -21,8 +21,8 @@ function(array $array, int $count): void { assertType('int<1, 5>', count($a)); assertType('list{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a); } else { - assertType('0', count($a)); - assertType('array{}', $a); + assertType('int<0, 5>', count($a)); + assertType('array{}|list{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a); } }; diff --git a/tests/PHPStan/Analyser/nsrt/bug-7088.php b/tests/PHPStan/Analyser/nsrt/bug-7088.php new file mode 100644 index 00000000000..618b9255ee5 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7088.php @@ -0,0 +1,15 @@ +foo); + assertType('(SimpleXMLElement|null)', $prop->{'foo-bar'}); + assertType('(SimpleXMLElement|null)', $prop->{$x}); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug11480.php b/tests/PHPStan/Analyser/nsrt/bug11480.php index 17077d7bfc7..f80a7237ff2 100644 --- a/tests/PHPStan/Analyser/nsrt/bug11480.php +++ b/tests/PHPStan/Analyser/nsrt/bug11480.php @@ -106,7 +106,7 @@ public function intRangeCount($count): void if (count($x) >= $count) { assertType("array{'xy'}|array{0: 'ab', 1?: 'xy'}", $x); } else { - assertType("array{}", $x); + assertType("array{}|array{'xy'}|array{0: 'ab', 1?: 'xy'}", $x); } assertType("array{}|array{'xy'}|array{0: 'ab', 1?: 'xy'}", $x); } diff --git a/tests/PHPStan/Analyser/nsrt/date-format.php b/tests/PHPStan/Analyser/nsrt/date-format.php index e8a68785216..2f11a334717 100644 --- a/tests/PHPStan/Analyser/nsrt/date-format.php +++ b/tests/PHPStan/Analyser/nsrt/date-format.php @@ -45,5 +45,27 @@ function (\DateTimeImmutable $dt, string $s): void { }; function (?\DateTimeImmutable $d): void { - assertType('DateTimeImmutable|null', $d->modify('+1 day')); + assertType('DateTimeImmutable', $d->modify('+1 day')); +}; + +function (?\DateTimeImmutable $d): void { + assertType('DateTimeImmutable|null', $d?->modify('+1 day')); +}; + +class Foo extends \DateTimeImmutable {} +class Bar { + /** @return string */ + public function modify($string) {} +} +class Bar2 { + /** @return string|false */ + public function modify($string) {} +} + +function foo(Foo|Bar $d): void { + assertType('DateFormatReturnType\Foo|string', $d->modify('+1 day')); +}; + +function foo2(Foo|Bar2 $d): void { + assertType('DateFormatReturnType\Foo|string|false', $d->modify('+1 day')); }; diff --git a/tests/PHPStan/Analyser/nsrt/gmp-operators.php b/tests/PHPStan/Analyser/nsrt/gmp-operators.php new file mode 100644 index 00000000000..f9897520fbf --- /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/list-count.php b/tests/PHPStan/Analyser/nsrt/list-count.php index 4a90322e538..24bfc6fa63f 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count.php +++ b/tests/PHPStan/Analyser/nsrt/list-count.php @@ -352,7 +352,7 @@ protected function testOptionalKeysInUnionArray($row): void protected function testOptionalKeysInUnionListWithIntRange($row, $listRow, $twoOrThree, $twoOrMore, int $maxThree, $tenOrEleven, $threeOrMoreInRangeLimit, $threeOrMoreOverRangeLimit): void { if (count($row) >= $twoOrThree) { - assertType('array{0: int, 1: string|null, 2?: int|null}', $row); + assertType('list{0: int, 1: string|null, 2?: int|null, 3?: float|null}', $row); } else { assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); } @@ -376,25 +376,25 @@ protected function testOptionalKeysInUnionListWithIntRange($row, $listRow, $twoO } if (count($row) >= $threeOrMoreInRangeLimit) { - assertType('list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); + assertType('array{0: int, 1: string|null, 2: int|null, 3?: float|null}', $row); } else { assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); } if (count($listRow) >= $threeOrMoreInRangeLimit) { - assertType('list{0: string, 1: string, 2: string, 3?: string, 4?: string, 5?: string, 6?: string, 7?: string, 8?: string, 9?: string, 10?: string, 11?: string, 12?: string, 13?: string, 14?: string, 15?: string, 16?: string, 17?: string, 18?: string, 19?: string, 20?: string, 21?: string, 22?: string, 23?: string, 24?: string, 25?: string, 26?: string, 27?: string, 28?: string, 29?: string, 30?: string, 31?: string}', $listRow); + assertType('non-empty-list&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $listRow); } else { assertType('list', $listRow); } if (count($row) >= $threeOrMoreOverRangeLimit) { - assertType('list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); + assertType('array{0: int, 1: string|null, 2: int|null, 3?: float|null}', $row); } else { assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); } if (count($listRow) >= $threeOrMoreOverRangeLimit) { - assertType('non-empty-list', $listRow); + assertType('non-empty-list&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $listRow); } else { assertType('list', $listRow); } diff --git a/tests/PHPStan/Analyser/nsrt/method-call-return-type-fallback.php b/tests/PHPStan/Analyser/nsrt/method-call-return-type-fallback.php new file mode 100644 index 00000000000..9639e09e543 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/method-call-return-type-fallback.php @@ -0,0 +1,27 @@ += 8.1 + +namespace MethodCallReturnTypeFallback; + +use function PHPStan\Testing\assertType; + +enum Suit: string { + case Hearts = 'hearts'; + case Diamonds = 'diamonds'; +} + +class MyClass { + /** @return self */ + public static function from(string $value): self { + return new self(); + } +} + +/** @param class-string|class-string $class */ +function testStaticCallOnUnionWithConstant(string $class): void { + assertType('MethodCallReturnTypeFallback\MyClass|MethodCallReturnTypeFallback\Suit::Hearts', $class::from('hearts')); +} + +/** @param class-string|class-string $class */ +function testStaticCallOnUnionWithVariable(string $class, string $value): void { + assertType('MethodCallReturnTypeFallback\MyClass|MethodCallReturnTypeFallback\Suit', $class::from($value)); +} diff --git a/tests/PHPStan/Analyser/nsrt/pow.php b/tests/PHPStan/Analyser/nsrt/pow.php index 3ca27690db9..69a7a3c2224 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/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 8b5bff461b5..3964901b989 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1253,6 +1253,12 @@ public function testBug14429(): void $this->analyse([__DIR__ . '/data/bug-14429.php'], []); } + public function testBug13705(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-13705.php'], []); + } + public function testBug13799(): void { $this->treatPhpDocTypesAsCertain = true; @@ -1276,4 +1282,10 @@ public function testInTrait(): void ]); } + public function testBug12063(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-12063.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-12063.php b/tests/PHPStan/Rules/Comparison/data/bug-12063.php new file mode 100644 index 00000000000..3cfcc42eca1 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-12063.php @@ -0,0 +1,38 @@ +viewFunctions = $viewFunctions; + } + + public function iterateFunctions(): void + { + $functionMappings = [ + 'i_exist' => 'existingMethod', + 'i_dont_exist' => 'nonExistingMethod' + ]; + + $functions = []; + foreach ($functionMappings as $nameFrom => $nameTo) { + $callable = [$this->viewFunctions, $nameTo]; + if (!is_callable($callable)) { + throw new BadFunctionCallException("Function $nameTo does not exist in view functions"); + } + } + } +} diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php index 03e5554b19c..86615a292dc 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php @@ -748,4 +748,42 @@ public function testBug13806(): void ]); } + public function testBug5952(): void + { + $this->analyse([__DIR__ . '/data/bug-5952.php'], [ + [ + 'Dead catch - Exception is never thrown in the try block.', + 51, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 57, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 84, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 97, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 110, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 125, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 138, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 151, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-5952.php b/tests/PHPStan/Rules/Exceptions/data/bug-5952.php new file mode 100644 index 00000000000..9c8cc81a15c --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/bug-5952.php @@ -0,0 +1,153 @@ += 7.4 + +namespace Bug5952; + +class Foo +{ + public function __toString(): string + { + throw new \Exception(); + } +} + +$foo = new Foo(); + +try { + echo $foo; +} catch (\Exception $e) { + echo "Should be printed"; +} + +class Bar +{ + /** @throws \Exception */ + public function __toString(): string + { + throw new \Exception(); + } +} + +$bar = new Bar(); + +try { + echo $bar; +} catch (\Exception $e) { + echo "Should be printed"; +} + +class Baz +{ + /** @throws void */ + public function __toString(): string + { + return 'hello'; + } +} + +$baz = new Baz(); + +try { + echo $baz; +} catch (\Exception $e) { + echo "Should not be printed"; +} + +try { + echo 123; +} catch (\Exception $e) { + echo "Should not be printed"; +} + +/** @var int|Foo $intOrFoo */ +$intOrFoo = doFoo(); + +try { + echo $intOrFoo; +} catch (\Exception $e) { + echo "Should be printed"; +} + +/** @var int|Bar $intOrBar */ +$intOrBar = doFoo(); + +try { + echo $intOrBar; +} catch (\Exception $e) { + echo "Should be printed"; +} + +/** @var int|Baz $intOrBaz */ +$intOrBaz = doFoo(); + +try { + echo $intOrBaz; +} catch (\Exception $e) { + echo "Should not be printed"; +} + +// print statement +try { + print $foo; +} catch (\Exception $e) { + echo "Should be printed"; +} + +try { + print $baz; +} catch (\Exception $e) { + echo "Should not be printed"; +} + +// String concatenation +try { + $x = 'hello' . $foo; +} catch (\Exception $e) { + echo "Should be printed"; +} + +try { + $x = 'hello' . $baz; +} catch (\Exception $e) { + echo "Should not be printed"; +} + +// Concat assignment +try { + $x = 'hello'; + $x .= $foo; +} catch (\Exception $e) { + echo "Should be printed"; +} + +try { + $x = 'hello'; + $x .= $baz; +} catch (\Exception $e) { + echo "Should not be printed"; +} + +// String interpolation +try { + $x = "hello $foo"; +} catch (\Exception $e) { + echo "Should be printed"; +} + +try { + $x = "hello $baz"; +} catch (\Exception $e) { + echo "Should not be printed"; +} + +// String interpolation with curly braces +try { + $x = "hello {$foo}"; +} catch (\Exception $e) { + echo "Should be printed"; +} + +try { + $x = "hello {$baz}"; +} catch (\Exception $e) { + echo "Should not be printed"; +} diff --git a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php index b806acabef2..db49ecb7661 100644 --- a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php @@ -376,6 +376,16 @@ public function testConstantParameterCheckCallables(): void ]); } + public function testBug4608(): void + { + $this->analyse([__DIR__ . '/data/bug-4608-callables.php'], [ + [ + "Trying to invoke array{class@anonymous/tests/PHPStan/Rules/Functions/data/bug-4608-callables.php:5, 'abc'|'not_abc'} but it might not be a callable.", + 11, + ], + ]); + } + public function testMaybeNotCallable(): void { $errors = []; diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 2cef1b89eab..193208ae8dc 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2901,4 +2901,16 @@ public function testBug12850(): void ]); } + public function testBug4608(): void + { + $paramName = PHP_VERSION_ID >= 80000 ? 'callback' : 'function'; + $this->analyse([__DIR__ . '/data/bug-4608-call-user-func.php'], [ + [ + sprintf("Parameter #1 \$%s of function call_user_func expects callable(): mixed, array{class@anonymous/tests/PHPStan/Rules/Functions/data/bug-4608-call-user-func.php:5, 'abc'|'not_abc'} given.", $paramName), + 11, + + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-4608-call-user-func.php b/tests/PHPStan/Rules/Functions/data/bug-4608-call-user-func.php new file mode 100644 index 00000000000..29ca4e5cfd7 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-4608-call-user-func.php @@ -0,0 +1,11 @@ +checkThisOnly = false; $this->checkNullables = true; $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/constant-parameter-check-methods.php'], [ [ 'Constant SORT_REGULAR is not allowed for parameter #2 $flags of method finfo::file().', @@ -3967,6 +3968,15 @@ public function testConstantParameterCheckMethods(): void ]); } + public function testBug11073(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + + $this->analyse([__DIR__ . '/data/bug-11073.php'], []); + } + public function testBug11463(): void { $this->checkThisOnly = false; @@ -3984,4 +3994,34 @@ public function testBug11463(): void ]); } + public function testBug4608(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-4608.php'], [ + [ + 'Call to an undefined method class@anonymous/tests/PHPStan/Rules/Methods/data/bug-4608.php:5::not_abc().', + 11, + ], + ]); + } + + public function testBug11978(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-11978.php'], [ + [ + 'Method Bug11978\ViewB::render() invoked with 2 parameters, 0-1 required.', + 25, + ], + [ + 'Method Bug11978\ViewB::render() invoked with 2 parameters, 0-1 required.', + 26, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-11073.php b/tests/PHPStan/Rules/Methods/data/bug-11073.php new file mode 100644 index 00000000000..1f0d0301ff7 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-11073.php @@ -0,0 +1,15 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug11073; + +use DateTimeImmutable; + +class HelloWorld +{ + public function sayHello(?DateTimeImmutable $date): ?DateTimeImmutable + { + return $date?->modify('+1 year')->setTime(23, 59, 59); + } +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-11978.php b/tests/PHPStan/Rules/Methods/data/bug-11978.php new file mode 100644 index 00000000000..2ba628068fb --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-11978.php @@ -0,0 +1,32 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug11978; + +interface ViewA { + public function render(): string; +} +interface ViewB { + public function render(string $foo = ''): string; +} + +class Foo +{ + public function __construct( + private readonly ViewA&ViewB $view1, + private readonly ViewB&ViewA $view2, + ) {} + + public function renderFoo(string $foo): string + { + $a = $this->view1->render($foo); + $b = $this->view2->render($foo); + $c = $this->view1->render($foo, $foo); + $d = $this->view2->render($foo, $foo); + $e = $this->view1->render(); + $f = $this->view2->render(); + + return $a . $b; + } +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-4608.php b/tests/PHPStan/Rules/Methods/data/bug-4608.php new file mode 100644 index 00000000000..118ff889cef --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-4608.php @@ -0,0 +1,11 @@ +{$s}(); diff --git a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php index 9c1a95adc9f..aea4a376c12 100644 --- a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php @@ -133,21 +133,89 @@ public function testRule(): void 'Impure call to method PureMethod\ImpureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doFoo().', 296, ], + [ + 'Impure echo in pure method PureMethod\TestMagicMethods::doEcho().', + 309, + ], + [ + 'Impure echo in pure method PureMethod\TestMagicMethods::doEcho().', + 310, + ], + [ + 'Possibly impure call to method PureMethod\MaybePureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doEcho().', + 311, + ], + [ + 'Impure echo in pure method PureMethod\TestMagicMethods::doEcho().', + 311, + ], + [ + 'Impure call to method PureMethod\ImpureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doEcho().', + 312, + ], + [ + 'Impure echo in pure method PureMethod\TestMagicMethods::doEcho().', + 312, + ], + [ + 'Impure print in pure method PureMethod\TestMagicMethods::doPrint().', + 324, + ], + [ + 'Possibly impure call to method PureMethod\MaybePureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doPrint().', + 325, + ], + [ + 'Impure print in pure method PureMethod\TestMagicMethods::doPrint().', + 325, + ], + [ + 'Impure call to method PureMethod\ImpureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doPrint().', + 326, + ], + [ + 'Impure print in pure method PureMethod\TestMagicMethods::doPrint().', + 326, + ], + [ + 'Possibly impure call to method PureMethod\MaybePureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doConcat().', + 339, + ], + [ + 'Impure call to method PureMethod\ImpureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doConcat().', + 340, + ], + [ + 'Possibly impure call to method PureMethod\MaybePureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doConcatAssign().', + 355, + ], + [ + 'Impure call to method PureMethod\ImpureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doConcatAssign().', + 357, + ], + [ + 'Possibly impure call to method PureMethod\MaybePureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doInterpolation().', + 370, + ], + [ + 'Impure call to method PureMethod\ImpureMagicMethods::__toString() in pure method PureMethod\TestMagicMethods::doInterpolation().', + 371, + ], [ 'Possibly impure call to a callable in pure method PureMethod\MaybeCallableFromUnion::doFoo().', - 330, + 405, ], [ 'Possibly impure call to a callable in pure method PureMethod\MaybeCallableFromUnion::doFoo().', - 330, + 405, ], [ 'Impure static property access in pure method PureMethod\StaticMethodAccessingStaticProperty::getA().', - 388, + 463, ], [ 'Impure property assignment in pure method PureMethod\StaticMethodAssigningStaticProperty::getA().', - 409, + 484, ], ]); } diff --git a/tests/PHPStan/Rules/Pure/data/pure-method.php b/tests/PHPStan/Rules/Pure/data/pure-method.php index eca2976cda5..761afcd73b5 100644 --- a/tests/PHPStan/Rules/Pure/data/pure-method.php +++ b/tests/PHPStan/Rules/Pure/data/pure-method.php @@ -296,6 +296,81 @@ public function doFoo( (string) $impure; } + /** + * @phpstan-pure + */ + public function doEcho( + NoMagicMethods $no, + PureMagicMethods $pure, + MaybePureMagicMethods $maybe, + ImpureMagicMethods $impure + ) + { + echo $no; + echo $pure; + echo $maybe; + echo $impure; + } + + /** + * @phpstan-pure + */ + public function doPrint( + PureMagicMethods $pure, + MaybePureMagicMethods $maybe, + ImpureMagicMethods $impure + ) + { + print $pure; + print $maybe; + print $impure; + } + + /** + * @phpstan-pure + */ + public function doConcat( + PureMagicMethods $pure, + MaybePureMagicMethods $maybe, + ImpureMagicMethods $impure + ) + { + 'hello' . $pure; + 'hello' . $maybe; + 'hello' . $impure; + } + + /** + * @phpstan-pure + */ + public function doConcatAssign( + PureMagicMethods $pure, + MaybePureMagicMethods $maybe, + ImpureMagicMethods $impure + ) + { + $x = 'hello'; + $x .= $pure; + $x = 'hello'; + $x .= $maybe; + $x = 'hello'; + $x .= $impure; + } + + /** + * @phpstan-pure + */ + public function doInterpolation( + PureMagicMethods $pure, + MaybePureMagicMethods $maybe, + ImpureMagicMethods $impure + ) + { + "hello $pure"; + "hello $maybe"; + "hello $impure"; + } + } final class NoConstructor diff --git a/tests/PHPStan/Type/Php/GmpOperatorTypeSpecifyingExtensionTest.php b/tests/PHPStan/Type/Php/GmpOperatorTypeSpecifyingExtensionTest.php new file mode 100644 index 00000000000..97c50184e0f --- /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)); + } + } + +}