diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index eab0c85e880..e387e2c6cab 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1646,6 +1646,7 @@ public function enterClassMethod( $isConstructor, $this->attributeReflectionFactory->fromAttrGroups($classMethod->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $classMethod)), $phpDocPureUnlessCallableIsImpureParameters, + $this->phpVersion, ), !$classMethod->isStatic(), ); @@ -1736,6 +1737,7 @@ public function enterPropertyHook( false, $this->attributeReflectionFactory->fromAttrGroups($hook->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $hook)), [], + $this->phpVersion, ), true, ); diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 4ea71d7f8e9..ec52c85dfc7 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -362,6 +362,11 @@ public function supportsReadonlyPropertyReinitializationOnClone(): bool return $this->versionId >= 80300; } + public function supportsNeverReturnType(): bool + { + return $this->versionId >= 80100; + } + public function supportsNeverReturnTypeInArrowFunction(): bool { return $this->versionId >= 80200; diff --git a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php index 290e0cb4b76..15eab7267b4 100644 --- a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php @@ -4,6 +4,7 @@ use PhpParser\Node; use PhpParser\Node\Stmt\ClassMethod; +use PHPStan\Php\PhpVersion; use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\Reflection\Assertions; use PHPStan\Reflection\AttributeReflection; @@ -18,6 +19,7 @@ use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\IntegerType; use PHPStan\Type\MixedType; +use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\StringType; @@ -73,6 +75,7 @@ public function __construct( private bool $isConstructor, array $attributes, array $pureUnlessCallableIsImpureParameters, + PhpVersion $phpVersion, ) { if ($this->classMethod instanceof Node\PropertyHook) { @@ -87,34 +90,38 @@ public function __construct( if ($this->isConstructor) { $realReturnType = new VoidType(); } - if (in_array($name, ['__destruct', '__unset', '__wakeup', '__clone'], true)) { - $realReturnType = new VoidType(); - } - if ($name === '__tostring') { - $realReturnType = new StringType(); - } - if ($name === '__isset') { - $realReturnType = new BooleanType(); - } - if ($name === '__sleep') { - $realReturnType = new ArrayType(new IntegerType(), new StringType()); - } - if ($name === '__set_state') { - $realReturnType = TypeCombinator::intersect(new ObjectWithoutClassType(), $realReturnType); - } - if ($name === '__set') { - $realReturnType = new VoidType(); - } + // Since PHP 8.1 "never" is always accepted as the declared return type of a magic method, + // even when PHP otherwise mandates a specific one. Before 8.1 "never" is just a class name. + if (!$phpVersion->supportsNeverReturnType() || !$realReturnType instanceof NeverType) { + if (in_array($name, ['__destruct', '__unset', '__wakeup', '__clone'], true)) { + $realReturnType = new VoidType(); + } + if ($name === '__tostring') { + $realReturnType = new StringType(); + } + if ($name === '__isset') { + $realReturnType = new BooleanType(); + } + if ($name === '__sleep') { + $realReturnType = new ArrayType(new IntegerType(), new StringType()); + } + if ($name === '__set_state') { + $realReturnType = TypeCombinator::intersect(new ObjectWithoutClassType(), $realReturnType); + } + if ($name === '__set') { + $realReturnType = new VoidType(); + } - if ($name === '__debuginfo') { - $realReturnType = TypeCombinator::intersect(new UnionType([new ArrayType(new MixedType(true), new MixedType(true)), new NullType()]), $realReturnType); - } + if ($name === '__debuginfo') { + $realReturnType = TypeCombinator::intersect(new UnionType([new ArrayType(new MixedType(true), new MixedType(true)), new NullType()]), $realReturnType); + } - if ($name === '__unserialize') { - $realReturnType = new VoidType(); - } - if ($name === '__serialize') { - $realReturnType = new ArrayType(new MixedType(true), new MixedType(true)); + if ($name === '__unserialize') { + $realReturnType = new VoidType(); + } + if ($name === '__serialize') { + $realReturnType = new ArrayType(new MixedType(true), new MixedType(true)); + } } parent::__construct( diff --git a/src/Rules/Playground/MethodNeverRule.php b/src/Rules/Playground/MethodNeverRule.php index fdef4e9396e..7fceba01abf 100644 --- a/src/Rules/Playground/MethodNeverRule.php +++ b/src/Rules/Playground/MethodNeverRule.php @@ -8,7 +8,9 @@ use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use function count; +use function in_array; use function sprintf; +use function strtolower; /** * @implements Rule @@ -33,6 +35,11 @@ public function processNode(Node $node, Scope $scope): array $method = $node->getMethodReflection(); + // PHP does not allow declaring a return type on these at all + if (in_array(strtolower($method->getName()), ['__construct', '__destruct'], true)) { + return []; + } + $returnType = $method->getReturnType(); $helperResult = $this->helper->shouldReturnNever($node, $returnType); if ($helperResult === false) { diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index 718a35b370b..e3da13e1409 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1364,4 +1364,31 @@ public function testBug14893(): void $this->analyse([__DIR__ . '/data/bug-14893.php'], []); } + #[RequiresPhp('>= 8.1.0')] + public function testNeverMagicMethodReturnType(): void + { + $this->analyse([__DIR__ . '/data/never-magic-method-return-type.php'], [ + [ + 'Method NeverMagicMethodReturnType\Foo::__clone() should never return but return statement found.', + 10, + ], + [ + 'Method NeverMagicMethodReturnType\Foo::__serialize() should never return but return statement found.', + 15, + ], + [ + 'Method NeverMagicMethodReturnType\Foo::__toString() should never return but return statement found.', + 20, + ], + [ + 'Method NeverMagicMethodReturnType\Foo::__isset() should never return but return statement found.', + 25, + ], + [ + 'Method NeverMagicMethodReturnType\Foo::__sleep() should never return but return statement found.', + 31, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/never-magic-method-return-type.php b/tests/PHPStan/Rules/Methods/data/never-magic-method-return-type.php new file mode 100644 index 00000000000..7efcae3a406 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/never-magic-method-return-type.php @@ -0,0 +1,57 @@ += 8.1 + +namespace NeverMagicMethodReturnType; + +class Foo +{ + + public function __clone(): never + { + return; + } + + public function __serialize(): never + { + return []; + } + + public function __toString(): never + { + return 'foo'; + } + + public function __isset($name): never + { + return true; + } + + public function __sleep(): never + { + if (rand(0, 1)) { + return ['a']; + } + + throw new \Exception(); + } + + public function __clone2(): never + { + throw new \Exception(); + } + +} + +class Bar +{ + + public function __clone(): void + { + return; + } + + public function __toString(): string + { + return 'foo'; + } + +} diff --git a/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php b/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php index f7ce31dbcf8..b2c2560d01c 100644 --- a/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php +++ b/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php @@ -386,4 +386,28 @@ public function testBug5681(): void $this->analyse([__DIR__ . '/data/bug-5681.php'], []); } + #[RequiresPhp('>= 8.1.0')] + public function testNeverMagicMethod(): void + { + $this->checkExplicitMixedMissingReturn = true; + $this->analyse([__DIR__ . '/data/never-magic-method.php'], [ + [ + 'Method MissingReturnNeverMagicMethod\Foo::__toString() should always throw an exception or terminate script execution but doesn\'t do that.', + 8, + ], + [ + 'Method MissingReturnNeverMagicMethod\Foo::__clone() should always throw an exception or terminate script execution but doesn\'t do that.', + 12, + ], + [ + 'Method MissingReturnNeverMagicMethod\Foo::__serialize() should always throw an exception or terminate script execution but doesn\'t do that.', + 16, + ], + [ + 'Method MissingReturnNeverMagicMethod\Foo::__isset() should always throw an exception or terminate script execution but doesn\'t do that.', + 20, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Missing/data/never-magic-method.php b/tests/PHPStan/Rules/Missing/data/never-magic-method.php new file mode 100644 index 00000000000..6eabb1af8fc --- /dev/null +++ b/tests/PHPStan/Rules/Missing/data/never-magic-method.php @@ -0,0 +1,43 @@ += 8.1 + +namespace MissingReturnNeverMagicMethod; + +class Foo +{ + + public function __toString(): never + { + } + + public function __clone(): never + { + } + + public function __serialize(): never + { + } + + public function __isset($name): never + { + } + + public function __sleep(): never + { + throw new \Exception(); + } + +} + +class Bar +{ + + public function __toString(): string + { + return 'foo'; + } + + public function __clone(): void + { + } + +} diff --git a/tests/PHPStan/Rules/Playground/MethodNeverRulePhp80Test.php b/tests/PHPStan/Rules/Playground/MethodNeverRulePhp80Test.php new file mode 100644 index 00000000000..a040b6b1824 --- /dev/null +++ b/tests/PHPStan/Rules/Playground/MethodNeverRulePhp80Test.php @@ -0,0 +1,42 @@ + + */ +class MethodNeverRulePhp80Test extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new MethodNeverRule(new NeverRuleHelper()); + } + + public function testMagicMethods(): void + { + // "never" is not accepted as a magic method return type before PHP 8.1, + // so the return type PHP mandates still wins. + $this->analyse([__DIR__ . '/data/method-never-php80.php'], [ + [ + 'Method MethodNeverPhp80\MagicMethods::__clone() always throws an exception, it should have return type "never".', + 8, + ], + [ + 'Method MethodNeverPhp80\MagicMethods::__toString() always throws an exception, it should have return type "never".', + 13, + ], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/data/method-never-php-8.0.neon', + ]; + } + +} diff --git a/tests/PHPStan/Rules/Playground/MethodNeverRuleTest.php b/tests/PHPStan/Rules/Playground/MethodNeverRuleTest.php index 5715a8fc38e..a81fed1b5ce 100644 --- a/tests/PHPStan/Rules/Playground/MethodNeverRuleTest.php +++ b/tests/PHPStan/Rules/Playground/MethodNeverRuleTest.php @@ -33,6 +33,14 @@ public function testRule(): void 'Method MethodNever\Foo::doBaz() always terminates script execution, it should have return type "never".', 31, ], + [ + 'Method MethodNever\MagicMethodsWithoutNever::__clone() always throws an exception, it should have return type "never".', + 132, + ], + [ + 'Method MethodNever\MagicMethodsWithoutNever::__toString() always throws an exception, it should have return type "never".', + 137, + ], ]); } diff --git a/tests/PHPStan/Rules/Playground/data/method-never-php-8.0.neon b/tests/PHPStan/Rules/Playground/data/method-never-php-8.0.neon new file mode 100644 index 00000000000..2e63367f9fe --- /dev/null +++ b/tests/PHPStan/Rules/Playground/data/method-never-php-8.0.neon @@ -0,0 +1,2 @@ +parameters: + phpVersion: 80000 diff --git a/tests/PHPStan/Rules/Playground/data/method-never-php80.php b/tests/PHPStan/Rules/Playground/data/method-never-php80.php new file mode 100644 index 00000000000..d85a5b6678d --- /dev/null +++ b/tests/PHPStan/Rules/Playground/data/method-never-php80.php @@ -0,0 +1,18 @@ += 8.1 + +namespace MethodNeverPhp80; + +class MagicMethods +{ + + public function __clone(): never + { + throw new \Exception(); + } + + public function __toString(): never + { + throw new \Exception(); + } + +} diff --git a/tests/PHPStan/Rules/Playground/data/method-never.php b/tests/PHPStan/Rules/Playground/data/method-never.php index 8353da4d4a6..430bf758a32 100644 --- a/tests/PHPStan/Rules/Playground/data/method-never.php +++ b/tests/PHPStan/Rules/Playground/data/method-never.php @@ -1,4 +1,4 @@ -= 8.1 namespace MethodNever; @@ -55,3 +55,88 @@ public function yields(): \Generator } } + +class MagicMethods +{ + + public function __construct() + { + throw new \Exception(); + } + + public function __destruct() + { + throw new \Exception(); + } + + public function __clone(): never + { + throw new \Exception(); + } + + public function __toString(): never + { + throw new \Exception(); + } + + public function __isset($name): never + { + throw new \Exception(); + } + + public function __set($name, $value): never + { + throw new \Exception(); + } + + public function __unset($name): never + { + throw new \Exception(); + } + + public function __sleep(): never + { + throw new \Exception(); + } + + public function __wakeup(): never + { + throw new \Exception(); + } + + public function __serialize(): never + { + throw new \Exception(); + } + + public function __unserialize(array $data): never + { + throw new \Exception(); + } + + public static function __set_state($properties): never + { + throw new \Exception(); + } + + public function __debugInfo(): never + { + throw new \Exception(); + } + +} + +class MagicMethodsWithoutNever +{ + + public function __clone(): void + { + throw new \Exception(); + } + + public function __toString(): string + { + throw new \Exception(); + } + +}