Skip to content

Commit 3e7d13d

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 444acab commit 3e7d13d

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;
@@ -2967,6 +2968,60 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty
29672968
return $specifiedTypes;
29682969
}
29692970

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

3287+
// (string) (int) $x === $x (and the strval(intval()) equivalents)
3288+
if (!$context->null()) {
3289+
$leftCastedExpr = $this->getDecimalIntegerStringCastedExpr($unwrappedLeftExpr);
3290+
$rightCastedExpr = $this->getDecimalIntegerStringCastedExpr($unwrappedRightExpr);
3291+
3292+
$decimalValueExpr = null;
3293+
if (
3294+
$leftCastedExpr !== null
3295+
&& $this->exprPrinter->printExpr($leftCastedExpr) === $this->exprPrinter->printExpr($unwrappedRightExpr)
3296+
) {
3297+
$decimalValueExpr = $unwrappedRightExpr;
3298+
} elseif (
3299+
$rightCastedExpr !== null
3300+
&& $this->exprPrinter->printExpr($rightCastedExpr) === $this->exprPrinter->printExpr($unwrappedLeftExpr)
3301+
) {
3302+
$decimalValueExpr = $unwrappedLeftExpr;
3303+
}
3304+
3305+
if ($decimalValueExpr !== null) {
3306+
$decimalValueType = $scope->getType($decimalValueExpr);
3307+
if ($decimalValueType->isString()->yes()) {
3308+
return $this->create(
3309+
$decimalValueExpr,
3310+
TypeCombinator::intersect($decimalValueType, new AccessoryDecimalIntegerStringType($context->falsey())),
3311+
TypeSpecifierContext::createTruthy(),
3312+
$scope,
3313+
)->setRootExpr($expr);
3314+
}
3315+
}
3316+
}
3317+
32323318
if ($rightType->isString()->yes()) {
32333319
$types = null;
32343320
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)