From 700a601ea3ec75adfb330e06ff0dfe02eaf79d23 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:59:36 +0000 Subject: [PATCH] Narrow to `decimal-int-string`/`non-decimal-int-string` from `(string) (int) $x === $x` comparisons - Add a special case to `TypeSpecifier::resolveNormalizedIdentical()` that detects the canonical decimal-int round-trip `(string) (int) $x === $x` (the same check `ConstantStringType::isDecimalIntegerString()` performs) and narrows `$x`. - In the truthy branch `$x` is intersected with `AccessoryDecimalIntegerStringType` (`decimal-int-string`); in the falsey branch with its inverse (`non-decimal-int-string`). The truthy direction already worked through the generic identical fallback, but the falsey direction did not because the accessory type is non-removeable. - Detection is symmetric in operand order and recognizes both the cast forms and the `strval()`/`intval()` function forms, in any mix (`strval((int) $x)`, `(string) intval($x)`, ...), via the new `getStringCastedExpr()` / `getIntCastedExpr()` helpers. - Narrowing is only applied when the compared expression is already known to be a string, so `int|string` operands keep their int part in the falsey branch. --- src/Analyser/TypeSpecifier.php | 86 +++++++++++++++++++ .../Analyser/nsrt/decimal-int-string-cast.php | 69 +++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/decimal-int-string-cast.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index fe4ec9b65d..7bd1b200bc 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -37,6 +37,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; @@ -2898,6 +2899,60 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty return $specifiedTypes; } + /** + * Returns the inner expression E when $expr casts E to a string and back to an int, + * i.e. `(string) (int) E`, `strval(intval(E))` or any mix of the cast/function forms. + * This is the canonical "decimal integer string" round-trip that + * ConstantStringType::isDecimalIntegerString() checks with `(string) (int) $value === $value`. + */ + private function getDecimalIntegerStringCastedExpr(Expr $expr): ?Expr + { + $intCasted = $this->getStringCastedExpr($expr); + if ($intCasted === null) { + return null; + } + + return $this->getIntCastedExpr($intCasted); + } + + private function getStringCastedExpr(Expr $expr): ?Expr + { + if ($expr instanceof Expr\Cast\String_) { + return $expr->expr; + } + + if ( + $expr instanceof FuncCall + && $expr->name instanceof Name + && !$expr->isFirstClassCallable() + && strtolower($expr->name->toString()) === 'strval' + && count($expr->getArgs()) === 1 + ) { + return $expr->getArgs()[0]->value; + } + + return null; + } + + private function getIntCastedExpr(Expr $expr): ?Expr + { + if ($expr instanceof Expr\Cast\Int_) { + return $expr->expr; + } + + if ( + $expr instanceof FuncCall + && $expr->name instanceof Name + && !$expr->isFirstClassCallable() + && strtolower($expr->name->toString()) === 'intval' + && count($expr->getArgs()) === 1 + ) { + return $expr->getArgs()[0]->value; + } + + return null; + } + private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes { $leftExpr = $expr->left; @@ -3160,6 +3215,37 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope } } + // (string) (int) $x === $x (and the strval(intval()) equivalents) + if (!$context->null()) { + $leftCastedExpr = $this->getDecimalIntegerStringCastedExpr($unwrappedLeftExpr); + $rightCastedExpr = $this->getDecimalIntegerStringCastedExpr($unwrappedRightExpr); + + $decimalValueExpr = null; + if ( + $leftCastedExpr !== null + && $this->exprPrinter->printExpr($leftCastedExpr) === $this->exprPrinter->printExpr($unwrappedRightExpr) + ) { + $decimalValueExpr = $unwrappedRightExpr; + } elseif ( + $rightCastedExpr !== null + && $this->exprPrinter->printExpr($rightCastedExpr) === $this->exprPrinter->printExpr($unwrappedLeftExpr) + ) { + $decimalValueExpr = $unwrappedLeftExpr; + } + + if ($decimalValueExpr !== null) { + $decimalValueType = $scope->getType($decimalValueExpr); + if ($decimalValueType->isString()->yes()) { + return $this->create( + $decimalValueExpr, + TypeCombinator::intersect($decimalValueType, new AccessoryDecimalIntegerStringType($context->falsey())), + TypeSpecifierContext::createTruthy(), + $scope, + )->setRootExpr($expr); + } + } + } + if ($rightType->isString()->yes()) { $types = null; foreach ($rightType->getConstantStrings() as $constantString) { diff --git a/tests/PHPStan/Analyser/nsrt/decimal-int-string-cast.php b/tests/PHPStan/Analyser/nsrt/decimal-int-string-cast.php new file mode 100644 index 0000000000..2bca282bc5 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/decimal-int-string-cast.php @@ -0,0 +1,69 @@ +