Skip to content

Commit 700a601

Browse files
committed
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 cdd76d2 commit 700a601

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;
@@ -2898,6 +2899,60 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty
28982899
return $specifiedTypes;
28992900
}
29002901

2902+
/**
2903+
* Returns the inner expression E when $expr casts E to a string and back to an int,
2904+
* i.e. `(string) (int) E`, `strval(intval(E))` or any mix of the cast/function forms.
2905+
* This is the canonical "decimal integer string" round-trip that
2906+
* ConstantStringType::isDecimalIntegerString() checks with `(string) (int) $value === $value`.
2907+
*/
2908+
private function getDecimalIntegerStringCastedExpr(Expr $expr): ?Expr
2909+
{
2910+
$intCasted = $this->getStringCastedExpr($expr);
2911+
if ($intCasted === null) {
2912+
return null;
2913+
}
2914+
2915+
return $this->getIntCastedExpr($intCasted);
2916+
}
2917+
2918+
private function getStringCastedExpr(Expr $expr): ?Expr
2919+
{
2920+
if ($expr instanceof Expr\Cast\String_) {
2921+
return $expr->expr;
2922+
}
2923+
2924+
if (
2925+
$expr instanceof FuncCall
2926+
&& $expr->name instanceof Name
2927+
&& !$expr->isFirstClassCallable()
2928+
&& strtolower($expr->name->toString()) === 'strval'
2929+
&& count($expr->getArgs()) === 1
2930+
) {
2931+
return $expr->getArgs()[0]->value;
2932+
}
2933+
2934+
return null;
2935+
}
2936+
2937+
private function getIntCastedExpr(Expr $expr): ?Expr
2938+
{
2939+
if ($expr instanceof Expr\Cast\Int_) {
2940+
return $expr->expr;
2941+
}
2942+
2943+
if (
2944+
$expr instanceof FuncCall
2945+
&& $expr->name instanceof Name
2946+
&& !$expr->isFirstClassCallable()
2947+
&& strtolower($expr->name->toString()) === 'intval'
2948+
&& count($expr->getArgs()) === 1
2949+
) {
2950+
return $expr->getArgs()[0]->value;
2951+
}
2952+
2953+
return null;
2954+
}
2955+
29012956
private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes
29022957
{
29032958
$leftExpr = $expr->left;
@@ -3160,6 +3215,37 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
31603215
}
31613216
}
31623217

3218+
// (string) (int) $x === $x (and the strval(intval()) equivalents)
3219+
if (!$context->null()) {
3220+
$leftCastedExpr = $this->getDecimalIntegerStringCastedExpr($unwrappedLeftExpr);
3221+
$rightCastedExpr = $this->getDecimalIntegerStringCastedExpr($unwrappedRightExpr);
3222+
3223+
$decimalValueExpr = null;
3224+
if (
3225+
$leftCastedExpr !== null
3226+
&& $this->exprPrinter->printExpr($leftCastedExpr) === $this->exprPrinter->printExpr($unwrappedRightExpr)
3227+
) {
3228+
$decimalValueExpr = $unwrappedRightExpr;
3229+
} elseif (
3230+
$rightCastedExpr !== null
3231+
&& $this->exprPrinter->printExpr($rightCastedExpr) === $this->exprPrinter->printExpr($unwrappedLeftExpr)
3232+
) {
3233+
$decimalValueExpr = $unwrappedLeftExpr;
3234+
}
3235+
3236+
if ($decimalValueExpr !== null) {
3237+
$decimalValueType = $scope->getType($decimalValueExpr);
3238+
if ($decimalValueType->isString()->yes()) {
3239+
return $this->create(
3240+
$decimalValueExpr,
3241+
TypeCombinator::intersect($decimalValueType, new AccessoryDecimalIntegerStringType($context->falsey())),
3242+
TypeSpecifierContext::createTruthy(),
3243+
$scope,
3244+
)->setRootExpr($expr);
3245+
}
3246+
}
3247+
}
3248+
31633249
if ($rightType->isString()->yes()) {
31643250
$types = null;
31653251
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)