diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 4f3e7f9c43..3014868b99 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2934,6 +2934,21 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope } } + // (cast)$expr === value - propagate narrowing to inner expression + if ( + !$context->null() + && $unwrappedLeftExpr instanceof Expr\Cast + ) { + $castProducingType = $this->determineCastProducingType($unwrappedLeftExpr, $rightType); + if ($castProducingType !== null) { + $innerExpr = $unwrappedLeftExpr->expr; + $result = $this->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + return $result->unionWith( + $this->create($innerExpr, $castProducingType, $context, $scope)->setRootExpr($expr), + ); + } + } + $expressions = $this->findTypeExpressionsFromBinaryOperation($scope, $expr); if ($expressions !== null) { $exprNode = $expressions[0]; @@ -3123,4 +3138,40 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope return (new SpecifiedTypes([], []))->setRootExpr($expr); } + /** + * Given a cast expression and the value it's compared to, + * returns the union of types that produce that value when cast. + */ + private function determineCastProducingType(Expr\Cast $cast, Type $comparedType): ?Type + { + if ($cast instanceof Expr\Cast\String_) { + $constantStrings = $comparedType->getConstantStrings(); + if (count($constantStrings) === 1 && $constantStrings[0]->getValue() === '') { + // Types that produce '' when cast to string: null, false, '' + return TypeCombinator::union( + new NullType(), + new ConstantBooleanType(false), + new ConstantStringType(''), + ); + } + } + + if ($cast instanceof Expr\Cast\Int_) { + $constantScalars = $comparedType->getConstantScalarValues(); + if (count($constantScalars) === 1 && $constantScalars[0] === 0) { + // Types that produce 0 when cast to int: null, false, 0, 0.0, '', '0' + return TypeCombinator::union( + new NullType(), + new ConstantBooleanType(false), + new ConstantIntegerType(0), + new ConstantFloatType(0.0), + new ConstantStringType(''), + new ConstantStringType('0'), + ); + } + } + + return null; + } + } diff --git a/tests/PHPStan/Analyser/nsrt/bug-8231.php b/tests/PHPStan/Analyser/nsrt/bug-8231.php new file mode 100644 index 0000000000..7d3ab75a97 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-8231.php @@ -0,0 +1,75 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug8231; + +use function PHPStan\Testing\assertType; + +function foo(string $x): void {} + +function test(string|null $x): void { + if ((string)$x !== '') { + assertType('non-empty-string', $x); + foo($x); + } +} + +function testIdentical(string|null $x): void { + if ((string)$x === '') { + assertType("''|null", $x); + } else { + assertType('non-empty-string', $x); + } +} + +function testInt(int|null $x): void { + if ((string)$x !== '') { + assertType('int', $x); + } +} + +function testIntString(int|string|null $x): void { + if ((string)$x !== '') { + assertType('int|non-empty-string', $x); + } +} + +function testBool(bool|string|null $x): void { + if ((string)$x !== '') { + assertType('non-empty-string|true', $x); + } +} + +// (int) cast narrowing +function testIntCast(int|null $x): void { + if ((int)$x !== 0) { + assertType('int|int<1, max>', $x); + } +} + +function testIntCastIdentical(int|null $x): void { + if ((int)$x === 0) { + assertType('0|null', $x); + } else { + assertType('int|int<1, max>', $x); + } +} + +function testIntCastWithString(int|string|null $x): void { + if ((int)$x !== 0) { + assertType("int|int<1, max>|non-falsy-string", $x); + } +} + +function testIntCastWithFloat(float|null $x): void { + if ((int)$x !== 0) { + assertType('float', $x); + } +} + +function testIntCastWithBool(bool|int|null $x): void { + if ((int)$x !== 0) { + assertType('int|int<1, max>|true', $x); + } +}