diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 356d0b30be..55acba449c 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -38,6 +38,7 @@ use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; +use PHPStan\Type\ComparisonAwareTypeSpecifyingExtension; use PHPStan\Type\ConditionalTypeForParameter; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantBooleanType; @@ -2005,6 +2006,47 @@ private function getTypeSpecifyingExtensionsForType(array $extensions, string $c return array_merge(...$extensionsForClass); } + private function specifyWithComparisonAwareTypeSpecifyingExtensions( + Expr\BinaryOp $binaryOp, + Expr $callExpr, + Expr\CallLike $callLike, + Type $comparisonType, + Scope $scope, + TypeSpecifierContext $context, + ?Expr $rootExpr, + ): ?SpecifiedTypes + { + if ($callLike instanceof FuncCall && $callLike->name instanceof Name) { + if (!$this->reflectionProvider->hasFunction($callLike->name, $scope)) { + return null; + } + $functionReflection = $this->reflectionProvider->getFunction($callLike->name, $scope); + + $comparisonContext = TypeSpecifierContext::createComparison( + new TypeSpecifierComparisonContext( + $binaryOp, + $callExpr, + $comparisonType, + $context, + $rootExpr, + ), + ); + foreach ($this->getFunctionTypeSpecifyingExtensions() as $extension) { + if (!$extension instanceof ComparisonAwareTypeSpecifyingExtension) { + continue; + } + + if (!$extension->isFunctionSupported($functionReflection, $callLike, $comparisonContext)) { + continue; + } + + return $extension->specifyTypes($functionReflection, $callLike, $scope, $comparisonContext); + } + } + + return null; + } + public function resolveEqual(Expr\BinaryOp\Equal $expr, Scope $scope, TypeSpecifierContext $context, ?Expr $rootExpr): SpecifiedTypes { $expressions = $this->findTypeExpressionsFromBinaryOperation($scope, $expr); @@ -2039,14 +2081,19 @@ public function resolveEqual(Expr\BinaryOp\Equal $expr, Scope $scope, TypeSpecif return $this->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context, $rootExpr); } - if ( - $context->true() - && $exprNode instanceof FuncCall - && $exprNode->name instanceof Name - && $exprNode->name->toLowerString() === 'preg_match' - && (new ConstantIntegerType(1))->isSuperTypeOf($constantType)->yes() - ) { - return $this->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context, $rootExpr); + if ($exprNode instanceof Expr\CallLike) { + $specifiedTypes = $this->specifyWithComparisonAwareTypeSpecifyingExtensions( + $expr, + $exprNode, + $exprNode, + $constantType, + $scope, + $context, + $rootExpr, + ); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } } } @@ -2138,18 +2185,21 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty $rightType = $scope->getType($rightExpr); if ( - $context->true() - && $unwrappedLeftExpr instanceof FuncCall + $unwrappedLeftExpr instanceof FuncCall && $unwrappedLeftExpr->name instanceof Name - && $unwrappedLeftExpr->name->toLowerString() === 'preg_match' - && (new ConstantIntegerType(1))->isSuperTypeOf($rightType)->yes() ) { - return $this->specifyTypesInCondition( - $scope, + $specifiedTypes = $this->specifyWithComparisonAwareTypeSpecifyingExtensions( + $expr, $leftExpr, + $unwrappedLeftExpr, + $rightType, + $scope, $context, $rootExpr, ); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } } if ( diff --git a/src/Analyser/TypeSpecifierComparisonContext.php b/src/Analyser/TypeSpecifierComparisonContext.php new file mode 100644 index 0000000000..8fa49d2f36 --- /dev/null +++ b/src/Analyser/TypeSpecifierComparisonContext.php @@ -0,0 +1,48 @@ +binaryOp; + } + + public function getCallExpr(): Expr + { + return $this->callExpr; + } + + public function getComparisonType(): Type + { + return $this->comparisonType; + } + + public function getTypeSpecifierContext(): TypeSpecifierContext + { + return $this->context; + } + + public function getRootExpr(): ?Expr + { + return $this->rootExpr; + } + +} diff --git a/src/Analyser/TypeSpecifierContext.php b/src/Analyser/TypeSpecifierContext.php index 3cd0ead0f9..1ca63ca41a 100644 --- a/src/Analyser/TypeSpecifierContext.php +++ b/src/Analyser/TypeSpecifierContext.php @@ -17,19 +17,29 @@ class TypeSpecifierContext public const CONTEXT_FALSE = 0b0100; public const CONTEXT_FALSEY_BUT_NOT_FALSE = 0b1000; public const CONTEXT_FALSEY = self::CONTEXT_FALSE | self::CONTEXT_FALSEY_BUT_NOT_FALSE; - public const CONTEXT_BITMASK = 0b1111; + public const CONTEXT_COMPARISON = 0b10000; + public const CONTEXT_BITMASK = 0b01111; /** @var self[] */ private static array $registry; - private function __construct(private ?int $value) + private function __construct( + private ?int $value, + private ?TypeSpecifierComparisonContext $comparisonContext, + ) { } - private static function create(?int $value): self + private static function create(?int $value, ?TypeSpecifierComparisonContext $comparisonContext = null): self { - self::$registry[$value] ??= new self($value); - return self::$registry[$value]; + if ($value !== self::CONTEXT_COMPARISON) { + self::$registry[$value] ??= new self($value, $comparisonContext); + return self::$registry[$value]; + } + + // each comparison context contains context dependent properties + // and therefore cannot be cached/re-used + return new self($value, $comparisonContext); } public static function createTrue(): self @@ -52,6 +62,11 @@ public static function createFalsey(): self return self::create(self::CONTEXT_FALSEY); } + public static function createComparison(TypeSpecifierComparisonContext $comparisonContext): self + { + return self::create(self::CONTEXT_COMPARISON, $comparisonContext); + } + public static function createNull(): self { return self::create(null); @@ -90,4 +105,9 @@ public function null(): bool return $this->value === null; } + public function comparison(): ?TypeSpecifierComparisonContext + { + return $this->comparisonContext; + } + } diff --git a/src/Type/ComparisonAwareTypeSpecifyingExtension.php b/src/Type/ComparisonAwareTypeSpecifyingExtension.php new file mode 100644 index 0000000000..5783a1bdc1 --- /dev/null +++ b/src/Type/ComparisonAwareTypeSpecifyingExtension.php @@ -0,0 +1,18 @@ +comparison(); + if ($comparisonContext !== null) { + $binaryOp = $comparisonContext->getBinaryOp(); + if ( + ($binaryOp instanceof Equal || $binaryOp instanceof Identical) + && $comparisonContext->getTypeSpecifierContext()->true() + && (new ConstantIntegerType(1))->isSuperTypeOf($comparisonContext->getComparisonType())->yes() + ) { + return $this->typeSpecifier->specifyTypesInCondition( + $scope, + $comparisonContext->getCallExpr(), + $comparisonContext->getTypeSpecifierContext(), + $comparisonContext->getRootExpr(), + ); + } + + return new SpecifiedTypes(); + } + $args = $node->getArgs(); $patternArg = $args[0] ?? null; $matchesArg = $args[2] ?? null; diff --git a/tests/PHPStan/Analyser/TypeSpecifierContextTest.php b/tests/PHPStan/Analyser/TypeSpecifierContextTest.php index c6e083ed5d..8482c60aec 100644 --- a/tests/PHPStan/Analyser/TypeSpecifierContextTest.php +++ b/tests/PHPStan/Analyser/TypeSpecifierContextTest.php @@ -2,8 +2,12 @@ namespace PHPStan\Analyser; +use PhpParser\Node\Expr\BinaryOp\Equal; +use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Scalar\String_; use PHPStan\ShouldNotHappenException; use PHPStan\Testing\PHPStanTestCase; +use PHPStan\Type\NullType; class TypeSpecifierContextTest extends PHPStanTestCase { @@ -13,23 +17,27 @@ public function dataContext(): array return [ [ TypeSpecifierContext::createTrue(), - [true, true, false, false, false], + [true, true, false, false, false, false], ], [ TypeSpecifierContext::createTruthy(), - [true, true, false, false, false], + [true, true, false, false, false, false], ], [ TypeSpecifierContext::createFalse(), - [false, false, true, true, false], + [false, false, true, true, false, false], ], [ TypeSpecifierContext::createFalsey(), - [false, false, true, true, false], + [false, false, true, true, false, false], ], [ TypeSpecifierContext::createNull(), - [false, false, false, false, true], + [false, false, false, false, true, false], + ], + [ + $this->createComparisonContext(), + [false, false, false, false, false, true], ], ]; } @@ -45,6 +53,12 @@ public function testContext(TypeSpecifierContext $context, array $results): void $this->assertSame($results[2], $context->false()); $this->assertSame($results[3], $context->falsey()); $this->assertSame($results[4], $context->null()); + + if ($results[5]) { + $this->assertNotNull($context->comparison()); + } else { + $this->assertNull($context->comparison()); + } } public function dataNegate(): array @@ -52,20 +66,27 @@ public function dataNegate(): array return [ [ TypeSpecifierContext::createTrue()->negate(), - [false, true, true, true, false], + [false, true, true, true, false, false], ], [ TypeSpecifierContext::createTruthy()->negate(), - [false, false, true, true, false], + [false, false, true, true, false, false], ], [ TypeSpecifierContext::createFalse()->negate(), - [true, true, false, true, false], + [true, true, false, true, false, false], ], [ TypeSpecifierContext::createFalsey()->negate(), - [true, true, false, false, false], + [true, true, false, false, false, false], + ], + /* + // XXX should a comparison context be negatable? + [ + $this->createComparisonContext()->negate(), + [false, false, false, false, false, true], ], + */ ]; } @@ -80,6 +101,12 @@ public function testNegate(TypeSpecifierContext $context, array $results): void $this->assertSame($results[2], $context->false()); $this->assertSame($results[3], $context->falsey()); $this->assertSame($results[4], $context->null()); + + if ($results[5]) { + $this->assertNotNull($context->comparison()); + } else { + $this->assertNull($context->comparison()); + } } public function testNegateNull(): void @@ -88,4 +115,17 @@ public function testNegateNull(): void TypeSpecifierContext::createNull()->negate(); } + private function createComparisonContext(): TypeSpecifierContext + { + return TypeSpecifierContext::createComparison( + new TypeSpecifierComparisonContext( + new Equal(new String_('dummy'), new String_('dummy2')), + new FuncCall('dummyFunc'), + new NullType(), + TypeSpecifierContext::createNull(), + null, + ), + ); + } + }