From 86a84708c94076580d29f3a06331f02640ce3d46 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 31 Oct 2026 10:16:53 +0100 Subject: [PATCH 1/6] UselessCastRule on native types is auto-fixable --- src/Rules/Cast/UselessCastRule.php | 53 ++++++++------ tests/Rules/Cast/UselessCastRuleTest.php | 25 ++++++- .../Rules/Cast/data/useless-cast-fixable.php | 71 +++++++++++++++++++ .../Cast/data/useless-cast-fixable.php.fixed | 71 +++++++++++++++++++ 4 files changed, 198 insertions(+), 22 deletions(-) create mode 100644 tests/Rules/Cast/data/useless-cast-fixable.php create mode 100644 tests/Rules/Cast/data/useless-cast-fixable.php.fixed diff --git a/src/Rules/Cast/UselessCastRule.php b/src/Rules/Cast/UselessCastRule.php index 4e0be95..c84c97a 100644 --- a/src/Rules/Cast/UselessCastRule.php +++ b/src/Rules/Cast/UselessCastRule.php @@ -3,8 +3,11 @@ namespace PHPStan\Rules\Cast; use PhpParser\Node; +use PhpParser\Node\Expr; use PhpParser\Node\Expr\Cast; use PhpParser\Node\Expr\Cast\Void_; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitorAbstract; use PHPStan\Analyser\Scope; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; @@ -55,28 +58,36 @@ public function processNode(Node $node, Scope $scope): array $expressionType = $scope->getNativeType($node->expr); } if ($castType->isSuperTypeOf($expressionType)->yes()) { - $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node, $castType): RuleErrorBuilder { - if (!$this->treatPhpDocTypesAsCertain) { - return $ruleErrorBuilder; - } - - if (!$this->treatPhpDocTypesAsCertainTip) { - return $ruleErrorBuilder; - } - - $expressionTypeWithoutPhpDoc = $scope->getNativeType($node->expr); - if ($castType->isSuperTypeOf($expressionTypeWithoutPhpDoc)->yes()) { - return $ruleErrorBuilder; - } - - return $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); - }; + $ruleErrorBuilder = RuleErrorBuilder::message(sprintf( + 'Casting to %s something that\'s already %s.', + $castType->describe(VerbosityLevel::typeOnly()), + $expressionType->describe(VerbosityLevel::typeOnly()), + ))->identifier('cast.useless'); + + if ($castType->isSuperTypeOf($scope->getNativeType($node->expr))->yes()) { + $ruleErrorBuilder->fixNode($node, static function (Cast $node): Node { + // Reprint the operand tree so parentheses reflect operator precedence. + $traverser = new NodeTraverser(new class extends NodeVisitorAbstract { + + public function enterNode(Node $node): ?Node + { + if ($node instanceof Expr) { + $node->setAttribute('origNode', null); + } + + return null; + } + + }); + + return $traverser->traverse([$node->expr])[0]; + }); + } elseif ($this->treatPhpDocTypesAsCertain && $this->treatPhpDocTypesAsCertainTip) { + $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); + } + return [ - $addTip(RuleErrorBuilder::message(sprintf( - 'Casting to %s something that\'s already %s.', - $castType->describe(VerbosityLevel::typeOnly()), - $expressionType->describe(VerbosityLevel::typeOnly()), - )))->identifier('cast.useless')->build(), + $ruleErrorBuilder->build(), ]; } diff --git a/tests/Rules/Cast/UselessCastRuleTest.php b/tests/Rules/Cast/UselessCastRuleTest.php index e0297e8..1b403a3 100644 --- a/tests/Rules/Cast/UselessCastRuleTest.php +++ b/tests/Rules/Cast/UselessCastRuleTest.php @@ -13,11 +13,13 @@ class UselessCastRuleTest extends RuleTestCase private bool $treatPhpDocTypesAsCertain; + private bool $treatPhpDocTypesAsCertainTip = true; + protected function getRule(): Rule { return new UselessCastRule( $this->treatPhpDocTypesAsCertain, - true, + $this->treatPhpDocTypesAsCertainTip, ); } @@ -95,4 +97,25 @@ public function testBug14565(): void $this->analyse([__DIR__ . '/data/bug-14565.php'], []); } + /** + * @return iterable + */ + public function dataFix(): iterable + { + yield [true, true]; + yield [true, false]; + yield [false, true]; + yield [false, false]; + } + + /** + * @dataProvider dataFix + */ + public function testFix(bool $treatPhpDocTypesAsCertain, bool $treatPhpDocTypesAsCertainTip): void + { + $this->treatPhpDocTypesAsCertain = $treatPhpDocTypesAsCertain; + $this->treatPhpDocTypesAsCertainTip = $treatPhpDocTypesAsCertainTip; + $this->fix(__DIR__ . '/data/useless-cast-fixable.php', __DIR__ . '/data/useless-cast-fixable.php.fixed'); + } + } diff --git a/tests/Rules/Cast/data/useless-cast-fixable.php b/tests/Rules/Cast/data/useless-cast-fixable.php new file mode 100644 index 0000000..1f7081a --- /dev/null +++ b/tests/Rules/Cast/data/useless-cast-fixable.php @@ -0,0 +1,71 @@ + $phpDocArray + * @param stdClass $phpDocObject + * @param non-empty-string $string + */ +function casts( + bool $boolean, + int $integer, + float $float, + string $string, + array $array, + stdClass $object, + ?int $nullableInteger, + $phpDocBoolean, + $phpDocInteger, + $phpDocFloat, + $phpDocString, + $phpDocArray, + $phpDocObject, + $mixed +): void +{ + (bool) $boolean; + (int) $integer; + (float) $float; + (string) $string; + (array) $array; + (object) $object; + + (bool) $phpDocBoolean; + (int) $phpDocInteger; + (float) $phpDocFloat; + (string) $phpDocString; + (array) $phpDocArray; + (object) $phpDocObject; + + (string) $integer; + (int) $nullableInteger; + (int) $mixed; + + if ($nullableInteger !== null) { + (int) $nullableInteger; + } + if (is_int($mixed)) { + (int) $mixed; + } + + (int) 42; + (bool) true; + (string) 'value'; + (array) [1, 2]; + (object) new stdClass(); + (int) ($integer + 1) * 2; + 2 - (int) ($integer + 1); + (bool) ($boolean || true) && false; + (int) ($integer = 1) + 2; + (int) ($integer + 1) ** 2; + (int) ($boolean ? $integer : 0); + (int) ++$integer; + (int) strlen($string); +} diff --git a/tests/Rules/Cast/data/useless-cast-fixable.php.fixed b/tests/Rules/Cast/data/useless-cast-fixable.php.fixed new file mode 100644 index 0000000..0fb3f0b --- /dev/null +++ b/tests/Rules/Cast/data/useless-cast-fixable.php.fixed @@ -0,0 +1,71 @@ + $phpDocArray + * @param stdClass $phpDocObject + * @param non-empty-string $string + */ +function casts( + bool $boolean, + int $integer, + float $float, + string $string, + array $array, + stdClass $object, + ?int $nullableInteger, + $phpDocBoolean, + $phpDocInteger, + $phpDocFloat, + $phpDocString, + $phpDocArray, + $phpDocObject, + $mixed +): void +{ + $boolean; + $integer; + $float; + $string; + $array; + $object; + + (bool) $phpDocBoolean; + (int) $phpDocInteger; + (float) $phpDocFloat; + (string) $phpDocString; + (array) $phpDocArray; + (object) $phpDocObject; + + (string) $integer; + (int) $nullableInteger; + (int) $mixed; + + if ($nullableInteger !== null) { + $nullableInteger; + } + if (is_int($mixed)) { + $mixed; + } + + 42; + true; + 'value'; + [1, 2]; + new stdClass(); + ($integer + 1) * 2; + 2 - ($integer + 1); + ($boolean || true) && false; + ($integer = 1) + 2; + ($integer + 1) ** 2; + $boolean ? $integer : 0; + ++$integer; + strlen($string); +} From 82dab5cd029a967c00d96f15f7e979b0a3726f5e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 31 Oct 2026 10:20:32 +0100 Subject: [PATCH 2/6] fix php-parser compat --- src/Rules/Cast/UselessCastRule.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Rules/Cast/UselessCastRule.php b/src/Rules/Cast/UselessCastRule.php index c84c97a..dac80cd 100644 --- a/src/Rules/Cast/UselessCastRule.php +++ b/src/Rules/Cast/UselessCastRule.php @@ -67,7 +67,8 @@ public function processNode(Node $node, Scope $scope): array if ($castType->isSuperTypeOf($scope->getNativeType($node->expr))->yes()) { $ruleErrorBuilder->fixNode($node, static function (Cast $node): Node { // Reprint the operand tree so parentheses reflect operator precedence. - $traverser = new NodeTraverser(new class extends NodeVisitorAbstract { + $traverser = new NodeTraverser(); + $traverser->addVisitor(new class extends NodeVisitorAbstract { public function enterNode(Node $node): ?Node { From 526a58ecc18fb480bdea2a56337aa1ef999cb53d Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 31 Oct 2026 10:24:14 +0100 Subject: [PATCH 3/6] test void cast is not fixed --- tests/Rules/Cast/UselessCastRuleTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Rules/Cast/UselessCastRuleTest.php b/tests/Rules/Cast/UselessCastRuleTest.php index 1b403a3..7fbd1da 100644 --- a/tests/Rules/Cast/UselessCastRuleTest.php +++ b/tests/Rules/Cast/UselessCastRuleTest.php @@ -97,6 +97,12 @@ public function testBug14565(): void $this->analyse([__DIR__ . '/data/bug-14565.php'], []); } + public function testVoidCastIsNotFixed(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->fix(__DIR__ . '/data/bug-14565.php', __DIR__ . '/data/bug-14565.php'); + } + /** * @return iterable */ From 1526610ee3caa4429a1cdf259a94ada276e3e246 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 1 Nov 2026 10:47:36 +0100 Subject: [PATCH 4/6] simplify --- src/Rules/Cast/UselessCastRule.php | 31 +++++---- .../Rules/Cast/data/useless-cast-fixable.php | 43 ++++++++++++ .../Cast/data/useless-cast-fixable.php.fixed | 65 +++++++++++++++---- 3 files changed, 112 insertions(+), 27 deletions(-) diff --git a/src/Rules/Cast/UselessCastRule.php b/src/Rules/Cast/UselessCastRule.php index dac80cd..8693e1a 100644 --- a/src/Rules/Cast/UselessCastRule.php +++ b/src/Rules/Cast/UselessCastRule.php @@ -64,24 +64,23 @@ public function processNode(Node $node, Scope $scope): array $expressionType->describe(VerbosityLevel::typeOnly()), ))->identifier('cast.useless'); - if ($castType->isSuperTypeOf($scope->getNativeType($node->expr))->yes()) { - $ruleErrorBuilder->fixNode($node, static function (Cast $node): Node { - // Reprint the operand tree so parentheses reflect operator precedence. - $traverser = new NodeTraverser(); - $traverser->addVisitor(new class extends NodeVisitorAbstract { - - public function enterNode(Node $node): ?Node - { - if ($node instanceof Expr) { - $node->setAttribute('origNode', null); - } - - return null; + $nativeType = $scope->getNativeType($node->expr); + if ($castType->isSuperTypeOf($nativeType)->yes()) { + $ruleErrorBuilder->fixNode($node, static function (Cast $node) use ($nativeType): Node { + if ($node instanceof Cast\Bool_) { + if (!$nativeType->isBoolean()->yes()) { + return $node; } + } else if ($node instanceof Cast\Int_) { + if (!$nativeType->isInteger()->yes()) { + return $node; + } + } else { + // skip all other casts + return $node; + } - }); - - return $traverser->traverse([$node->expr])[0]; + return $node->expr; }); } elseif ($this->treatPhpDocTypesAsCertain && $this->treatPhpDocTypesAsCertainTip) { $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); diff --git a/tests/Rules/Cast/data/useless-cast-fixable.php b/tests/Rules/Cast/data/useless-cast-fixable.php index 1f7081a..7d0602d 100644 --- a/tests/Rules/Cast/data/useless-cast-fixable.php +++ b/tests/Rules/Cast/data/useless-cast-fixable.php @@ -69,3 +69,46 @@ function casts( (int) ++$integer; (int) strlen($string); } + +class Foo +{ + public static function bug(string $key): void + { + $a = [$key => 1]; + + foreach ($a as $k => $v) { + $l = strlen((string)$k); // type cast is needed for auto-coerced non-decimal-int-string + } + } +} + +// https://3v4l.org/krTct#v +function doFoo() { + $array = [0, 1]; + foreach ((array) $array as &$v) { + $v=2; + } + var_dump($array); +} +function doBar() { + $array = [0, 1]; + foreach ($array as &$v) { + $v=2; + } + var_dump($array); +} +doFoo(); +doBar(); + +class X { + public int $i; + public string $s; +} + +function doBaz(X $x) { + echo (int) $x->i; + echo (int) $x->s; + + echo (string) $x->i; + echo (string) $x->s; +} diff --git a/tests/Rules/Cast/data/useless-cast-fixable.php.fixed b/tests/Rules/Cast/data/useless-cast-fixable.php.fixed index 0fb3f0b..e36fd51 100644 --- a/tests/Rules/Cast/data/useless-cast-fixable.php.fixed +++ b/tests/Rules/Cast/data/useless-cast-fixable.php.fixed @@ -32,10 +32,10 @@ function casts( { $boolean; $integer; - $float; - $string; - $array; - $object; + (float) $float; + (string) $string; + (array) $array; + (object) $object; (bool) $phpDocBoolean; (int) $phpDocInteger; @@ -57,15 +57,58 @@ function casts( 42; true; - 'value'; - [1, 2]; - new stdClass(); - ($integer + 1) * 2; - 2 - ($integer + 1); - ($boolean || true) && false; - ($integer = 1) + 2; + (string) 'value'; + (array) [1, 2]; + (object) new stdClass(); + $integer + 1 * 2; + 2 - $integer + 1; + $boolean || true && false; + $integer = 1 + 2; ($integer + 1) ** 2; $boolean ? $integer : 0; ++$integer; strlen($string); } + +class Foo +{ + public static function bug(string $key): void + { + $a = [$key => 1]; + + foreach ($a as $k => $v) { + $l = strlen((string)$k); // type cast is needed for auto-coerced non-decimal-int-string + } + } +} + +// https://3v4l.org/krTct#v +function doFoo() { + $array = [0, 1]; + foreach ((array) $array as &$v) { + $v=2; + } + var_dump($array); +} +function doBar() { + $array = [0, 1]; + foreach ($array as &$v) { + $v=2; + } + var_dump($array); +} +doFoo(); +doBar(); + +class X { + public int $i; + public string $s; +} + +function doBaz(X $x) { + echo $x->i; + echo (int) $x->s; + + echo (string) $x->i; + echo (string) $x->s; +} From 9eb99a991ccf0adef4942c21c806591cfb3026ed Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 1 Nov 2026 10:49:42 +0100 Subject: [PATCH 5/6] Update UselessCastRule.php --- src/Rules/Cast/UselessCastRule.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Rules/Cast/UselessCastRule.php b/src/Rules/Cast/UselessCastRule.php index 8693e1a..eb444dc 100644 --- a/src/Rules/Cast/UselessCastRule.php +++ b/src/Rules/Cast/UselessCastRule.php @@ -3,11 +3,8 @@ namespace PHPStan\Rules\Cast; use PhpParser\Node; -use PhpParser\Node\Expr; use PhpParser\Node\Expr\Cast; use PhpParser\Node\Expr\Cast\Void_; -use PhpParser\NodeTraverser; -use PhpParser\NodeVisitorAbstract; use PHPStan\Analyser\Scope; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; @@ -71,7 +68,7 @@ public function processNode(Node $node, Scope $scope): array if (!$nativeType->isBoolean()->yes()) { return $node; } - } else if ($node instanceof Cast\Int_) { + } elseif ($node instanceof Cast\Int_) { if (!$nativeType->isInteger()->yes()) { return $node; } From 49c37752abcfacb97137e89df188cf4cb29523e0 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 1 Nov 2026 10:56:55 +0100 Subject: [PATCH 6/6] fix --- Makefile | 2 +- src/Rules/Cast/UselessCastRule.php | 5 ++++- tests/Rules/Cast/data/useless-cast-fixable.php | 1 + tests/Rules/Cast/data/useless-cast-fixable.php.fixed | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 6d79b27..02c6d35 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ tests: .PHONY: lint lint: - php vendor/bin/parallel-lint --colors \ + php vendor/bin/parallel-lint --colors -e php,php3,php4,php5,phtml,phpt,fixed \ src tests .PHONY: cs-install diff --git a/src/Rules/Cast/UselessCastRule.php b/src/Rules/Cast/UselessCastRule.php index eb444dc..78507b3 100644 --- a/src/Rules/Cast/UselessCastRule.php +++ b/src/Rules/Cast/UselessCastRule.php @@ -69,7 +69,10 @@ public function processNode(Node $node, Scope $scope): array return $node; } } elseif ($node instanceof Cast\Int_) { - if (!$nativeType->isInteger()->yes()) { + if ( + $node->expr instanceof Node\Expr\UnaryMinus + || !$nativeType->isInteger()->yes() + ) { return $node; } } else { diff --git a/tests/Rules/Cast/data/useless-cast-fixable.php b/tests/Rules/Cast/data/useless-cast-fixable.php index 7d0602d..4617dd4 100644 --- a/tests/Rules/Cast/data/useless-cast-fixable.php +++ b/tests/Rules/Cast/data/useless-cast-fixable.php @@ -68,6 +68,7 @@ function casts( (int) ($boolean ? $integer : 0); (int) ++$integer; (int) strlen($string); + $b = $integer-(int)-$integer; } class Foo diff --git a/tests/Rules/Cast/data/useless-cast-fixable.php.fixed b/tests/Rules/Cast/data/useless-cast-fixable.php.fixed index e36fd51..9482f11 100644 --- a/tests/Rules/Cast/data/useless-cast-fixable.php.fixed +++ b/tests/Rules/Cast/data/useless-cast-fixable.php.fixed @@ -68,6 +68,7 @@ function casts( $boolean ? $integer : 0; ++$integer; strlen($string); + $b = $integer-(int)-$integer; } class Foo