Skip to content

Commit ff4a32d

Browse files
phpstan-botstaabm
authored andcommitted
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.
1 parent 792edea commit ff4a32d

2 files changed

Lines changed: 155 additions & 0 deletions

File tree

src/Analyser/TypeSpecifier.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
use PHPStan\ShouldNotHappenException;
3838
use PHPStan\TrinaryLogic;
3939
use PHPStan\Type\Accessory\AccessoryArrayListType;
40+
use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType;
4041
use PHPStan\Type\Accessory\AccessoryLowercaseStringType;
4142
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
4243
use PHPStan\Type\Accessory\AccessoryNonFalsyStringType;
@@ -2960,6 +2961,60 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty
29602961
return $specifiedTypes;
29612962
}
29622963

2964+
/**
2965+
* Returns the inner expression E when $expr casts E to a string and back to an int,
2966+
* i.e. `(string) (int) E`, `strval(intval(E))` or any mix of the cast/function forms.
2967+
* This is the canonical "decimal integer string" round-trip that
2968+
* ConstantStringType::isDecimalIntegerString() checks with `(string) (int) $value === $value`.
2969+
*/
2970+
private function getDecimalIntegerStringCastedExpr(Expr $expr): ?Expr
2971+
{
2972+
$intCasted = $this->getStringCastedExpr($expr);
2973+
if ($intCasted === null) {
2974+
return null;
2975+
}
2976+
2977+
return $this->getIntCastedExpr($intCasted);
2978+
}
2979+
2980+
private function getStringCastedExpr(Expr $expr): ?Expr
2981+
{
2982+
if ($expr instanceof Expr\Cast\String_) {
2983+
return $expr->expr;
2984+
}
2985+
2986+
if (
2987+
$expr instanceof FuncCall
2988+
&& $expr->name instanceof Name
2989+
&& !$expr->isFirstClassCallable()
2990+
&& strtolower($expr->name->toString()) === 'strval'
2991+
&& count($expr->getArgs()) === 1
2992+
) {
2993+
return $expr->getArgs()[0]->value;
2994+
}
2995+
2996+
return null;
2997+
}
2998+
2999+
private function getIntCastedExpr(Expr $expr): ?Expr
3000+
{
3001+
if ($expr instanceof Expr\Cast\Int_) {
3002+
return $expr->expr;
3003+
}
3004+
3005+
if (
3006+
$expr instanceof FuncCall
3007+
&& $expr->name instanceof Name
3008+
&& !$expr->isFirstClassCallable()
3009+
&& strtolower($expr->name->toString()) === 'intval'
3010+
&& count($expr->getArgs()) === 1
3011+
) {
3012+
return $expr->getArgs()[0]->value;
3013+
}
3014+
3015+
return null;
3016+
}
3017+
29633018
private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes
29643019
{
29653020
$leftExpr = $expr->left;
@@ -3222,6 +3277,37 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
32223277
}
32233278
}
32243279

3280+
// (string) (int) $x === $x (and the strval(intval()) equivalents)
3281+
if (!$context->null()) {
3282+
$leftCastedExpr = $this->getDecimalIntegerStringCastedExpr($unwrappedLeftExpr);
3283+
$rightCastedExpr = $this->getDecimalIntegerStringCastedExpr($unwrappedRightExpr);
3284+
3285+
$decimalValueExpr = null;
3286+
if (
3287+
$leftCastedExpr !== null
3288+
&& $this->exprPrinter->printExpr($leftCastedExpr) === $this->exprPrinter->printExpr($unwrappedRightExpr)
3289+
) {
3290+
$decimalValueExpr = $unwrappedRightExpr;
3291+
} elseif (
3292+
$rightCastedExpr !== null
3293+
&& $this->exprPrinter->printExpr($rightCastedExpr) === $this->exprPrinter->printExpr($unwrappedLeftExpr)
3294+
) {
3295+
$decimalValueExpr = $unwrappedLeftExpr;
3296+
}
3297+
3298+
if ($decimalValueExpr !== null) {
3299+
$decimalValueType = $scope->getType($decimalValueExpr);
3300+
if ($decimalValueType->isString()->yes()) {
3301+
return $this->create(
3302+
$decimalValueExpr,
3303+
TypeCombinator::intersect($decimalValueType, new AccessoryDecimalIntegerStringType($context->falsey())),
3304+
TypeSpecifierContext::createTruthy(),
3305+
$scope,
3306+
)->setRootExpr($expr);
3307+
}
3308+
}
3309+
}
3310+
32253311
if ($rightType->isString()->yes()) {
32263312
$types = null;
32273313
foreach ($rightType->getConstantStrings() as $constantString) {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace DecimalIntStringCast;
4+
5+
use function PHPStan\Testing\assertType;
6+
use function intval;
7+
use function strval;
8+
9+
class Foo
10+
{
11+
12+
public function castIdentical(string $s): void
13+
{
14+
if ((string) (int) $s === $s) {
15+
assertType('decimal-int-string', $s);
16+
} else {
17+
assertType('non-decimal-int-string', $s);
18+
}
19+
}
20+
21+
public function castIdenticalFlipped(string $s): void
22+
{
23+
if ($s === (string) (int) $s) {
24+
assertType('decimal-int-string', $s);
25+
} else {
26+
assertType('non-decimal-int-string', $s);
27+
}
28+
}
29+
30+
public function castNotIdentical(string $s): void
31+
{
32+
if ((string) (int) $s !== $s) {
33+
assertType('non-decimal-int-string', $s);
34+
} else {
35+
assertType('decimal-int-string', $s);
36+
}
37+
}
38+
39+
public function strvalIntval(string $s): void
40+
{
41+
if (strval(intval($s)) === $s) {
42+
assertType('decimal-int-string', $s);
43+
} else {
44+
assertType('non-decimal-int-string', $s);
45+
}
46+
}
47+
48+
public function mixedCastForms(string $s): void
49+
{
50+
if (strval((int) $s) === $s) {
51+
assertType('decimal-int-string', $s);
52+
}
53+
54+
if ((string) intval($s) === $s) {
55+
assertType('decimal-int-string', $s);
56+
}
57+
}
58+
59+
public function notAlwaysString(int|string $s): void
60+
{
61+
if ((string) (int) $s === $s) {
62+
assertType('decimal-int-string', $s);
63+
} else {
64+
// $s can still be an int here, so we cannot narrow to non-decimal-int-string
65+
assertType('int|string', $s);
66+
}
67+
}
68+
69+
}

0 commit comments

Comments
 (0)